Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/control-plane/src/router.scm-credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,24 @@ describe("SCM credentials router provider gate", () => {
expect(new URL(request.url).pathname).toBe("/internal/scm-credentials");
});

it("allows GitLab deployments to reach the tunnel URLs endpoint", async () => {
const { env, fetch } = createEnv();
const token = await generateInternalToken(secret);

const response = await handleRequest(
new Request("https://test.local/sessions/session-1/tunnel-urls", {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
}),
env as never
);

expect(response.status).toBe(202);
expect(fetch).toHaveBeenCalledOnce();
const request = fetch.mock.calls[0][0];
expect(new URL(request.url).pathname).toBe("/internal/tunnel-urls");
});

it("continues blocking unrelated GitLab session routes", async () => {
const { env, fetch } = createEnv();
const token = await generateInternalToken(secret);
Expand Down
4 changes: 3 additions & 1 deletion packages/control-plane/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const SANDBOX_AUTH_ROUTES: RegExp[] = [
/^\/sessions\/[^/]+\/pr$/, // PR creation from sandbox
/^\/sessions\/[^/]+\/openai-token-refresh$/, // OpenAI token refresh from sandbox
/^\/sessions\/[^/]+\/scm-credentials$/, // SCM credential broker for git credential helper
/^\/sessions\/[^/]+\/tunnel-urls$/, // Tunnel URL fetch for sandboxes whose .tunnels.env write isn't visible from inside
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/^\/sessions\/[^/]+\/media$/, // Media upload from sandbox
/^\/sessions\/[^/]+\/children$/, // POST spawn, GET list
/^\/sessions\/[^/]+\/children\/[^/]+$/, // GET child detail
Expand Down Expand Up @@ -124,7 +125,8 @@ function isSandboxAuthRoute(path: string): boolean {
function isScmAgnosticRoute(path: string): boolean {
return (
/^\/analytics\/(summary|timeseries|breakdown)$/.test(path) ||
/^\/provider-identities\/github\/[^/]+$/.test(path)
/^\/provider-identities\/github\/[^/]+$/.test(path) ||
/^\/sessions\/[^/]+\/tunnel-urls$/.test(path)
);
}

Expand Down
6 changes: 6 additions & 0 deletions packages/control-plane/src/routes/session-runtime-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,12 @@ export const sessionRuntimeProxyRoutes: Route[] = [
internalPath: SessionInternalPaths.scmCredentials,
runtimeMethod: "POST",
}),
simpleProxyRoute({
method: "GET",
routePath: "/sessions/:id/tunnel-urls",
internalPath: SessionInternalPaths.tunnelUrls,
runtimeMethod: "GET",
}),
sessionRoute({
method: "PATCH",
pattern: parsePattern("/sessions/:id/title"),
Expand Down
1 change: 1 addition & 0 deletions packages/control-plane/src/session/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const SessionInternalPaths = {
verifySandboxToken: "/internal/verify-sandbox-token",
openaiTokenRefresh: "/internal/openai-token-refresh",
scmCredentials: "/internal/scm-credentials",
tunnelUrls: "/internal/tunnel-urls",
spawnContext: "/internal/spawn-context",
childSummary: "/internal/child-summary",
updateTitle: "/internal/update-title",
Expand Down
1 change: 1 addition & 0 deletions packages/control-plane/src/session/durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ export class SessionDO extends DurableObject<Env> {
verifySandboxToken: (request) => this.sandboxHandler.verifySandboxToken(request),
openaiTokenRefresh: () => this.sandboxHandler.openaiTokenRefresh(),
scmCredentials: () => this.sandboxHandler.scmCredentials(),
tunnelUrls: () => this.sandboxHandler.tunnelUrls(),
spawnContext: () => this.childSessionsHandler.getSpawnContext(),
childSummary: (_request, url) => this.childSessionsHandler.getChildSummary(url),
cancel: () => this.sessionLifecycleHandler.cancel(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -443,4 +443,64 @@ describe("createSandboxHandler", () => {
expires_at_epoch_ms: expiresAt,
});
});

it("returns 404 when tunnel URLs have no sandbox", async () => {
const { handler, getSandbox } = createHandler();
getSandbox.mockReturnValue(null);

const response = await handler.tunnelUrls();

expect(response.status).toBe(404);
expect(await response.json()).toEqual({ error: "No sandbox" });
});

it("returns an empty map when no tunnel URLs are stored yet", async () => {
const { handler, getSandbox } = createHandler();
getSandbox.mockReturnValue({ tunnel_urls: null } as unknown as SandboxRow);

const response = await handler.tunnelUrls();

expect(response.status).toBe(200);
expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(await response.json()).toEqual({ tunnelUrls: {} });
});

it("returns parsed tunnel URLs on success", async () => {
const { handler, getSandbox } = createHandler();
getSandbox.mockReturnValue({
tunnel_urls: JSON.stringify({ "3000": "https://a.example", "5000": "https://b.example" }),
} as unknown as SandboxRow);

const response = await handler.tunnelUrls();

expect(response.status).toBe(200);
expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(await response.json()).toEqual({
tunnelUrls: { "3000": "https://a.example", "5000": "https://b.example" },
});
});

it("returns 500 when stored tunnel URLs are malformed JSON", async () => {
const { handler, getSandbox, log } = createHandler();
getSandbox.mockReturnValue({ tunnel_urls: "{not json" } as unknown as SandboxRow);

const response = await handler.tunnelUrls();

expect(response.status).toBe(500);
expect(await response.json()).toEqual({ error: "Invalid stored tunnel URLs" });
expect(log.warn).toHaveBeenCalled();
});

it("returns 500 when stored tunnel URLs are not an object", async () => {
const { handler, getSandbox, log } = createHandler();
getSandbox.mockReturnValue({
tunnel_urls: JSON.stringify(["3000", "5000"]),
} as unknown as SandboxRow);

const response = await handler.tunnelUrls();

expect(response.status).toBe(500);
expect(await response.json()).toEqual({ error: "Invalid stored tunnel URLs" });
expect(log.warn).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export interface SandboxHandler {
verifySandboxToken: (request: Request) => Promise<Response>;
openaiTokenRefresh: () => Promise<Response>;
scmCredentials: () => Promise<Response>;
/** Return the sandbox's resolved tunnel URLs as a `{ [port]: url }` map. */
tunnelUrls: () => Promise<Response>;
}

export function createSandboxHandler(deps: SandboxHandlerDeps): SandboxHandler {
Expand Down Expand Up @@ -191,6 +193,55 @@ export function createSandboxHandler(deps: SandboxHandlerDeps): SandboxHandler {
);
},

/**
* Return the sandbox's resolved tunnel URLs as a `{ [port]: url }` map.
*
* `sandbox.tunnel_urls` is a JSON-encoded `{ [port: string]: string }`
* stored by `SandboxLifecycleManager#storeAndBroadcastTunnelUrls`. When the
* control plane has resolved Modal tunnel URLs but the in-sandbox file write
* (`sandbox.open` from outside) hasn't propagated to the sandbox's own
* filesystem view — a real failure mode on the Modal provider — this
* endpoint is the in-sandbox fallback for retrieving them via
* `SANDBOX_AUTH_TOKEN`.
*
* Responses:
* - `404` when no sandbox exists for the session.
* - `500` when the stored value is malformed JSON or not a JSON object, so
* corrupted data is distinguishable from "no tunnels resolved yet" (an
* empty map would let the in-sandbox setup silently write an empty
* `.tunnels.env`).
* - `200` with `{ tunnelUrls }` otherwise (empty map when none are stored).
*/
async tunnelUrls(): Promise<Response> {
const sandbox = deps.getSandbox();
if (!sandbox) {
return Response.json({ error: "No sandbox" }, { status: 404 });
}

let urls: Record<string, string> = {};
if (sandbox.tunnel_urls) {
let parsed: unknown;
try {
parsed = JSON.parse(sandbox.tunnel_urls);
} catch (err) {
deps.getLog().warn("Failed to parse stored tunnel_urls JSON", {
error: err instanceof Error ? err.message : String(err),
});
return Response.json({ error: "Invalid stored tunnel URLs" }, { status: 500 });
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
deps.getLog().warn("Stored tunnel_urls is not a JSON object");
return Response.json({ error: "Invalid stored tunnel URLs" }, { status: 500 });
}
urls = parsed as Record<string, string>;
}

return Response.json(
{ tunnelUrls: urls },
{ status: 200, headers: { "Cache-Control": "no-store" } }
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},

async scmCredentials(): Promise<Response> {
const session = deps.getSession();
if (!session) {
Expand Down
2 changes: 2 additions & 0 deletions packages/control-plane/src/session/http/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ describe("createSessionInternalRoutes", () => {
verifySandboxToken: noopHandler(),
openaiTokenRefresh: noopHandler(),
scmCredentials: noopHandler(),
tunnelUrls: noopHandler(),
spawnContext: noopHandler(),
childSummary: noopHandler(),
cancel: noopHandler(),
Expand Down Expand Up @@ -57,6 +58,7 @@ describe("createSessionInternalRoutes", () => {
`POST ${SessionInternalPaths.verifySandboxToken}`,
`POST ${SessionInternalPaths.openaiTokenRefresh}`,
`POST ${SessionInternalPaths.scmCredentials}`,
`GET ${SessionInternalPaths.tunnelUrls}`,
`GET ${SessionInternalPaths.spawnContext}`,
`GET ${SessionInternalPaths.childSummary}`,
`POST ${SessionInternalPaths.cancel}`,
Expand Down
2 changes: 2 additions & 0 deletions packages/control-plane/src/session/http/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface SessionInternalRouteHandlers {
verifySandboxToken: SessionInternalRouteHandler;
openaiTokenRefresh: SessionInternalRouteHandler;
scmCredentials: SessionInternalRouteHandler;
tunnelUrls: SessionInternalRouteHandler;
spawnContext: SessionInternalRouteHandler;
childSummary: SessionInternalRouteHandler;
cancel: SessionInternalRouteHandler;
Expand Down Expand Up @@ -88,6 +89,7 @@ export function createSessionInternalRoutes(
path: SessionInternalPaths.scmCredentials,
handler: handlers.scmCredentials,
},
{ method: "GET", path: SessionInternalPaths.tunnelUrls, handler: handlers.tunnelUrls },
{ method: "GET", path: SessionInternalPaths.spawnContext, handler: handlers.spawnContext },
{ method: "GET", path: SessionInternalPaths.childSummary, handler: handlers.childSummary },
{ method: "POST", path: SessionInternalPaths.cancel, handler: handlers.cancel },
Expand Down
Loading