diff --git a/apps/web/app/api/gen2/workspaces/[workspaceId]/git/route.test.ts b/apps/web/app/api/gen2/workspaces/[workspaceId]/git/route.test.ts index ca51bb582..f90520dfc 100644 --- a/apps/web/app/api/gen2/workspaces/[workspaceId]/git/route.test.ts +++ b/apps/web/app/api/gen2/workspaces/[workspaceId]/git/route.test.ts @@ -38,10 +38,30 @@ describe("gen2 git route", () => { const response = await GET(new Request(`${url}?operation=status`), { params, }); - expect(mocks.git).toHaveBeenCalledWith(workspaceId, userId, "status"); + expect(mocks.git).toHaveBeenCalledWith( + workspaceId, + userId, + "status", + "main", + ); expect(await response.json()).toEqual({ output: "## main\n M a.ts\n" }); }); + it("reads Git state from the selected worktree", async () => { + mocks.git.mockResolvedValue("## codev/agent-a\n"); + const response = await GET( + new Request(`${url}?operation=status&worktreeId=agent-a`), + { params }, + ); + expect(mocks.git).toHaveBeenCalledWith( + workspaceId, + userId, + "status", + "agent-a", + ); + expect(await response.json()).toEqual({ output: "## codev/agent-a\n" }); + }); + it("rejects an operation it does not serve", async () => { const response = await GET(new Request(`${url}?operation=push`), { params, diff --git a/apps/web/app/api/gen2/workspaces/[workspaceId]/git/route.ts b/apps/web/app/api/gen2/workspaces/[workspaceId]/git/route.ts index 144f2c385..4c3a66574 100644 --- a/apps/web/app/api/gen2/workspaces/[workspaceId]/git/route.ts +++ b/apps/web/app/api/gen2/workspaces/[workspaceId]/git/route.ts @@ -1,4 +1,8 @@ -import { gen2FilePathSchema, gen2GitOperationSchema } from "@codev/contracts"; +import { + gen2FilePathSchema, + gen2GitOperationSchema, + gen2SupersetWorktreeIdSchema, +} from "@codev/contracts"; import { ApiError, withUser } from "@/lib/http/api-route"; import { getGen2Git, showGen2HeadFile } from "@/lib/gen2/workbench"; @@ -13,10 +17,19 @@ export const GET = withUser( const operation = gen2GitOperationSchema.safeParse( url.searchParams.get("operation"), ); - if (!operation.success) { + const worktreeId = gen2SupersetWorktreeIdSchema.safeParse( + url.searchParams.get("worktreeId") ?? "main", + ); + if (!operation.success || !worktreeId.success) { throw new ApiError("operation must be status, diff, or show.", 400); } if (operation.data === "show") { + if (worktreeId.data !== "main") { + throw new ApiError( + "show is only available for the primary worktree.", + 400, + ); + } const path = gen2FilePathSchema.safeParse(url.searchParams.get("path")); if (!path.success) { throw new ApiError("A valid workspace path is required.", 400); @@ -26,7 +39,12 @@ export const GET = withUser( ); } return Response.json({ - output: await getGen2Git(workspaceId, user.id, operation.data), + output: await getGen2Git( + workspaceId, + user.id, + operation.data, + worktreeId.data, + ), }); }, { errorStatus: 502 }, diff --git a/apps/web/app/api/gen2/workspaces/[workspaceId]/superset/worktrees/route.test.ts b/apps/web/app/api/gen2/workspaces/[workspaceId]/superset/worktrees/route.test.ts new file mode 100644 index 000000000..6a0b80b9a --- /dev/null +++ b/apps/web/app/api/gen2/workspaces/[workspaceId]/superset/worktrees/route.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getApiUser: vi.fn(), + list: vi.fn(), + create: vi.fn(), +})); + +vi.mock("@/lib/http/api", () => ({ + apiError: (error: unknown, status = 400) => + Response.json( + { error: error instanceof Error ? error.message : "request failed" }, + { status }, + ), + getApiUser: mocks.getApiUser, + getApiUserAnyAuth: mocks.getApiUser, +})); +vi.mock("@/lib/gen2/superset", () => ({ + listGen2SupersetWorktrees: mocks.list, + createGen2SupersetWorktree: mocks.create, +})); + +import { GET, POST, maxDuration } from "./route"; + +const workspaceId = "e010bd2c-a3c1-438f-acef-166287a3b1cb"; +const userId = "2f2387ed-4a63-4b05-88cc-266d65f7b82b"; +const params = Promise.resolve({ workspaceId }); +const url = `https://codev.test/api/gen2/workspaces/${workspaceId}/superset/worktrees`; + +describe("Superset worktrees route", () => { + beforeEach(() => { + mocks.getApiUser.mockResolvedValue({ id: userId }); + }); + afterEach(() => vi.resetAllMocks()); + + it("keeps the host bridge's workspace operation time budget", () => { + expect(maxDuration).toBe(60); + }); + + it("lists selectable worktrees", async () => { + mocks.list.mockResolvedValue([{ worktreeId: "main", branch: "main" }]); + const response = await GET(new Request(url), { params }); + expect(mocks.list).toHaveBeenCalledWith(workspaceId, userId); + expect(await response.json()).toEqual({ + worktrees: [{ worktreeId: "main", branch: "main" }], + }); + }); + + it("validates a worktree before creating it", async () => { + const response = await POST( + new Request(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ worktreeId: "../escape", branch: "codev/a" }), + }), + { params }, + ); + expect(response.status).toBe(400); + expect(mocks.create).not.toHaveBeenCalled(); + }); + + it("creates a branch worktree through the authorized library", async () => { + const worktree = { worktreeId: "agent-a", branch: "codev/agent-a" }; + mocks.create.mockResolvedValue(worktree); + const response = await POST( + new Request(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...worktree, baseRef: "HEAD" }), + }), + { params }, + ); + expect(response.status).toBe(201); + expect(mocks.create).toHaveBeenCalledWith(workspaceId, userId, { + ...worktree, + baseRef: "HEAD", + }); + expect(await response.json()).toEqual({ worktree }); + }); +}); diff --git a/apps/web/app/api/gen2/workspaces/[workspaceId]/superset/worktrees/route.ts b/apps/web/app/api/gen2/workspaces/[workspaceId]/superset/worktrees/route.ts new file mode 100644 index 000000000..73e827b62 --- /dev/null +++ b/apps/web/app/api/gen2/workspaces/[workspaceId]/superset/worktrees/route.ts @@ -0,0 +1,35 @@ +import { gen2SupersetWorktreeCreateRequestSchema } from "@codev/contracts"; + +import { + createGen2SupersetWorktree, + listGen2SupersetWorktrees, +} from "@/lib/gen2/superset"; +import { readJson, withUser } from "@/lib/http/api-route"; + +type Params = { workspaceId: string }; + +export const maxDuration = 60; + +export const GET = withUser( + async ({ user, params: { workspaceId } }) => + Response.json({ + worktrees: await listGen2SupersetWorktrees(workspaceId, user.id), + }), + { errorStatus: 502 }, +); + +export const POST = withUser( + async ({ request, user, params: { workspaceId } }) => { + const input = await readJson( + request, + gen2SupersetWorktreeCreateRequestSchema, + ); + return Response.json( + { + worktree: await createGen2SupersetWorktree(workspaceId, user.id, input), + }, + { status: 201 }, + ); + }, + { errorStatus: 502 }, +); diff --git a/apps/web/app/api/gen2/workspaces/[workspaceId]/terminal/route.test.ts b/apps/web/app/api/gen2/workspaces/[workspaceId]/terminal/route.test.ts index 5773b349f..2dc6b47dd 100644 --- a/apps/web/app/api/gen2/workspaces/[workspaceId]/terminal/route.test.ts +++ b/apps/web/app/api/gen2/workspaces/[workspaceId]/terminal/route.test.ts @@ -61,6 +61,20 @@ describe("gen2 terminal route", () => { expect(await response.json()).toEqual({ sessionId: "term-1-2" }); }); + it("forwards a selected worktree to the terminal service", async () => { + mocks.start.mockResolvedValue("term-1-2"); + await POST( + post({ action: "start", rows: 24, columns: 80, worktreeId: "agent-a" }), + { params }, + ); + expect(mocks.start).toHaveBeenCalledWith(workspaceId, userId, { + action: "start", + rows: 24, + columns: 80, + worktreeId: "agent-a", + }); + }); + it("refuses a viewport the guest would reject anyway", async () => { const response = await POST( post({ action: "start", rows: 24, columns: 501 }), @@ -90,6 +104,7 @@ describe("gen2 terminal route", () => { userId, "term-1-2", "ls\n", + "main", ); const resize = await POST( @@ -120,6 +135,11 @@ describe("gen2 terminal route", () => { { params }, ); expect(response.status).toBe(204); - expect(mocks.close).toHaveBeenCalledWith(workspaceId, userId, "term-1-2"); + expect(mocks.close).toHaveBeenCalledWith( + workspaceId, + userId, + "term-1-2", + "main", + ); }); }); diff --git a/apps/web/app/api/gen2/workspaces/[workspaceId]/terminal/route.ts b/apps/web/app/api/gen2/workspaces/[workspaceId]/terminal/route.ts index 88b1028a7..08bda43ae 100644 --- a/apps/web/app/api/gen2/workspaces/[workspaceId]/terminal/route.ts +++ b/apps/web/app/api/gen2/workspaces/[workspaceId]/terminal/route.ts @@ -1,6 +1,7 @@ import { gen2TerminalActionSchema, gen2TerminalSessionIdSchema, + gen2SupersetWorktreeIdSchema, } from "@codev/contracts"; import { ApiError, readJson, withUser } from "@/lib/http/api-route"; @@ -31,10 +32,17 @@ export const POST = withUser( user.id, input.sessionId, input.data, + input.worktreeId, ); return new Response(null, { status: 204 }); case "resize": - await resizeGen2Terminal(workspaceId, user.id, input.sessionId, input); + await resizeGen2Terminal( + workspaceId, + user.id, + input.sessionId, + input, + input.worktreeId, + ); return new Response(null, { status: 204 }); case "poll": return Response.json( @@ -43,6 +51,7 @@ export const POST = withUser( user.id, input.sessionId, input.after, + input.worktreeId, ), ); } @@ -55,10 +64,18 @@ export const DELETE = withUser( const sessionId = gen2TerminalSessionIdSchema.safeParse( new URL(request.url).searchParams.get("sessionId"), ); - if (!sessionId.success) { + const worktreeId = gen2SupersetWorktreeIdSchema.safeParse( + new URL(request.url).searchParams.get("worktreeId") ?? "main", + ); + if (!sessionId.success || !worktreeId.success) { throw new ApiError("A valid terminal session is required.", 400); } - await closeGen2Terminal(workspaceId, user.id, sessionId.data); + await closeGen2Terminal( + workspaceId, + user.id, + sessionId.data, + worktreeId.data, + ); return new Response(null, { status: 204 }); }, { errorStatus: 502 }, diff --git a/apps/web/lib/gen2/superset-runtime-feature.ts b/apps/web/lib/gen2/superset-runtime-feature.ts new file mode 100644 index 000000000..7707185d4 --- /dev/null +++ b/apps/web/lib/gen2/superset-runtime-feature.ts @@ -0,0 +1,10 @@ +import "server-only"; + +/** + * The file bridge and the broader Superset runtime migrate independently. + * Keep terminal/Git/worktree replacement opt-in until it has passed a real + * workspace acceptance run and the old guest endpoints can be retired. + */ +export function isGen2SupersetRuntimeEnabled() { + return process.env.CODEV_SUPERSET_RUNTIME_ENABLED === "true"; +} diff --git a/apps/web/lib/gen2/superset-worktrees.test.ts b/apps/web/lib/gen2/superset-worktrees.test.ts new file mode 100644 index 000000000..291aebe4c --- /dev/null +++ b/apps/web/lib/gen2/superset-worktrees.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + member: vi.fn(), + list: vi.fn(), + create: vi.fn(), +})); + +vi.mock("./workspaces", () => ({ + requireGen2Member: (...args: unknown[]) => mocks.member(...args), +})); + +vi.mock("../runtime/orchestrator-superset-runtime", () => ({ + listSupersetWorktrees: (...args: unknown[]) => mocks.list(...args), + createSupersetWorktree: (...args: unknown[]) => mocks.create(...args), +})); + +vi.mock("../runtime/orchestrator-request", () => ({ + OrchestratorError: class OrchestratorError extends Error {}, + orchestratorRequest: vi.fn(), +})); + +const { createGen2SupersetWorktree, listGen2SupersetWorktrees } = + await import("./superset"); + +const workspaceId = "11111111-1111-4111-8111-111111111111"; +const userId = "22222222-2222-4222-8222-222222222222"; +const originalSupersetRuntime = process.env.CODEV_SUPERSET_RUNTIME_ENABLED; + +describe("Gen 2 Superset worktrees", () => { + beforeEach(() => { + vi.resetAllMocks(); + process.env.CODEV_SUPERSET_RUNTIME_ENABLED = "true"; + mocks.member.mockResolvedValue({ status: "ready", role: "owner" }); + }); + + it("does not expose runtime worktrees while the migration flag is off", async () => { + delete process.env.CODEV_SUPERSET_RUNTIME_ENABLED; + await expect( + listGen2SupersetWorktrees(workspaceId, userId), + ).rejects.toThrow(/not enabled/); + expect(mocks.member).not.toHaveBeenCalled(); + expect(mocks.list).not.toHaveBeenCalled(); + }); + + it("checks membership before calling the private host bridge", async () => { + mocks.member.mockRejectedValue(new Error("not a member")); + await expect( + listGen2SupersetWorktrees(workspaceId, userId), + ).rejects.toThrow("not a member"); + expect(mocks.list).not.toHaveBeenCalled(); + }); + + it("does not let a viewer create an isolated checkout", async () => { + mocks.member.mockResolvedValue({ status: "ready", role: "viewer" }); + await expect( + createGen2SupersetWorktree(workspaceId, userId, { + worktreeId: "agent-a", + branch: "codev/agent-a", + }), + ).rejects.toThrow(/Edit permission/); + expect(mocks.create).not.toHaveBeenCalled(); + }); + + it("creates and returns a host-owned worktree for an editor", async () => { + mocks.create.mockResolvedValue({ + worktreeId: "agent-a", + branch: "codev/agent-a", + }); + await expect( + createGen2SupersetWorktree(workspaceId, userId, { + worktreeId: "agent-a", + branch: "codev/agent-a", + baseRef: "HEAD", + }), + ).resolves.toEqual({ worktreeId: "agent-a", branch: "codev/agent-a" }); + expect(mocks.create).toHaveBeenCalledWith(workspaceId, { + worktreeId: "agent-a", + branch: "codev/agent-a", + baseRef: "HEAD", + }); + }); +}); + +afterEach(() => { + if (originalSupersetRuntime === undefined) { + delete process.env.CODEV_SUPERSET_RUNTIME_ENABLED; + } else { + process.env.CODEV_SUPERSET_RUNTIME_ENABLED = originalSupersetRuntime; + } +}); diff --git a/apps/web/lib/gen2/superset.ts b/apps/web/lib/gen2/superset.ts index 45e5dba2e..a1d1fbaf6 100644 --- a/apps/web/lib/gen2/superset.ts +++ b/apps/web/lib/gen2/superset.ts @@ -8,9 +8,12 @@ import { gen2SupersetMoveEntryResponseSchema, gen2SupersetReadFileResponseSchema, gen2SupersetSaveFileResponseSchema, + gen2SupersetWorktreeCreateResponseSchema, + gen2SupersetWorktreeListResponseSchema, type Gen2SupersetFile, type Gen2SupersetExternalFileChange, type Gen2SupersetEntry, + type Gen2SupersetWorktree, } from "@codev/contracts"; import { z } from "zod"; @@ -18,6 +21,10 @@ import { OrchestratorError, orchestratorRequest, } from "../runtime/orchestrator-request"; +import { + createSupersetWorktree, + listSupersetWorktrees, +} from "../runtime/orchestrator-superset-runtime"; import { canRunGen2Agent } from "./agent-policy"; import { Gen2AccessError, @@ -25,10 +32,17 @@ import { Gen2LifecycleError, } from "./errors"; import { recordGen2DocumentSave } from "./collaboration-documents"; +import { isGen2SupersetRuntimeEnabled } from "./superset-runtime-feature"; import { requireGen2Member } from "./workspaces"; const healthSchema = z.object({ status: z.literal("ok") }); +function requireSupersetRuntime() { + if (!isGen2SupersetRuntimeEnabled()) { + throw new Gen2LifecycleError("The Superset runtime is not enabled.", 503); + } +} + /** Keep the Superset service private to the guest; expose only readiness. */ export async function getGen2SupersetHealth( workspaceId: string, @@ -237,3 +251,32 @@ export async function listGen2SupersetExternalFileChanges( await response.json(), ).changes; } + +export async function listGen2SupersetWorktrees( + workspaceId: string, + userId: string, +): Promise { + requireSupersetRuntime(); + await requireReadySupersetMember(workspaceId, userId); + return gen2SupersetWorktreeListResponseSchema.parse({ + worktrees: await listSupersetWorktrees(workspaceId), + }).worktrees; +} + +export async function createGen2SupersetWorktree( + workspaceId: string, + userId: string, + input: { worktreeId: string; branch: string; baseRef?: string | undefined }, +): Promise { + requireSupersetRuntime(); + const membership = await requireReadySupersetMember(workspaceId, userId); + if (membership.role === "viewer") { + throw new Gen2AccessError( + "Edit permission is required to create a worktree.", + 403, + ); + } + return gen2SupersetWorktreeCreateResponseSchema.parse({ + worktree: await createSupersetWorktree(workspaceId, input), + }).worktree; +} diff --git a/apps/web/lib/gen2/terminals.test.ts b/apps/web/lib/gen2/terminals.test.ts index e66667095..474cbdb7b 100644 --- a/apps/web/lib/gen2/terminals.test.ts +++ b/apps/web/lib/gen2/terminals.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ requireMember: vi.fn(), @@ -7,6 +7,11 @@ const mocks = vi.hoisted(() => ({ resize: vi.fn(), poll: vi.fn(), close: vi.fn(), + supersetStart: vi.fn(), + supersetInput: vi.fn(), + supersetResize: vi.fn(), + supersetPoll: vi.fn(), + supersetClose: vi.fn(), })); vi.mock("./workspaces", () => ({ @@ -21,6 +26,15 @@ vi.mock("../runtime/orchestrator-terminals", () => ({ closeSandboxTerminal: (...args: unknown[]) => mocks.close(...args), })); +vi.mock("../runtime/orchestrator-superset-runtime", () => ({ + startSupersetTerminal: (...args: unknown[]) => mocks.supersetStart(...args), + sendSupersetTerminalInput: (...args: unknown[]) => + mocks.supersetInput(...args), + resizeSupersetTerminal: (...args: unknown[]) => mocks.supersetResize(...args), + pollSupersetTerminal: (...args: unknown[]) => mocks.supersetPoll(...args), + closeSupersetTerminal: (...args: unknown[]) => mocks.supersetClose(...args), +})); + const { closeGen2Terminal, pollGen2Terminal, @@ -31,10 +45,12 @@ const { const workspaceId = "11111111-1111-4111-8111-111111111111"; const userId = "22222222-2222-4222-8222-222222222222"; const sessionId = "term-1-2"; +const originalSupersetRuntime = process.env.CODEV_SUPERSET_RUNTIME_ENABLED; describe("gen2 terminals", () => { beforeEach(() => { vi.resetAllMocks(); + delete process.env.CODEV_SUPERSET_RUNTIME_ENABLED; mocks.requireMember.mockResolvedValue({ id: workspaceId, status: "ready", @@ -90,4 +106,26 @@ describe("gen2 terminals", () => { await sendGen2TerminalInput(workspaceId, userId, sessionId, "ls\n"); expect(mocks.input).toHaveBeenCalledWith(workspaceId, sessionId, "ls\n"); }); + + it("uses the Superset terminal lifecycle when the runtime flag is enabled", async () => { + process.env.CODEV_SUPERSET_RUNTIME_ENABLED = "true"; + mocks.supersetStart.mockResolvedValue(sessionId); + await expect( + startGen2Terminal(workspaceId, userId, { rows: 24, columns: 80 }), + ).resolves.toBe(sessionId); + expect(mocks.supersetStart).toHaveBeenCalledWith(workspaceId, { + worktreeId: "main", + rows: 24, + columns: 80, + }); + expect(mocks.start).not.toHaveBeenCalled(); + }); +}); + +afterEach(() => { + if (originalSupersetRuntime === undefined) { + delete process.env.CODEV_SUPERSET_RUNTIME_ENABLED; + } else { + process.env.CODEV_SUPERSET_RUNTIME_ENABLED = originalSupersetRuntime; + } }); diff --git a/apps/web/lib/gen2/terminals.ts b/apps/web/lib/gen2/terminals.ts index c40f76a79..d11bf81e7 100644 --- a/apps/web/lib/gen2/terminals.ts +++ b/apps/web/lib/gen2/terminals.ts @@ -7,9 +7,19 @@ import { sendSandboxTerminalInput, startSandboxTerminal, } from "../runtime/orchestrator-terminals"; +import { + closeSupersetTerminal, + pollSupersetTerminal, + resizeSupersetTerminal, + sendSupersetTerminalInput, + startSupersetTerminal, +} from "../runtime/orchestrator-superset-runtime"; import { canRunGen2Agent } from "./agent-policy"; import { Gen2LifecycleError } from "./errors"; import { requireGen2Member } from "./workspaces"; +import { isGen2SupersetRuntimeEnabled } from "./superset-runtime-feature"; + +const PRIMARY_WORKTREE_ID = "main"; /** * A shell on the workspace's own machine — the same `/workspace` Codex edits. @@ -37,10 +47,20 @@ async function requireReadyMember(workspaceId: string, userId: string) { export async function startGen2Terminal( workspaceId: string, userId: string, - size: { rows: number; columns: number }, + size: { rows: number; columns: number; worktreeId?: string }, ) { await requireReadyMember(workspaceId, userId); - return startSandboxTerminal(workspaceId, size); + if (isGen2SupersetRuntimeEnabled()) { + return startSupersetTerminal(workspaceId, { + worktreeId: size.worktreeId ?? PRIMARY_WORKTREE_ID, + rows: size.rows, + columns: size.columns, + }); + } + return startSandboxTerminal(workspaceId, { + rows: size.rows, + columns: size.columns, + }); } export async function sendGen2TerminalInput( @@ -48,8 +68,17 @@ export async function sendGen2TerminalInput( userId: string, sessionId: string, data: string, + worktreeId = PRIMARY_WORKTREE_ID, ) { await requireGen2Member(workspaceId, userId); + if (isGen2SupersetRuntimeEnabled()) { + await sendSupersetTerminalInput(workspaceId, { + worktreeId, + sessionId, + data, + }); + return; + } await sendSandboxTerminalInput(workspaceId, sessionId, data); } @@ -58,8 +87,17 @@ export async function resizeGen2Terminal( userId: string, sessionId: string, size: { rows: number; columns: number }, + worktreeId = PRIMARY_WORKTREE_ID, ) { await requireGen2Member(workspaceId, userId); + if (isGen2SupersetRuntimeEnabled()) { + await resizeSupersetTerminal(workspaceId, { + worktreeId, + sessionId, + ...size, + }); + return; + } await resizeSandboxTerminal(workspaceId, sessionId, size); } @@ -68,8 +106,16 @@ export async function pollGen2Terminal( userId: string, sessionId: string, after: number, + worktreeId = PRIMARY_WORKTREE_ID, ) { await requireGen2Member(workspaceId, userId); + if (isGen2SupersetRuntimeEnabled()) { + return pollSupersetTerminal(workspaceId, { + worktreeId, + sessionId, + after, + }); + } return pollSandboxTerminal(workspaceId, sessionId, after); } @@ -77,7 +123,12 @@ export async function closeGen2Terminal( workspaceId: string, userId: string, sessionId: string, + worktreeId = PRIMARY_WORKTREE_ID, ) { await requireGen2Member(workspaceId, userId); + if (isGen2SupersetRuntimeEnabled()) { + await closeSupersetTerminal(workspaceId, { sessionId, worktreeId }); + return; + } await closeSandboxTerminal(workspaceId, sessionId); } diff --git a/apps/web/lib/gen2/workbench.test.ts b/apps/web/lib/gen2/workbench.test.ts index b61356d35..06573a72b 100644 --- a/apps/web/lib/gen2/workbench.test.ts +++ b/apps/web/lib/gen2/workbench.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ requireMember: vi.fn(), @@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({ git: vi.fn(), read: vi.fn(), write: vi.fn(), + supersetGit: vi.fn(), })); vi.mock("./workspaces", () => ({ @@ -19,6 +20,22 @@ vi.mock("../runtime/orchestrator-files", () => ({ writeSandboxFile: (...args: unknown[]) => mocks.write(...args), })); +vi.mock("../runtime/orchestrator-superset-runtime", () => ({ + getSupersetGitOutput: (...args: unknown[]) => mocks.supersetGit(...args), +})); + +vi.mock("../runtime/orchestrator-request", () => ({ + OrchestratorError: class OrchestratorError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + } + }, + orchestratorRequest: vi.fn(), +})); + const { OrchestratorError } = await import("../runtime/orchestrator-request"); const { Gen2FileConflictError } = await import("./errors"); const { @@ -32,10 +49,12 @@ const { const workspaceId = "11111111-1111-4111-8111-111111111111"; const userId = "22222222-2222-4222-8222-222222222222"; +const originalSupersetRuntime = process.env.CODEV_SUPERSET_RUNTIME_ENABLED; describe("gen2 workbench", () => { beforeEach(() => { vi.resetAllMocks(); + delete process.env.CODEV_SUPERSET_RUNTIME_ENABLED; mocks.requireMember.mockResolvedValue({ id: workspaceId, status: "ready", @@ -140,4 +159,26 @@ describe("gen2 workbench", () => { showGen2HeadFile(workspaceId, userId, "new.ts"), ).resolves.toEqual({ contents: "", exists: false }); }); + + it("gets Git state from the Superset host when the runtime flag is enabled", async () => { + process.env.CODEV_SUPERSET_RUNTIME_ENABLED = "true"; + mocks.supersetGit.mockResolvedValue("## main\n"); + await expect(getGen2Git(workspaceId, userId, "status")).resolves.toBe( + "## main\n", + ); + expect(mocks.supersetGit).toHaveBeenCalledWith( + workspaceId, + "main", + "status", + ); + expect(mocks.git).not.toHaveBeenCalled(); + }); +}); + +afterEach(() => { + if (originalSupersetRuntime === undefined) { + delete process.env.CODEV_SUPERSET_RUNTIME_ENABLED; + } else { + process.env.CODEV_SUPERSET_RUNTIME_ENABLED = originalSupersetRuntime; + } }); diff --git a/apps/web/lib/gen2/workbench.ts b/apps/web/lib/gen2/workbench.ts index 662929f41..713dd0749 100644 --- a/apps/web/lib/gen2/workbench.ts +++ b/apps/web/lib/gen2/workbench.ts @@ -8,6 +8,7 @@ import { readSandboxFile, writeSandboxFile, } from "../runtime/orchestrator-files"; +import { getSupersetGitOutput } from "../runtime/orchestrator-superset-runtime"; import { OrchestratorError } from "../runtime/orchestrator-request"; import { attachGitStatus, @@ -17,6 +18,7 @@ import { import { canRunGen2Agent } from "./agent-policy"; import { Gen2FileConflictError, Gen2LifecycleError } from "./errors"; import { requireGen2Member } from "./workspaces"; +import { isGen2SupersetRuntimeEnabled } from "./superset-runtime-feature"; /** * Files and Git for a Gen 2 workspace, addressed against the same Firecracker @@ -189,8 +191,17 @@ export async function getGen2Git( workspaceId: string, userId: string, operation: "status" | "diff", + worktreeId = "main", ) { await requireGen2Member(workspaceId, userId); + if (isGen2SupersetRuntimeEnabled()) { + return getSupersetGitOutput(workspaceId, worktreeId, operation); + } + if (worktreeId !== "main") { + throw new Gen2LifecycleError( + "Start the Superset runtime to inspect another worktree.", + ); + } return getSandboxGitOutput(workspaceId, operation); } diff --git a/apps/web/lib/runtime/orchestrator-superset-runtime.ts b/apps/web/lib/runtime/orchestrator-superset-runtime.ts new file mode 100644 index 000000000..8ebb2a663 --- /dev/null +++ b/apps/web/lib/runtime/orchestrator-superset-runtime.ts @@ -0,0 +1,122 @@ +import "server-only"; + +import { z } from "zod"; + +import { orchestratorRequest } from "./orchestrator-request"; + +const terminalPollSchema = z.object({ + chunks: z.array( + z.object({ sequence: z.number().int().nonnegative(), data: z.string() }), + ), + nextSequence: z.number().int().nonnegative(), + exited: z.boolean(), + exitCode: z.number().int().nullable(), +}); + +const worktreeSchema = z.object({ + worktreeId: z.string(), + branch: z.string(), +}); + +async function requestSupersetRuntime( + workspaceId: string, + method: "GET" | "POST" | "DELETE", + operation: string, + body?: unknown, +) { + return orchestratorRequest( + method, + `/v1/sandboxes/${workspaceId}/superset/runtime/${operation}`, + body, + ); +} + +export async function getSupersetGitOutput( + workspaceId: string, + worktreeId: string, + operation: "status" | "diff", +) { + const response = await requestSupersetRuntime(workspaceId, "POST", "git", { + worktreeId, + operation, + }); + return z.object({ output: z.string() }).parse(await response.json()).output; +} + +export async function listSupersetWorktrees(workspaceId: string) { + const response = await requestSupersetRuntime( + workspaceId, + "GET", + "worktrees", + ); + return z + .object({ worktrees: z.array(worktreeSchema) }) + .parse(await response.json()).worktrees; +} + +export async function createSupersetWorktree( + workspaceId: string, + input: { worktreeId: string; branch: string; baseRef?: string | undefined }, +) { + const response = await requestSupersetRuntime( + workspaceId, + "POST", + "worktrees", + input, + ); + return z.object({ worktree: worktreeSchema }).parse(await response.json()) + .worktree; +} + +export async function startSupersetTerminal( + workspaceId: string, + input: { worktreeId: string; rows: number; columns: number }, +) { + const response = await requestSupersetRuntime( + workspaceId, + "POST", + "terminal/start", + input, + ); + return z.object({ sessionId: z.string() }).parse(await response.json()) + .sessionId; +} + +export async function sendSupersetTerminalInput( + workspaceId: string, + input: { worktreeId: string; sessionId: string; data: string }, +) { + await requestSupersetRuntime(workspaceId, "POST", "terminal/input", input); +} + +export async function resizeSupersetTerminal( + workspaceId: string, + input: { + worktreeId: string; + sessionId: string; + rows: number; + columns: number; + }, +) { + await requestSupersetRuntime(workspaceId, "POST", "terminal/resize", input); +} + +export async function pollSupersetTerminal( + workspaceId: string, + input: { worktreeId: string; sessionId: string; after: number }, +) { + const response = await requestSupersetRuntime( + workspaceId, + "POST", + "terminal/poll", + input, + ); + return terminalPollSchema.parse(await response.json()); +} + +export async function closeSupersetTerminal( + workspaceId: string, + input: { sessionId: string; worktreeId: string }, +) { + await requestSupersetRuntime(workspaceId, "DELETE", "terminal", input); +} diff --git a/docs/SUPERSET_ADOPTION_MANIFEST.md b/docs/SUPERSET_ADOPTION_MANIFEST.md index 986db0609..8c1b6d8fd 100644 --- a/docs/SUPERSET_ADOPTION_MANIFEST.md +++ b/docs/SUPERSET_ADOPTION_MANIFEST.md @@ -34,8 +34,21 @@ The current concrete reuse is the separately built Superset host artifact, its PTY daemon and host-local persistence, and the private CoDev file bridge. The feature-gated file operations traverse CoDev authorization, the orchestrator, `codev-guestd`, and the host service rather than reimplementing file management -in the browser. Terminal, Git/worktree, and agent-session replacement remain -the next proof points. +in the browser. + +The next slice is implemented behind `CODEV_SUPERSET_RUNTIME_ENABLED=true`: +Superset's host service owns terminal lifecycle, Git status/diff, and managed +branch-worktree creation/listing. CoDev keeps the public routes, membership and +role checks, then sends a private request through the orchestrator and +`codev-guestd`; the browser never receives the host bridge secret. Terminal +requests select a worktree (defaulting to `main`), and the host verifies that a +session belongs to that worktree before accepting input, resize, poll, or +close. CoDev-launched shells omit Superset's host-wide provider account and +run through `codev-shell`, never as the host-service account. + +This is still an internal flag-gated integration, not a rollout: it requires a +guest-image smoke test before enabling it for members. Superset agent-session +replacement and the per-launch provider credential adapter remain unbuilt. After the terminal, Git status/diff, and worktree bridge slice, make a stop/go decision. If those operations cannot run through the real Superset host service diff --git a/docs/SUPERSET_WORKSPACE_OWNERSHIP.md b/docs/SUPERSET_WORKSPACE_OWNERSHIP.md index 337cefcb2..108576595 100644 --- a/docs/SUPERSET_WORKSPACE_OWNERSHIP.md +++ b/docs/SUPERSET_WORKSPACE_OWNERSHIP.md @@ -127,6 +127,18 @@ creation of further workspaces; using it unchanged cannot meet the multiple-worktree requirement. The CoDev mode must not inherit Superset's host-wide managed provider environment for multi-member agent sessions. +### Current bridge boundary + +`CODEV_SUPERSET_RUNTIME_ENABLED` is deliberately off by default. When enabled +for an internal workspace, the existing authenticated terminal and Git routes +call the private Superset runtime bridge for the selected worktree; the +`/superset/worktrees` route lists and creates host-owned checkouts. Members can +read Git and use terminals, while only editors and owners can create a +worktree. The host service validates its bridge credential and terminal +worktree ownership; it starts CoDev shells via `codev-shell` without a +host-wide Superset provider account. Do not enable the flag in production until +the guest image has passed an end-to-end terminal, Git, and worktree smoke test. + ## Phase 1 proof and exit criteria The first implementation phase is an internal, flag-gated prototype. It is diff --git a/packages/contracts/src/gen2.ts b/packages/contracts/src/gen2.ts index a6586d138..c80448fc4 100644 --- a/packages/contracts/src/gen2.ts +++ b/packages/contracts/src/gen2.ts @@ -397,6 +397,41 @@ export const gen2SupersetExternalFileChangesResponseSchema = z.object({ changes: z.array(gen2SupersetExternalFileChangeSchema), }); +/** A branch checkout owned by the Superset host service. */ +export const gen2SupersetWorktreeSchema = z.object({ + worktreeId: gen2SupersetWorktreeIdSchema, + branch: z.string().min(1).max(255), +}); + +export const gen2SupersetWorktreeListResponseSchema = z.object({ + worktrees: z.array(gen2SupersetWorktreeSchema), +}); + +export const gen2SupersetWorktreeCreateRequestSchema = z.object({ + worktreeId: gen2SupersetWorktreeIdSchema.refine( + (value) => value !== "main", + "The primary worktree already exists.", + ), + branch: z + .string() + .min(1) + .max(255) + .refine( + (value) => + !value.startsWith("-") && + !value.includes("..") && + !/[~^:?*[\\\s]/.test(value) && + !value.endsWith(".") && + !value.endsWith("/"), + "Branch name is invalid.", + ), + baseRef: z.string().min(1).max(255).optional(), +}); + +export const gen2SupersetWorktreeCreateResponseSchema = z.object({ + worktree: gen2SupersetWorktreeSchema, +}); + export const gen2GitOperationSchema = z.enum(["status", "diff", "show"]); export const gen2GitResponseSchema = z.object({ output: z.string() }); @@ -411,22 +446,33 @@ const gen2TerminalDimensions = { columns: z.number().int().min(1).max(500), }; +const gen2TerminalWorktree = { + worktreeId: gen2SupersetWorktreeIdSchema.default("main"), +}; + export const gen2TerminalActionSchema = z.discriminatedUnion("action", [ - z.object({ action: z.literal("start"), ...gen2TerminalDimensions }), + z.object({ + action: z.literal("start"), + ...gen2TerminalDimensions, + ...gen2TerminalWorktree, + }), z.object({ action: z.literal("input"), sessionId: gen2TerminalSessionIdSchema, data: z.string().max(64 * 1_024), + ...gen2TerminalWorktree, }), z.object({ action: z.literal("resize"), sessionId: gen2TerminalSessionIdSchema, ...gen2TerminalDimensions, + ...gen2TerminalWorktree, }), z.object({ action: z.literal("poll"), sessionId: gen2TerminalSessionIdSchema, after: z.number().int().nonnegative(), + ...gen2TerminalWorktree, }), ]); @@ -479,6 +525,10 @@ export type Gen2SupersetCreateEntryRequest = z.infer< export type Gen2SupersetExternalFileChange = z.infer< typeof gen2SupersetExternalFileChangeSchema >; +export type Gen2SupersetWorktree = z.infer; +export type Gen2SupersetWorktreeCreateRequest = z.infer< + typeof gen2SupersetWorktreeCreateRequestSchema +>; export type Gen2GitOperation = z.infer; export type Gen2TerminalAction = z.infer; export type Gen2TerminalPollResponse = z.infer< diff --git a/services/orchestrator/src/backend/firecracker.rs b/services/orchestrator/src/backend/firecracker.rs index 601a4d9a5..5d7da815f 100644 --- a/services/orchestrator/src/backend/firecracker.rs +++ b/services/orchestrator/src/backend/firecracker.rs @@ -702,6 +702,24 @@ impl FirecrackerBackend { machine.guest.superset_file_changes(worktree_id).await } + pub async fn superset_runtime( + &self, + workspace_id: &str, + method: &str, + operation: &str, + body: Option<&serde_json::Value>, + ) -> Result { + let machine = self.machine(workspace_id).await?; + let result = machine + .guest + .superset_runtime(method, operation, body) + .await?; + if !(method == "POST" && operation == "terminal/poll") { + self.mark_activity(&machine); + } + Ok(result) + } + pub async fn write_file( &self, workspace_id: &str, diff --git a/services/orchestrator/src/backend/mod.rs b/services/orchestrator/src/backend/mod.rs index e9aa56fd3..cb355d265 100644 --- a/services/orchestrator/src/backend/mod.rs +++ b/services/orchestrator/src/backend/mod.rs @@ -413,6 +413,26 @@ impl Backend { } } + pub async fn superset_runtime( + &self, + workspace_id: &str, + method: &str, + operation: &str, + body: Option<&serde_json::Value>, + ) -> Result { + match self { + Self::Fake(_) => Err(RuntimeError::Unavailable( + "Superset host service is unavailable in the fake backend".into(), + )), + #[cfg(target_os = "linux")] + Self::Firecracker(backend) => { + backend + .superset_runtime(workspace_id, method, operation, body) + .await + } + } + } + pub async fn write_file( &self, workspace_id: &str, diff --git a/services/orchestrator/src/guest.rs b/services/orchestrator/src/guest.rs index 943fad0a9..389d1bac1 100644 --- a/services/orchestrator/src/guest.rs +++ b/services/orchestrator/src/guest.rs @@ -254,6 +254,9 @@ impl GuestService { if path == "/v1/superset/file/changes" && method == "POST" { return self.superset_file_changes(body); } + if let Some(operation) = path.strip_prefix("/v1/superset/runtime/") { + return self.superset_runtime(method, operation, body); + } if let Some(worktree_id) = path.strip_prefix("/v1/worktrees/") { let (worktree_id, action_and_query) = worktree_id.split_once('/').unwrap_or((worktree_id, "")); @@ -515,6 +518,138 @@ impl GuestService { ) } + /// Fixed, guest-validated forwarding for Superset-owned terminal, Git and + /// worktree operations. The orchestrator never forwards an arbitrary host + /// URL, and the browser never receives the loopback bridge secret. + fn superset_runtime(&self, method: &str, operation: &str, body: &[u8]) -> GuestResponse { + let request = if body.is_empty() { + serde_json::Value::Null + } else { + match serde_json::from_slice::(body) { + Ok(value) => value, + Err(_) => return GuestResponse::error(400, "invalid Superset runtime request"), + } + }; + let worktree_id = || { + request + .get("worktreeId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| GuestResponse::error(400, "worktree ID is required")) + }; + let terminal_id = || { + request + .get("sessionId") + .and_then(serde_json::Value::as_str) + .filter(|value| { + !value.is_empty() + && value.len() <= 128 + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') + }) + .ok_or_else(|| GuestResponse::error(400, "invalid terminal session ID")) + }; + match (method, operation) { + ("POST", "git") => { + let worktree_id = match worktree_id() { + Ok(value) => value, + Err(error) => return error, + }; + if let Err(error) = validate_worktree_id(worktree_id) { + return GuestResponse::error(400, error); + } + let Some(operation) = request.get("operation").and_then(serde_json::Value::as_str) + else { + return GuestResponse::error(400, "Git operation is required"); + }; + if !matches!(operation, "status" | "diff") { + return GuestResponse::error(400, "invalid Git operation"); + } + self.superset_bridge_request( + "GET", + &format!( + "/codev/git?worktreeId={}&operation={}", + percent_encode(worktree_id), + percent_encode(operation), + ), + &[], + ) + } + ("GET", "worktrees") => self.superset_bridge_request("GET", "/codev/worktrees", &[]), + ("POST", "worktrees") => { + let worktree_id = match worktree_id() { + Ok(value) => value, + Err(error) => return error, + }; + if let Err(error) = validate_worktree_id(worktree_id) { + return GuestResponse::error(400, error); + } + self.superset_bridge_request("POST", "/codev/worktrees", body) + } + ("POST", "terminal/start") => { + let worktree_id = match worktree_id() { + Ok(value) => value, + Err(error) => return error, + }; + if let Err(error) = validate_worktree_id(worktree_id) { + return GuestResponse::error(400, error); + } + self.superset_bridge_request("POST", "/codev/terminal", body) + } + ("POST", "terminal/input") + | ("POST", "terminal/resize") + | ("POST", "terminal/poll") => { + let worktree_id = match worktree_id() { + Ok(value) => value, + Err(error) => return error, + }; + let terminal_id = match terminal_id() { + Ok(value) => value, + Err(error) => return error, + }; + if let Err(error) = validate_worktree_id(worktree_id) { + return GuestResponse::error(400, error); + } + let action = operation + .strip_prefix("terminal/") + .expect("terminal action"); + self.superset_bridge_request( + "POST", + &format!( + "/codev/terminal/{}/{}?worktreeId={}", + percent_encode(terminal_id), + action, + percent_encode(worktree_id), + ), + body, + ) + } + ("DELETE", "terminal") => { + let worktree_id = match worktree_id() { + Ok(value) => value, + Err(error) => return error, + }; + let terminal_id = match terminal_id() { + Ok(value) => value, + Err(error) => return error, + }; + if let Err(error) = validate_worktree_id(worktree_id) { + return GuestResponse::error(400, error); + } + self.superset_bridge_request( + "DELETE", + &format!( + "/codev/terminal/{}?worktreeId={}", + percent_encode(terminal_id), + percent_encode(worktree_id), + ), + &[], + ) + } + _ => GuestResponse::error(400, "invalid Superset runtime operation"), + } + } + fn superset_bridge_request(&self, method: &str, path: &str, body: &[u8]) -> GuestResponse { let secret = match std::env::var("CODEV_SUPERSET_BRIDGE_SECRET") { Ok(secret) if !secret.is_empty() => secret, @@ -559,7 +694,7 @@ impl GuestService { return GuestResponse::error(503, "Superset host returned an invalid response"); }; match status { - 200 | 400 | 409 => GuestResponse { + 200 | 201 | 400 | 409 => GuestResponse { status, body: raw[(headers_end + 4)..].to_vec(), }, diff --git a/services/orchestrator/src/guest_client.rs b/services/orchestrator/src/guest_client.rs index 5134b8201..5a96c89fc 100644 --- a/services/orchestrator/src/guest_client.rs +++ b/services/orchestrator/src/guest_client.rs @@ -115,6 +115,16 @@ impl GuestClient { .await } + pub async fn superset_runtime( + &self, + method: &str, + operation: &str, + body: Option<&serde_json::Value>, + ) -> Result { + self.request(method, &format!("/v1/superset/runtime/{operation}"), body) + .await + } + pub async fn flush_workspace(&self) -> Result<()> { self.request::<(), serde_json::Value>("POST", "/v1/workspace/flush", None) .await diff --git a/services/orchestrator/src/http_api.rs b/services/orchestrator/src/http_api.rs index 378c24fcb..c83bee1e7 100644 --- a/services/orchestrator/src/http_api.rs +++ b/services/orchestrator/src/http_api.rs @@ -114,6 +114,12 @@ pub fn router(backend: SharedBackend, ide: IdeBackend) -> Router { "/v1/sandboxes/{workspace_id}/superset/file/changes", post(superset_file_changes), ) + .route( + "/v1/sandboxes/{workspace_id}/superset/runtime/{*operation}", + get(superset_runtime_get) + .post(superset_runtime_post) + .delete(superset_runtime_delete), + ) .route("/v1/sandboxes/{workspace_id}/files/write", post(write_file)) .route("/v1/sandboxes/{workspace_id}/pty/exec", post(exec_pty)) .route( @@ -527,6 +533,73 @@ async fn superset_file_changes( )) } +fn valid_superset_runtime_operation(method: &str, operation: &str) -> bool { + matches!( + (method, operation), + ("GET", "worktrees") + | ("POST", "git") + | ("POST", "worktrees") + | ("POST", "terminal/start") + | ("POST", "terminal/input") + | ("POST", "terminal/resize") + | ("POST", "terminal/poll") + | ("DELETE", "terminal") + ) +} + +async fn superset_runtime_get( + State(backend): State, + Path((workspace_id, operation)): Path<(String, String)>, +) -> Result> { + validate_workspace_id(&workspace_id)?; + if !valid_superset_runtime_operation("GET", &operation) { + return Err(RuntimeError::BadRequest( + "invalid Superset runtime operation".into(), + )); + } + Ok(Json( + backend + .superset_runtime(&workspace_id, "GET", &operation, None) + .await?, + )) +} + +async fn superset_runtime_post( + State(backend): State, + Path((workspace_id, operation)): Path<(String, String)>, + Json(body): Json, +) -> Result> { + validate_workspace_id(&workspace_id)?; + if !valid_superset_runtime_operation("POST", &operation) { + return Err(RuntimeError::BadRequest( + "invalid Superset runtime operation".into(), + )); + } + Ok(Json( + backend + .superset_runtime(&workspace_id, "POST", &operation, Some(&body)) + .await?, + )) +} + +async fn superset_runtime_delete( + State(backend): State, + Path((workspace_id, operation)): Path<(String, String)>, + Json(body): Json, +) -> Result> { + validate_workspace_id(&workspace_id)?; + if !valid_superset_runtime_operation("DELETE", &operation) { + return Err(RuntimeError::BadRequest( + "invalid Superset runtime operation".into(), + )); + } + Ok(Json( + backend + .superset_runtime(&workspace_id, "DELETE", &operation, Some(&body)) + .await?, + )) +} + async fn write_file( State(backend): State, Path(workspace_id): Path, diff --git a/vendor/superset/packages/host-service/src/app.ts b/vendor/superset/packages/host-service/src/app.ts index 0640a2b0e..3d1946ec2 100644 --- a/vendor/superset/packages/host-service/src/app.ts +++ b/vendor/superset/packages/host-service/src/app.ts @@ -15,6 +15,7 @@ import { cors } from "hono/cors"; import { createApiClient } from "./api"; import { createChatV3Mount, registerChatV3Routes } from "./chat-v3"; import { registerCoDevFileBridge } from "./codev/files"; +import { registerCoDevRuntimeBridge } from "./codev/runtime"; import { createDb, type HostDb } from "./db"; import { EventBus, GitWatcher, registerEventBusRoute } from "./events"; import { agentIsBusy, PageWatchManager } from "./page-watch/index.ts"; @@ -398,6 +399,14 @@ export function createApp(options: CreateAppOptions): CreateAppResult { workspaceRoot: codevWorkspaceRoot, bridgeSecret: codevBridgeSecret, }); + registerCoDevRuntimeBridge({ + app, + db, + eventBus, + git, + workspaceRoot: codevWorkspaceRoot, + bridgeSecret: codevBridgeSecret, + }); } app.use( diff --git a/vendor/superset/packages/host-service/src/codev/runtime.ts b/vendor/superset/packages/host-service/src/codev/runtime.ts new file mode 100644 index 000000000..1f273e5c9 --- /dev/null +++ b/vendor/superset/packages/host-service/src/codev/runtime.ts @@ -0,0 +1,341 @@ +import { mkdir, realpath } from "node:fs/promises"; +import { relative, resolve, sep } from "node:path"; +import { timingSafeEqual } from "node:crypto"; +import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import type { HostDb } from "../db"; +import { terminalSessions, workspaces } from "../db/schema"; +import type { EventBus } from "../events"; +import { + createTerminalSessionInternal, + disposeSessionAndWait, + resizeTerminalSession, + snapshotSession, + writeFramedInputToSession, +} from "../terminal/terminal"; +import { CODEV_PRIMARY_WORKTREE_ID, resolveCoDevWorktreeRoot } from "./files"; + +const worktreeIdSchema = z + .string() + .min(1) + .max(64) + .regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/); +const branchSchema = z + .string() + .min(1) + .max(255) + .regex(/^(?!-)(?!.*\.\.)(?!.*[~^:?*\[\\\s])[^/](?:.*[^/.])?$/); +const dimensionsSchema = z.object({ + rows: z.number().int().min(1).max(500), + columns: z.number().int().min(1).max(500), +}); +const terminalStartSchema = dimensionsSchema.extend({ + worktreeId: worktreeIdSchema, +}); +const terminalInputSchema = z.object({ data: z.string().max(64 * 1024) }); +const terminalPollSchema = z.object({ after: z.number().int().nonnegative() }); +const worktreeCreateSchema = z.object({ + worktreeId: worktreeIdSchema.refine((id) => id !== CODEV_PRIMARY_WORKTREE_ID), + branch: branchSchema, + baseRef: z.string().min(1).max(255).optional(), +}); + +type GitClient = { raw: (args: string[]) => Promise }; +type GitFactory = (worktreePath: string) => Promise; + +export type CoDevRuntimeBridgeOptions = { + app: Hono; + db: HostDb; + eventBus: EventBus; + git: GitFactory; + workspaceRoot: string; + bridgeSecret: string; +}; + +type TerminalPollState = { sequence: number; text: string }; +const terminalPollStates = new Map(); +let terminalSequence = 0; + +function secretMatches(actual: string | undefined, expected: string) { + if (!actual) return false; + const received = Buffer.from(actual); + const configured = Buffer.from(expected); + return received.length === configured.length && timingSafeEqual(received, configured); +} + +function isWithin(rootPath: string, candidate: string) { + const path = relative(rootPath, candidate); + return path === "" || (!path.startsWith(`..${sep}`) && path !== ".." && !path.startsWith(sep)); +} + +function hostWorkspaceId(worktreeId: string) { + return `codev-${worktreeId}`; +} + +async function branchAt(git: GitFactory, root: string) { + const branch = (await (await git(root)).raw(["branch", "--show-current"])).trim(); + return branch || "HEAD"; +} + +async function ensureTerminalWorkspace({ + db, + git, + workspaceRoot, + worktreeId, +}: Pick & { + worktreeId: string; +}) { + const worktreePath = await resolveCoDevWorktreeRoot(workspaceRoot, worktreeId); + const id = hostWorkspaceId(worktreeId); + const existing = db.query.workspaces.findFirst({ where: eq(workspaces.id, id) }).sync(); + if (existing) { + if (existing.worktreePath !== worktreePath) { + throw new Error("CoDev worktree identity points at a different checkout."); + } + return { id, worktreePath }; + } + const now = Date.now(); + db.insert(workspaces) + .values({ + id, + projectId: null, + worktreePath, + branch: await branchAt(git, worktreePath), + name: `CoDev ${worktreeId}`, + type: "worktree", + createdAt: now, + updatedAt: now, + }) + .run(); + return { id, worktreePath }; +} + +function terminalError(error: unknown) { + return error && typeof error === "object" && "error" in error + ? String(error.error) + : "Superset terminal operation failed."; +} + +function parseWorktreeList(output: string, primaryRoot: string) { + const managedRoot = resolve(primaryRoot, ".git", "codev-agent-worktrees"); + const entries: Array<{ worktreeId: string; branch: string }> = []; + for (const block of output.trim().split("\n\n")) { + const values = new Map( + block + .split("\n") + .map((line) => { + const [key, ...rest] = line.split(" "); + return [key, rest.join(" ")]; + }), + ); + const path = values.get("worktree"); + if (!path) continue; + let worktreeId: string | null = null; + if (path === primaryRoot) worktreeId = CODEV_PRIMARY_WORKTREE_ID; + else if (isWithin(managedRoot, path)) worktreeId = path.slice(managedRoot.length + 1); + if (!worktreeId || !worktreeIdSchema.safeParse(worktreeId).success) continue; + const branchRef = values.get("branch") ?? ""; + entries.push({ + worktreeId, + branch: branchRef.startsWith("refs/heads/") ? branchRef.slice(11) : "HEAD", + }); + } + return entries.sort((left, right) => left.worktreeId.localeCompare(right.worktreeId)); +} + +/** + * Private operations for codev-guestd. They intentionally reuse Superset's + * terminal lifecycle and Git client; the browser only reaches them through + * CoDev's membership and capability checks. + */ +export function registerCoDevRuntimeBridge({ + app, + db, + eventBus, + git, + workspaceRoot, + bridgeSecret, +}: CoDevRuntimeBridgeOptions) { + const requireBridge = (request: Request) => + secretMatches(request.headers.get("x-codev-bridge-secret") ?? undefined, bridgeSecret); + + app.get("/codev/git", async (context) => { + if (!requireBridge(context.req.raw)) return context.json({ error: "Unauthorized" }, 401); + const worktreeId = worktreeIdSchema.safeParse(context.req.query("worktreeId")); + const operation = z.enum(["status", "diff"]).safeParse(context.req.query("operation")); + if (!worktreeId.success || !operation.success) return context.json({ error: "Invalid Git request." }, 400); + try { + const root = await resolveCoDevWorktreeRoot(workspaceRoot, worktreeId.data); + const output = await (await git(root)).raw( + operation.data === "status" ? ["status", "--short", "--branch"] : ["diff", "--no-ext-diff"], + ); + return context.json({ output }); + } catch (error) { + return context.json({ error: error instanceof Error ? error.message : "Could not read Git state." }, 400); + } + }); + + app.get("/codev/worktrees", async (context) => { + if (!requireBridge(context.req.raw)) return context.json({ error: "Unauthorized" }, 401); + try { + const root = await realpath(workspaceRoot); + return context.json({ worktrees: parseWorktreeList(await (await git(root)).raw(["worktree", "list", "--porcelain"]), root) }); + } catch (error) { + return context.json({ error: error instanceof Error ? error.message : "Could not list worktrees." }, 400); + } + }); + + app.post("/codev/worktrees", async (context) => { + if (!requireBridge(context.req.raw)) return context.json({ error: "Unauthorized" }, 401); + const parsed = worktreeCreateSchema.safeParse(await context.req.json().catch(() => undefined)); + if (!parsed.success) return context.json({ error: "Invalid worktree creation request." }, 400); + try { + const root = await realpath(workspaceRoot); + const managedRoot = resolve(root, ".git", "codev-agent-worktrees"); + const target = resolve(managedRoot, parsed.data.worktreeId); + if (!isWithin(managedRoot, target)) throw new Error("Worktree path escapes the workspace."); + await mkdir(managedRoot, { recursive: true }); + const branchNames = (await (await git(root)).raw([ + "for-each-ref", + "--format=%(refname:short)", + "refs/heads", + ])) + .split("\n") + .map((branch) => branch.trim()); + const branchExists = branchNames.includes(parsed.data.branch); + if (branchExists && parsed.data.baseRef) { + return context.json( + { error: "baseRef can only be supplied when creating a new branch." }, + 400, + ); + } + await (await git(root)).raw( + branchExists + ? ["worktree", "add", target, parsed.data.branch] + : [ + "worktree", + "add", + "-b", + parsed.data.branch, + target, + parsed.data.baseRef ?? "HEAD", + ], + ); + return context.json({ worktree: { worktreeId: parsed.data.worktreeId, branch: parsed.data.branch } }, 201); + } catch (error) { + return context.json({ error: error instanceof Error ? error.message : "Could not create worktree." }, 400); + } + }); + + app.post("/codev/terminal", async (context) => { + if (!requireBridge(context.req.raw)) return context.json({ error: "Unauthorized" }, 401); + const parsed = terminalStartSchema.safeParse(await context.req.json().catch(() => undefined)); + if (!parsed.success) return context.json({ error: "Invalid terminal start request." }, 400); + try { + const workspace = await ensureTerminalWorkspace({ db, git, workspaceRoot, worktreeId: parsed.data.worktreeId }); + const terminalId = `term-${Date.now()}-${++terminalSequence}`; + const created = await createTerminalSessionInternal({ + terminalId, + workspaceId: workspace.id, + db, + eventBus, + rows: parsed.data.rows, + cols: parsed.data.columns, + includeDefaultAccountEnv: false, + // The image reserves uid/gid 2000 for codev-shell. setpriv drops + // directly to it without a PAM session, matching codev-guestd. + shell: "/usr/bin/setpriv", + shellArgs: [ + "--reuid=2000", + "--regid=2000", + "--clear-groups", + "--", + "/bin/sh", + "-l", + ], + }); + if ("error" in created) return context.json({ error: terminalError(created) }, 400); + terminalPollStates.set(terminalId, { sequence: 0, text: "" }); + return context.json({ sessionId: terminalId }, 201); + } catch (error) { + return context.json({ error: error instanceof Error ? error.message : "Could not start terminal." }, 400); + } + }); + + app.post("/codev/terminal/:terminalId/input", async (context) => { + if (!requireBridge(context.req.raw)) return context.json({ error: "Unauthorized" }, 401); + const parsed = terminalInputSchema.safeParse(await context.req.json().catch(() => undefined)); + const worktreeId = worktreeIdSchema.safeParse(context.req.query("worktreeId")); + if (!parsed.success || !worktreeId.success) return context.json({ error: "Invalid terminal input request." }, 400); + try { + const workspace = await ensureTerminalWorkspace({ db, git, workspaceRoot, worktreeId: worktreeId.data }); + const result = await writeFramedInputToSession({ terminalId: context.req.param("terminalId"), workspaceId: workspace.id, text: parsed.data.data, submit: false, db, eventBus }); + if ("error" in result) return context.json({ error: terminalError(result) }, 400); + return context.json({ ok: true }); + } catch (error) { + return context.json({ error: error instanceof Error ? error.message : "Could not write terminal input." }, 400); + } + }); + + app.post("/codev/terminal/:terminalId/resize", async (context) => { + if (!requireBridge(context.req.raw)) return context.json({ error: "Unauthorized" }, 401); + const parsed = dimensionsSchema.safeParse(await context.req.json().catch(() => undefined)); + const worktreeId = worktreeIdSchema.safeParse(context.req.query("worktreeId")); + if (!parsed.success || !worktreeId.success) return context.json({ error: "Invalid terminal resize request." }, 400); + try { + const workspace = await ensureTerminalWorkspace({ db, git, workspaceRoot, worktreeId: worktreeId.data }); + const result = await resizeTerminalSession({ terminalId: context.req.param("terminalId"), workspaceId: workspace.id, columns: parsed.data.columns, rows: parsed.data.rows, db, eventBus }); + if ("error" in result) return context.json({ error: terminalError(result) }, 400); + return context.json({ ok: true }); + } catch (error) { + return context.json({ error: error instanceof Error ? error.message : "Could not resize terminal." }, 400); + } + }); + + app.post("/codev/terminal/:terminalId/poll", async (context) => { + if (!requireBridge(context.req.raw)) return context.json({ error: "Unauthorized" }, 401); + const parsed = terminalPollSchema.safeParse(await context.req.json().catch(() => undefined)); + const worktreeId = worktreeIdSchema.safeParse(context.req.query("worktreeId")); + if (!parsed.success || !worktreeId.success) return context.json({ error: "Invalid terminal poll request." }, 400); + try { + const workspace = await ensureTerminalWorkspace({ db, git, workspaceRoot, worktreeId: worktreeId.data }); + const snapshot = await snapshotSession({ terminalId: context.req.param("terminalId"), workspaceId: workspace.id, maxLines: 1_000, db, eventBus }); + if ("error" in snapshot) return context.json({ error: terminalError(snapshot) }, 400); + const state = terminalPollStates.get(context.req.param("terminalId")) ?? { sequence: 0, text: "" }; + if (snapshot.text !== state.text) { + state.sequence += 1; + state.text = snapshot.text; + terminalPollStates.set(context.req.param("terminalId"), state); + } + return context.json({ + chunks: parsed.data.after < state.sequence ? [{ sequence: state.sequence, data: `\u001b[2J\u001b[H${state.text}` }] : [], + nextSequence: state.sequence, + exited: false, + exitCode: null, + }); + } catch (error) { + return context.json({ error: error instanceof Error ? error.message : "Could not poll terminal." }, 400); + } + }); + + app.delete("/codev/terminal/:terminalId", async (context) => { + if (!requireBridge(context.req.raw)) return context.json({ error: "Unauthorized" }, 401); + const worktreeId = worktreeIdSchema.safeParse(context.req.query("worktreeId")); + if (!worktreeId.success) return context.json({ error: "Invalid terminal close request." }, 400); + try { + const terminalId = context.req.param("terminalId"); + const session = db.query.terminalSessions + .findFirst({ where: eq(terminalSessions.id, terminalId) }) + .sync(); + if (!session || session.originWorkspaceId !== hostWorkspaceId(worktreeId.data)) { + return context.json({ error: "Terminal session is not in this worktree." }, 400); + } + await disposeSessionAndWait(terminalId, db); + terminalPollStates.delete(terminalId); + return context.json({ ok: true }); + } catch (error) { + return context.json({ error: error instanceof Error ? error.message : "Could not close terminal." }, 400); + } + }); +} diff --git a/vendor/superset/packages/host-service/src/terminal/terminal.ts b/vendor/superset/packages/host-service/src/terminal/terminal.ts index 50048d782..14d1a8b65 100644 --- a/vendor/superset/packages/host-service/src/terminal/terminal.ts +++ b/vendor/superset/packages/host-service/src/terminal/terminal.ts @@ -1020,6 +1020,39 @@ export function writeInputToSession({ return { success: true }; } +/** Resize a headless bridge session without requiring a renderer WebSocket. */ +export async function resizeTerminalSession({ + terminalId, + workspaceId, + columns, + rows, + db, + eventBus, +}: { + terminalId: string; + workspaceId: string; + columns: number; + rows: number; + db: HostDb; + eventBus?: EventBus; +}): Promise<{ success: true } | TerminalSessionError> { + const session = await getOrAdoptSession({ + terminalId, + workspaceId, + db, + eventBus, + }); + if ("error" in session) return session; + if (session.exited) { + return { kind: "SESSION_EXITED", error: "Terminal session has exited" }; + } + const cols = Math.max(MIN_TERMINAL_COLS, Math.floor(columns)); + const nextRows = Math.max(MIN_TERMINAL_ROWS, Math.floor(rows)); + session.pty.resize(cols, nextRows); + session.modeTracker.resize(cols, nextRows); + return { success: true }; +} + // Compatibility with daemons predating the replay-complete checkpoint. const ADOPTION_REPLAY_WAIT_MS = 500; @@ -2841,6 +2874,11 @@ interface CreateTerminalSessionOptions { listed?: boolean; cols?: number; rows?: number; + /** CoDev shells must not inherit a host-wide provider account. */ + includeDefaultAccountEnv?: boolean; + /** Trusted host callers may replace the login shell for an unprivileged launcher. */ + shell?: string; + shellArgs?: string[]; /** Only recover an already-live daemon session; never spawn a new PTY. */ adoptOnly?: boolean; /** @@ -2927,6 +2965,9 @@ async function createTerminalSessionUnlocked({ listed = true, cols: requestedCols, rows: requestedRows, + includeDefaultAccountEnv = true, + shell: shellOverride, + shellArgs: shellArgsOverride, adoptOnly = false, restoredNotice = false, }: CreateTerminalSessionOptions): Promise< @@ -3015,8 +3056,8 @@ async function createTerminalSessionUnlocked({ // without it the wrapper paths, hook guard env, and shell bootstrap all // silently disable (#6254). const supersetHomeDir = resolveSupersetHomeDir(); - const shell = resolveLaunchShell(baseEnv); - const shellArgs = getShellLaunchArgs({ shell, supersetHomeDir }); + const shell = shellOverride ?? resolveLaunchShell(baseEnv); + const shellArgs = shellArgsOverride ?? getShellLaunchArgs({ shell, supersetHomeDir }); const ptyEnv = { ...buildV2TerminalEnv({ baseEnv, @@ -3038,7 +3079,7 @@ async function createTerminalSessionUnlocked({ // Usage-tab default account: provider CLIs typed or preset-launched in // this terminal run on the selected login. Baked at spawn as the fast // path; the agent wrappers re-resolve later switches at launch time. - ...resolveDefaultAccountTerminalEnv(db), + ...(includeDefaultAccountEnv ? resolveDefaultAccountTerminalEnv(db) : {}), SUPERSET_ACCOUNT_ATTRIBUTION_TOKEN: issueAttributionToken(terminalId), };