diff --git a/app/api/sandbox/reconnect/__tests__/route.test.ts b/app/api/sandbox/reconnect/__tests__/route.test.ts new file mode 100644 index 00000000..a1ec8a53 --- /dev/null +++ b/app/api/sandbox/reconnect/__tests__/route.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { GET } from "@/app/api/sandbox/reconnect/route"; +import { getSandboxReconnectHandler } from "@/lib/sandbox/getSandboxReconnectHandler"; + +vi.mock("@/lib/sandbox/getSandboxReconnectHandler", () => ({ + getSandboxReconnectHandler: vi.fn(async () => NextResponse.json({ ok: true }, { status: 200 })), +})); + +describe("GET /api/sandbox/reconnect route shell", () => { + it("delegates to getSandboxReconnectHandler", async () => { + const req = new NextRequest("http://localhost/api/sandbox/reconnect?sessionId=s", { + method: "GET", + }); + const res = await GET(req); + + expect(getSandboxReconnectHandler).toHaveBeenCalledWith(req); + expect(res.status).toBe(200); + }); +}); diff --git a/app/api/sandbox/reconnect/route.ts b/app/api/sandbox/reconnect/route.ts new file mode 100644 index 00000000..7efb7cdb --- /dev/null +++ b/app/api/sandbox/reconnect/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getSandboxReconnectHandler } from "@/lib/sandbox/getSandboxReconnectHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: getCorsHeaders(), + }); +} + +/** + * `GET /api/sandbox/reconnect?sessionId=...` — live runtime probe to + * decide whether the sandbox bound to a session is still reachable. + * + * @param request - The incoming request. + * @returns A NextResponse with `{ status, hasSnapshot, expiresAt?, lifecycle }` on 200, or an error. + */ +export async function GET(request: NextRequest) { + return getSandboxReconnectHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/sandbox/__tests__/getSandboxReconnectHandler.test.ts b/lib/sandbox/__tests__/getSandboxReconnectHandler.test.ts new file mode 100644 index 00000000..1a195f1a --- /dev/null +++ b/lib/sandbox/__tests__/getSandboxReconnectHandler.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { getSandboxReconnectHandler } from "@/lib/sandbox/getSandboxReconnectHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import { connectSandbox } from "@/lib/sandbox/factory"; +import { updateSession } from "@/lib/supabase/sessions/updateSession"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); +vi.mock("@/lib/supabase/sessions/selectSessions", () => ({ + selectSessions: vi.fn(), +})); +vi.mock("@/lib/sandbox/factory", () => ({ + connectSandbox: vi.fn(), +})); +vi.mock("@/lib/supabase/sessions/updateSession", () => ({ + updateSession: vi.fn(), +})); + +const ACCOUNT_ID = "acc-1"; +const RUNTIME_STATE = { type: "vercel", sandboxName: "session-sess-1" }; + +const baseRow = { + id: "sess-1", + account_id: ACCOUNT_ID, + sandbox_state: null as unknown, + lifecycle_state: null as string | null, + lifecycle_version: 0, + sandbox_expires_at: null as string | null, + hibernate_after: null as string | null, + last_activity_at: null as string | null, + snapshot_url: null as string | null, +}; + +function makeReq(query = "?sessionId=sess-1"): NextRequest { + return new NextRequest(`http://localhost/api/sandbox/reconnect${query}`, { method: "GET" }); +} + +function fakeAliveSandbox(expiresAt = Date.now() + 1_800_000) { + return { + expiresAt, + exec: vi.fn(async () => ({ + success: true, + exitCode: 0, + stdout: "/workspace", + stderr: "", + truncated: false, + })), + }; +} + +describe("getSandboxReconnectHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT_ID, + orgId: null, + authToken: "k", + }); + vi.mocked(updateSession).mockResolvedValue({} as never); + }); + + it("returns the auth response unchanged when auth fails", async () => { + const fail = NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValueOnce(fail); + + const res = await getSandboxReconnectHandler(makeReq()); + + expect(res).toBe(fail); + }); + + it("returns 400 when sessionId is missing", async () => { + const res = await getSandboxReconnectHandler(makeReq("")); + expect(res.status).toBe(400); + }); + + it("returns 404 when no session exists with the given id", async () => { + vi.mocked(selectSessions).mockResolvedValue([]); + + const res = await getSandboxReconnectHandler(makeReq()); + expect(res.status).toBe(404); + }); + + it("returns 403 when the session is not owned by the authenticated account", async () => { + vi.mocked(selectSessions).mockResolvedValue([ + { ...baseRow, account_id: "someone-else" } as never, + ]); + + const res = await getSandboxReconnectHandler(makeReq()); + expect(res.status).toBe(403); + }); + + it("returns status='no_sandbox' when sandbox_state has no runtime metadata", async () => { + vi.mocked(selectSessions).mockResolvedValue([ + { ...baseRow, sandbox_state: { type: "vercel" } } as never, + ]); + + const res = await getSandboxReconnectHandler(makeReq()); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("no_sandbox"); + expect(body.expiresAt).toBeUndefined(); + expect(connectSandbox).not.toHaveBeenCalled(); + }); + + it("sets hasSnapshot=true when snapshot_url is set on a no_sandbox session", async () => { + vi.mocked(selectSessions).mockResolvedValue([ + { ...baseRow, sandbox_state: null, snapshot_url: "snap://x" } as never, + ]); + + const res = await getSandboxReconnectHandler(makeReq()); + + const body = await res.json(); + expect(body.status).toBe("no_sandbox"); + expect(body.hasSnapshot).toBe(true); + }); + + it("returns status='connected' with expiresAt when the runtime probe succeeds", async () => { + const expiresAt = Date.parse("2099-01-01T00:00:00.000Z"); + vi.mocked(selectSessions).mockResolvedValue([ + { ...baseRow, sandbox_state: RUNTIME_STATE } as never, + ]); + vi.mocked(connectSandbox).mockResolvedValueOnce(fakeAliveSandbox(expiresAt) as never); + + const res = await getSandboxReconnectHandler(makeReq()); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("connected"); + expect(body.expiresAt).toBe(expiresAt); + }); + + it("returns status='expired' and clears runtime state when the probe throws", async () => { + vi.mocked(selectSessions).mockResolvedValue([ + { ...baseRow, sandbox_state: RUNTIME_STATE, lifecycle_state: "active" } as never, + ]); + vi.mocked(connectSandbox).mockRejectedValueOnce(new Error("sandbox not found")); + + const res = await getSandboxReconnectHandler(makeReq()); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("expired"); + expect(body.expiresAt).toBeUndefined(); + expect(updateSession).toHaveBeenCalledWith( + "sess-1", + expect.objectContaining({ + sandbox_state: null, + lifecycle_state: "hibernated", + }), + ); + }); + + it("includes the lifecycle envelope on every 200 response", async () => { + vi.mocked(selectSessions).mockResolvedValue([ + { ...baseRow, sandbox_state: { type: "vercel" } } as never, + ]); + + const res = await getSandboxReconnectHandler(makeReq()); + + const body = await res.json(); + expect(body.lifecycle).toMatchObject({ + serverTime: expect.any(Number), + state: null, + lastActivityAt: null, + hibernateAfter: null, + sandboxExpiresAt: null, + }); + }); +}); diff --git a/lib/sandbox/__tests__/noSandboxResponse.test.ts b/lib/sandbox/__tests__/noSandboxResponse.test.ts new file mode 100644 index 00000000..60f51f6d --- /dev/null +++ b/lib/sandbox/__tests__/noSandboxResponse.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextResponse } from "next/server"; +import { noSandboxResponse } from "@/lib/sandbox/noSandboxResponse"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); + +const baseRow = { + id: "sess-1", + account_id: "acc-1", + sandbox_state: null, + lifecycle_state: null, + lifecycle_version: 0, + sandbox_expires_at: null, + hibernate_after: null, + last_activity_at: null, + snapshot_url: null, +}; + +describe("noSandboxResponse", () => { + it("returns a 200 NextResponse with status='no_sandbox'", async () => { + const res = noSandboxResponse(baseRow as never); + + expect(res).toBeInstanceOf(NextResponse); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("no_sandbox"); + }); + + it("derives hasSnapshot from snapshot_url presence", async () => { + const withSnap = await noSandboxResponse({ + ...baseRow, + snapshot_url: "snap://x", + } as never).json(); + const without = await noSandboxResponse(baseRow as never).json(); + + expect(withSnap.hasSnapshot).toBe(true); + expect(without.hasSnapshot).toBe(false); + }); + + it("includes the lifecycle envelope projected from the row", async () => { + const body = await noSandboxResponse(baseRow as never).json(); + + expect(body.lifecycle).toMatchObject({ + serverTime: expect.any(Number), + state: null, + lastActivityAt: null, + hibernateAfter: null, + sandboxExpiresAt: null, + }); + }); +}); diff --git a/lib/sandbox/__tests__/validateSandboxReconnectRequest.test.ts b/lib/sandbox/__tests__/validateSandboxReconnectRequest.test.ts new file mode 100644 index 00000000..340c8b64 --- /dev/null +++ b/lib/sandbox/__tests__/validateSandboxReconnectRequest.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateSandboxReconnectRequest } from "@/lib/sandbox/validateSandboxReconnectRequest"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); +vi.mock("@/lib/supabase/sessions/selectSessions", () => ({ + selectSessions: vi.fn(), +})); + +const ACCOUNT_ID = "acc-1"; +const baseRow = { id: "sess-1", account_id: ACCOUNT_ID } as never; + +function makeReq(query = "?sessionId=sess-1"): NextRequest { + return new NextRequest(`http://localhost/api/sandbox/reconnect${query}`, { method: "GET" }); +} + +describe("validateSandboxReconnectRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT_ID, + orgId: null, + authToken: "k", + }); + }); + + it("returns the auth response unchanged when auth fails", async () => { + const fail = NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValueOnce(fail); + + const result = await validateSandboxReconnectRequest(makeReq()); + + expect(result).toBe(fail); + }); + + it("returns 400 when sessionId is missing from the query", async () => { + const result = await validateSandboxReconnectRequest(makeReq("")); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns 404 when no session exists with the given id", async () => { + vi.mocked(selectSessions).mockResolvedValue([]); + + const result = await validateSandboxReconnectRequest(makeReq()); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(404); + }); + + it("returns 403 when the session is not owned by the authenticated account", async () => { + vi.mocked(selectSessions).mockResolvedValue([ + { ...baseRow, account_id: "someone-else" } as never, + ]); + + const result = await validateSandboxReconnectRequest(makeReq()); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(403); + }); + + it("returns the validated row + auth on the happy path", async () => { + vi.mocked(selectSessions).mockResolvedValue([baseRow]); + + const result = await validateSandboxReconnectRequest(makeReq()); + + expect(result).not.toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) return; + expect(result.row.id).toBe("sess-1"); + expect(result.auth.accountId).toBe(ACCOUNT_ID); + }); +}); diff --git a/lib/sandbox/getSandboxReconnectHandler.ts b/lib/sandbox/getSandboxReconnectHandler.ts new file mode 100644 index 00000000..98543994 --- /dev/null +++ b/lib/sandbox/getSandboxReconnectHandler.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { buildLifecycle } from "@/lib/sandbox/buildLifecycle"; +import { connectSandbox } from "@/lib/sandbox/factory"; +import { hasRuntimeSandboxState } from "@/lib/sandbox/hasRuntimeSandboxState"; +import { noSandboxResponse } from "@/lib/sandbox/noSandboxResponse"; +import { validateSandboxReconnectRequest } from "@/lib/sandbox/validateSandboxReconnectRequest"; +import { updateSession } from "@/lib/supabase/sessions/updateSession"; +import type { SandboxState } from "@/lib/sandbox/factory"; + +const PROBE_TIMEOUT_MS = 15_000; + +interface ReconnectBody { + status: "connected" | "expired"; + hasSnapshot: boolean; + expiresAt?: number; + lifecycle: ReturnType; +} + +/** + * Handles `GET /api/sandbox/reconnect`. Live runtime probe — actually + * runs a quick command inside the sandbox to verify it is reachable. + * The chat UI calls this on session re-entry / tab refocus to decide + * whether the sandbox can be resumed (`connected`), needs to be + * recreated from a snapshot (`expired`), or has never existed + * (`no_sandbox`). + * + * On `expired`, runtime state is cleared on the session row and + * lifecycle is set to `hibernated` so subsequent reads via + * `GET /api/sandbox/status` agree with the probe. + */ +export async function getSandboxReconnectHandler(request: NextRequest): Promise { + const validated = await validateSandboxReconnectRequest(request); + if (validated instanceof NextResponse) { + return validated; + } + const { row } = validated; + + if (!hasRuntimeSandboxState(row.sandbox_state)) { + return noSandboxResponse(row); + } + + try { + // Safe cast: hasRuntimeSandboxState above narrowed sandbox_state to an + // object with a non-empty `sandboxName` — but the Json type is wider, so + // TS needs the unknown bridge to accept the conversion. + const sandbox = await connectSandbox(row.sandbox_state as unknown as SandboxState); + await sandbox.exec("pwd", sandbox.workingDirectory, PROBE_TIMEOUT_MS); + + const body: ReconnectBody = { + status: "connected", + hasSnapshot: !!row.snapshot_url, + expiresAt: sandbox.expiresAt, + lifecycle: buildLifecycle(row), + }; + return NextResponse.json(body, { status: 200, headers: getCorsHeaders() }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[getSandboxReconnectHandler] probe failed for ${row.id}: ${message}`); + + await updateSession(row.id, { + sandbox_state: null, + lifecycle_state: "hibernated", + sandbox_expires_at: null, + hibernate_after: null, + }); + + const body: ReconnectBody = { + status: "expired", + hasSnapshot: !!row.snapshot_url, + lifecycle: { + serverTime: Date.now(), + state: "hibernated", + lastActivityAt: null, + hibernateAfter: null, + sandboxExpiresAt: null, + }, + }; + return NextResponse.json(body, { status: 200, headers: getCorsHeaders() }); + } +} diff --git a/lib/sandbox/noSandboxResponse.ts b/lib/sandbox/noSandboxResponse.ts new file mode 100644 index 00000000..b0e1d273 --- /dev/null +++ b/lib/sandbox/noSandboxResponse.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { buildLifecycle } from "@/lib/sandbox/buildLifecycle"; +import type { Tables } from "@/types/database.types"; + +/** + * Builds the `status: "no_sandbox"` response shared by sandbox lifecycle + * endpoints (currently `/reconnect`). Used when the session row lacks + * runtime metadata — there is no live sandbox to probe, so report that + * directly along with whether a snapshot exists for resume affordances. + * + * @param row - The `sessions` row. + * @returns A 200 NextResponse with `{status, hasSnapshot, lifecycle}`. + */ +export function noSandboxResponse(row: Tables<"sessions">): NextResponse { + return NextResponse.json( + { + status: "no_sandbox" as const, + hasSnapshot: !!row.snapshot_url, + lifecycle: buildLifecycle(row), + }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/sandbox/validateSandboxReconnectRequest.ts b/lib/sandbox/validateSandboxReconnectRequest.ts new file mode 100644 index 00000000..3d8d3cb7 --- /dev/null +++ b/lib/sandbox/validateSandboxReconnectRequest.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import type { AuthContext } from "@/lib/auth/validateAuthContext"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import type { Tables } from "@/types/database.types"; + +export interface ValidatedSandboxReconnectRequest { + row: Tables<"sessions">; + auth: AuthContext; +} + +/** + * Validates a `GET /api/sandbox/reconnect` request end-to-end: + * 1. Authenticates the caller via Privy Bearer / x-api-key + * 2. Requires a `sessionId` query parameter + * 3. Looks up the session row + * 4. Enforces ownership (the authed account must match `account_id`) + * + * Returns either a 4xx NextResponse describing the first failure, or + * the validated `{ row, auth }` ready for the handler to consume. + * + * @param request - The incoming GET request. + * @returns A NextResponse on validation failure, or the validated row + auth. + */ +export async function validateSandboxReconnectRequest( + request: NextRequest, +): Promise { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return auth; + } + + const sessionId = request.nextUrl.searchParams.get("sessionId"); + if (!sessionId) { + return NextResponse.json( + { status: "error", error: "Missing sessionId" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const rows = await selectSessions({ id: sessionId }); + const row = rows[0]; + + if (!row) { + return NextResponse.json( + { status: "error", error: "Session not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + if (row.account_id !== auth.accountId) { + return NextResponse.json( + { status: "error", error: "Forbidden" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + return { row, auth }; +}