From 46976f5a4025bbf85aacee325ac9f6c7f92de5d2 Mon Sep 17 00:00:00 2001 From: Shan Valleru Date: Fri, 11 Sep 2026 14:25:35 -0700 Subject: [PATCH 1/7] feat(container): serve the dashboard from a standalone container image --- .dockerignore | 16 +++++++++ Dockerfile | 63 +++++++++++++++++++++++++++++++++++ README.md | 30 +++++++++++++++++ next.config.ts | 4 +++ scripts/container-smoke.sh | 67 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 180 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100755 scripts/container-smoke.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..d14637597 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.github +.vscode +.conductor +node_modules +.next +out +build +coverage +test-results +readme-assets +.env +.env.* +*.md +Dockerfile +.dockerignore diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..1726f0779 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +# Three stages: Bun resolves the dependencies (bun.lock is the lockfile), Node +# runs the Next build, Node serves. The runtime stage carries only Next's +# standalone output, so the full dependency tree never ships in the image. +# +# The build runs under Node, not Bun: `bun run build` forks Next's page-data +# workers, and Bun's CommonJS interop throws "Expected CommonJS module to have +# a function wrapper" on the webpack output those workers load. +# +# The build fetches three Google Fonts families through next/font/google +# (src/app/fonts.ts): it needs outbound HTTPS to fonts.googleapis.com and +# fonts.gstatic.com, and fails there in an air-gapped environment. +FROM oven/bun:1.2.20 AS deps + +WORKDIR /app + +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile + +FROM node:22-bookworm-slim AS builder + +WORKDIR /app + +# Only to run the prebuild env check, which is a TypeScript entrypoint. +COPY --from=deps /usr/local/bin/bun /usr/local/bin/bun +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next inlines every NEXT_PUBLIC_* value into the bundles, so the domain is a +# build input, and the prebuild env check (scripts/check-app-env.ts) exits 1 +# without it. The default resolves nowhere on purpose: a container started +# with no configuration must fail loudly instead of reaching a deployment that +# is not yours. Point a container at an install with the runtime variables. +ARG NEXT_PUBLIC_E2B_DOMAIN=unset.invalid +ENV NEXT_PUBLIC_E2B_DOMAIN=${NEXT_PUBLIC_E2B_DOMAIN} +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN bun scripts/check-app-env.ts +RUN node node_modules/next/dist/bin/next build --webpack + +FROM node:22-bookworm-slim AS runtime + +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +# server.js reads PORT (default 3000) and HOSTNAME (default 0.0.0.0). The +# default is 3001 so the dashboard does not land on 3000, which an E2B install +# already uses for its API when both share a host network. +ENV PORT=3001 +ENV HOSTNAME=0.0.0.0 + +# Reported as service.version on OTEL traces (src/instrumentation.node.ts). +ARG BUILD=dev +ENV BUILD=${BUILD} + +COPY --from=builder --chown=node:node /app/.next/standalone ./ +COPY --from=builder --chown=node:node /app/.next/static ./.next/static +COPY --from=builder --chown=node:node /app/public ./public + +USER node +EXPOSE 3001 + +CMD ["node", "server.js"] diff --git a/README.md b/README.md index b855a51e7..3041959a8 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,36 @@ bun run build bun run start ``` +### Run it in a container + +The repository builds a self-contained image: Bun resolves the dependencies, +Node runs the Next build, and Node serves the standalone output; the runtime +stage carries no dev dependencies. + +```bash +docker build --build-arg NEXT_PUBLIC_E2B_DOMAIN=your-domain.com -t e2b-dashboard . +docker run --rm -p 3001:3001 e2b-dashboard +``` + +- `PORT` (default `3001`) and `HOSTNAME` (default `0.0.0.0`) are read by the + server at start. The default keeps the dashboard clear of port 3000, which + an E2B API already uses when both share a host network. +- `NEXT_PUBLIC_E2B_DOMAIN` is a **build** argument, not a runtime variable: + Next inlines `NEXT_PUBLIC_*` values into the bundles. It defaults to a + domain that resolves nowhere, so an unconfigured container fails loudly + instead of talking to a deployment that is not yours. +- An image built this way resolves both APIs from `NEXT_PUBLIC_E2B_DOMAIN` at + build time; pass `NEXT_PUBLIC_INFRA_API_URL`, `NEXT_PUBLIC_E2B_SANDBOX_URL` + or `NEXT_PUBLIC_DASHBOARD_API_URL` as extra `--build-arg`s only if you also + add matching `ARG` lines, until runtime configuration of those URLs lands in + a separate change. +- The build needs outbound HTTPS for the three Google Fonts families in + `src/app/fonts.ts`; an air-gapped build fails there. +- `GET /api/health` reports dashboard-api's health and answers 503 while + dashboard-api is unreachable, so use `GET /` as the container liveness + check. +- `scripts/container-smoke.sh` builds the image and asserts those responses. + ## Scripts | Command | Description | diff --git a/next.config.ts b/next.config.ts index e537e129b..b748851e6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -17,6 +17,10 @@ const browserNodeModuleStubs = { const config: NextConfig = { reactStrictMode: true, reactCompiler: true, + // Emits .next/standalone: a server plus only the traced dependencies, which + // is what the container image runs. `next start` still works from .next for + // local previews, and platform builds ignore this output. + output: 'standalone', experimental: { useCache: true, turbopackFileSystemCacheForDev: true, diff --git a/scripts/container-smoke.sh b/scripts/container-smoke.sh new file mode 100755 index 000000000..af326157f --- /dev/null +++ b/scripts/container-smoke.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Builds the container image and checks the three responses a self-hosted +# install depends on. Needs Docker and outbound HTTPS: the Next build pulls +# the Google Fonts faces declared in src/app/fonts.ts. +set -euo pipefail + +IMAGE="${IMAGE:-e2b-dashboard:smoke}" +PORT="${PORT:-3001}" +CONTAINER="e2b-dashboard-smoke-$$" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +cleanup() { + docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "==> building ${IMAGE}" +docker build -t "${IMAGE}" "${ROOT}" + +echo "==> starting ${CONTAINER} on port ${PORT}" +docker run -d --name "${CONTAINER}" -e PORT="${PORT}" -p "${PORT}:${PORT}" "${IMAGE}" >/dev/null + +ready=0 +for _ in $(seq 1 60); do + if curl -fs -o /dev/null "http://127.0.0.1:${PORT}/"; then + ready=1 + break + fi + sleep 1 +done + +if [ "${ready}" != 1 ]; then + echo "FAIL: nothing answered on port ${PORT} within 60s" >&2 + docker logs "${CONTAINER}" >&2 || true + exit 1 +fi + +fail=0 +check() { + if [ "$3" = "$2" ]; then + echo "ok $1: $3" + else + echo "FAIL $1: expected $2, got $3" >&2 + fail=1 + fi +} + +check "GET / serves the api key form" 200 \ + "$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PORT}/")" + +check "GET /sandboxes redirects to the key form" 307 \ + "$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PORT}/sandboxes")" + +check "GET /sandboxes redirect target" "http://127.0.0.1:${PORT}/?returnTo=%2Fsandboxes" \ + "$(curl -sS -o /dev/null -w '%{redirect_url}' "http://127.0.0.1:${PORT}/sandboxes")" + +# /api/health probes dashboard-api, which this run does not provide, so 503 is +# the correct answer here and proves route handlers are being served. +check "GET /api/health without a dashboard-api" 503 \ + "$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PORT}/api/health")" + +if [ "${fail}" != 0 ]; then + docker logs "${CONTAINER}" >&2 || true + exit 1 +fi + +echo "==> container smoke test passed" From 584f43e14a80fe56db9e5b3f34c851688ac3925d Mon Sep 17 00:00:00 2001 From: Shan Valleru Date: Fri, 11 Sep 2026 14:44:24 -0700 Subject: [PATCH 2/7] ci(container): build and smoke-test the image on container changes --- .github/workflows/container.yml | 54 +++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/container.yml diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml new file mode 100644 index 000000000..0982bf653 --- /dev/null +++ b/.github/workflows/container.yml @@ -0,0 +1,54 @@ +# Nothing else in CI builds the image, so a break in the Docker build would +# otherwise go unnoticed until someone builds it by hand. This job runs on the +# files that can break it, not on every PR — a full Next build in Docker is +# minutes, and application changes are already covered by Test / Code Quality. +name: Container + +on: + push: + branches: [main] + paths: + - Dockerfile + - .dockerignore + - next.config.ts + - tsconfig.json + - package.json + - bun.lock + - scripts/check-app-env.ts + - scripts/container-smoke.sh + - src/lib/env.ts + - .github/workflows/container.yml + pull_request: + branches: [main] + paths: + - Dockerfile + - .dockerignore + - next.config.ts + - tsconfig.json + - package.json + - bun.lock + - scripts/check-app-env.ts + - scripts/container-smoke.sh + - src/lib/env.ts + - .github/workflows/container.yml + workflow_dispatch: + +env: + FORCE_COLOR: "1" + CLICOLOR_FORCE: "1" + +permissions: + contents: read + +jobs: + smoke: + name: Build and Smoke-Test the Image + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Build the image and check the responses it serves + run: ./scripts/container-smoke.sh From ae994055c605b22b6159e2b072a4979fc466026c Mon Sep 17 00:00:00 2001 From: Shan Valleru Date: Fri, 11 Sep 2026 15:01:39 -0700 Subject: [PATCH 3/7] feat(config): resolve infra-api and dashboard-api URLs at runtime --- .env.example | 7 ++ README.md | 13 ++ src/core/server/api/routers/sandbox.ts | 9 +- src/core/server/runtime-config.ts | 81 ++++++++++++ src/core/shared/clients/api.ts | 12 +- src/lib/env.ts | 6 + tests/unit/runtime-config.test.ts | 135 ++++++++++++++++++++ tests/unit/sandbox-router-api-url.test.ts | 145 ++++++++++++++++++++++ 8 files changed, 398 insertions(+), 10 deletions(-) create mode 100644 src/core/server/runtime-config.ts create mode 100644 tests/unit/runtime-config.test.ts create mode 100644 tests/unit/sandbox-router-api-url.test.ts diff --git a/.env.example b/.env.example index a60fc5946..56ece385d 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,13 @@ NEXT_PUBLIC_E2B_DOMAIN=e2b.dev # NEXT_PUBLIC_INFRA_API_URL=http://localhost:3000 # NEXT_PUBLIC_DASHBOARD_API_URL=http://localhost:3001 +### Runtime API base URLs. Unlike the NEXT_PUBLIC_ variables above, these are +### read when the server starts rather than baked into the build, so one +### prebuilt image can serve any install. They take precedence over the +### NEXT_PUBLIC_ overrides. +# E2B_INFRA_API_URL=http://127.0.0.1:3000 +# E2B_DASHBOARD_API_URL=http://127.0.0.1:3010 + ### Optional sandbox traffic base URL for local development proxies. # NEXT_PUBLIC_E2B_SANDBOX_URL=http://sandbox.lvh.me:3002 diff --git a/README.md b/README.md index 3041959a8..b1aa62d39 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,19 @@ Authentication is a single **team API key**: - Visiting `/` shows a form to enter the key. It is validated against infra-api and stored in an httpOnly `e2b_api_key` cookie. All upstream calls happen server-side with the `X-API-Key` header — the key never reaches client JavaScript. - Alternatively, set the `E2B_API_KEY` environment variable to pre-authenticate the whole deployment (single-user mode; the key form and sign-out are hidden). +### Configuration + +| Variable | Read | Purpose | +|---|---|---| +| `NEXT_PUBLIC_E2B_DOMAIN` | build | Derives `https://api.` and `https://dashboard-api.` | +| `NEXT_PUBLIC_INFRA_API_URL` / `NEXT_PUBLIC_DASHBOARD_API_URL` | build | Explicit overrides of the derived URLs | +| `E2B_INFRA_API_URL` / `E2B_DASHBOARD_API_URL` | server start | Explicit URLs for a prebuilt image; take precedence | + +Each URL resolves in that order: the runtime variable, then the +`NEXT_PUBLIC_` override, then the value derived from the domain. Next inlines +`NEXT_PUBLIC_*` into the bundles at build time, so a prebuilt image is +configured with the runtime variables. + ## Features - **Sandboxes**: paginated live list, per-sandbox monitoring (CPU/memory/disk), logs, filesystem inspector, and an in-browser terminal diff --git a/src/core/server/api/routers/sandbox.ts b/src/core/server/api/routers/sandbox.ts index caa02644d..5feaf44f1 100644 --- a/src/core/server/api/routers/sandbox.ts +++ b/src/core/server/api/routers/sandbox.ts @@ -13,6 +13,7 @@ import { import { createSandboxesRepository } from '@/core/modules/sandboxes/repository.server' import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' import { withAuthedRequestRepository } from '@/core/server/api/middlewares/repository' +import { resolveInfraApiUrl } from '@/core/server/runtime-config' import { createTRPCRouter } from '@/core/server/trpc/init' import { protectedProcedure } from '@/core/server/trpc/procedures' import { SandboxIdSchema } from '@/core/shared/schemas/api' @@ -229,7 +230,7 @@ export const sandboxRouter = createTRPCRouter({ } const connectionOpts = { - apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL, + apiUrl: resolveInfraApiUrl(), domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, apiKey, @@ -317,7 +318,7 @@ export const sandboxRouter = createTRPCRouter({ const { apiKey } = ctx const connectionOpts = { - apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL, + apiUrl: resolveInfraApiUrl(), domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, apiKey, @@ -373,7 +374,7 @@ export const sandboxRouter = createTRPCRouter({ const { apiKey } = ctx const connectionOpts = { - apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL, + apiUrl: resolveInfraApiUrl(), domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, apiKey, @@ -412,7 +413,7 @@ export const sandboxRouter = createTRPCRouter({ ) .mutation(async ({ ctx, input }) => { const sandbox = await Sandbox.connect(input.sandboxId, { - apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL, + apiUrl: resolveInfraApiUrl(), domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, timeoutMs: TERMINAL_SANDBOX_TIMEOUT_MS, diff --git a/src/core/server/runtime-config.ts b/src/core/server/runtime-config.ts new file mode 100644 index 000000000..a235339a6 --- /dev/null +++ b/src/core/server/runtime-config.ts @@ -0,0 +1,81 @@ +import 'server-only' + +/** + * Where this deployment's APIs live. + * + * Hosted deployments are configured with NEXT_PUBLIC_* variables, which Next + * inlines into the bundles at build time — a prebuilt container image cannot + * use them. The E2B_* variables here carry no NEXT_PUBLIC_ prefix, so Node + * reads them from the environment when the server starts and one image can + * serve any install. The NEXT_PUBLIC_* values stay as the fallback, so a + * deployment that sets none of the new variables resolves exactly as before. + * + * The chosen value is validated here and not only by the schema in + * `src/lib/env.ts`, which runs in dev, prebuild and tests but never inside a + * running container. `api.ts` calls these resolvers at module scope, so a + * malformed URL fails on the first server import however the process was + * started, rather than surfacing later as an opaque fetch failure. + */ + +interface ResolvedValue { + name: string + value: string +} + +function firstSet( + ...candidates: Array<[name: string, value: string | undefined]> +): ResolvedValue | undefined { + for (const [name, value] of candidates) { + const trimmed = value?.trim() + + if (trimmed) { + return { name, value: trimmed } + } + } + + return undefined +} + +function isHttpUrl(value: string): boolean { + try { + const { protocol } = new URL(value) + + // A scheme-less "localhost:3010" parses as the scheme "localhost", so the + // protocol has to be checked as well. + return protocol === 'http:' || protocol === 'https:' + } catch { + return false + } +} + +function assertHttpUrl({ name, value }: ResolvedValue): string { + if (!isHttpUrl(value)) { + throw new Error( + `${name} is not a URL: "${value}" (include the scheme, e.g. http://127.0.0.1:3000)` + ) + } + + return value +} + +export function resolveInfraApiUrl(): string { + const configured = firstSet( + ['E2B_INFRA_API_URL', process.env.E2B_INFRA_API_URL], + ['NEXT_PUBLIC_INFRA_API_URL', process.env.NEXT_PUBLIC_INFRA_API_URL] + ) + + return configured + ? assertHttpUrl(configured) + : `https://api.${process.env.NEXT_PUBLIC_E2B_DOMAIN}` +} + +export function resolveDashboardApiUrl(): string { + const configured = firstSet( + ['E2B_DASHBOARD_API_URL', process.env.E2B_DASHBOARD_API_URL], + ['NEXT_PUBLIC_DASHBOARD_API_URL', process.env.NEXT_PUBLIC_DASHBOARD_API_URL] + ) + + return configured + ? assertHttpUrl(configured) + : `https://dashboard-api.${process.env.NEXT_PUBLIC_E2B_DOMAIN}` +} diff --git a/src/core/shared/clients/api.ts b/src/core/shared/clients/api.ts index d6c23276f..54146b697 100644 --- a/src/core/shared/clients/api.ts +++ b/src/core/shared/clients/api.ts @@ -1,16 +1,16 @@ import createClient from 'openapi-fetch' +import { + resolveDashboardApiUrl, + resolveInfraApiUrl, +} from '@/core/server/runtime-config' import type { paths as DashboardPaths } from '@/core/shared/contracts/dashboard-api.types' import type { paths as InfraPaths } from '@/core/shared/contracts/infra-api.types' type CombinedPaths = InfraPaths -const INFRA_API_URL = - process.env.NEXT_PUBLIC_INFRA_API_URL || - `https://api.${process.env.NEXT_PUBLIC_E2B_DOMAIN}` +const INFRA_API_URL = resolveInfraApiUrl() -const DASHBOARD_API_URL = - process.env.NEXT_PUBLIC_DASHBOARD_API_URL || - `https://dashboard-api.${process.env.NEXT_PUBLIC_E2B_DOMAIN}` +const DASHBOARD_API_URL = resolveDashboardApiUrl() export const infra = createClient({ baseUrl: INFRA_API_URL, diff --git a/src/lib/env.ts b/src/lib/env.ts index 7ba741251..b4d3097ac 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -5,6 +5,12 @@ export const serverSchema = z.object({ // key form on `/` is skipped entirely (single-user self-hosted deployments). E2B_API_KEY: z.string().min(1).optional(), + // Where this deployment reaches its APIs, read at runtime. Self-hosted + // installs set these; hosted deployments keep using the NEXT_PUBLIC_* + // variables below, which stay the fallback. + E2B_INFRA_API_URL: z.url().optional(), + E2B_DASHBOARD_API_URL: z.url().optional(), + OTEL_SERVICE_NAME: z.string().optional(), OTEL_EXPORTER_OTLP_ENDPOINT: z.url().optional(), OTEL_EXPORTER_OTLP_PROTOCOL: z diff --git a/tests/unit/runtime-config.test.ts b/tests/unit/runtime-config.test.ts new file mode 100644 index 000000000..6291930fa --- /dev/null +++ b/tests/unit/runtime-config.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + resolveDashboardApiUrl, + resolveInfraApiUrl, +} from '@/core/server/runtime-config' + +const MANAGED_KEYS = [ + 'E2B_INFRA_API_URL', + 'E2B_DASHBOARD_API_URL', + 'NEXT_PUBLIC_INFRA_API_URL', + 'NEXT_PUBLIC_DASHBOARD_API_URL', + 'NEXT_PUBLIC_E2B_DOMAIN', +] as const + +const saved = new Map() + +beforeEach(() => { + for (const key of MANAGED_KEYS) { + saved.set(key, process.env[key]) + delete process.env[key] + } + process.env.NEXT_PUBLIC_E2B_DOMAIN = 'example.dev' +}) + +afterEach(() => { + for (const key of MANAGED_KEYS) { + const value = saved.get(key) + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } +}) + +describe('resolveInfraApiUrl', () => { + it('derives the URL from the domain when nothing is set', () => { + expect(resolveInfraApiUrl()).toBe('https://api.example.dev') + }) + + it('falls back to the NEXT_PUBLIC override', () => { + process.env.NEXT_PUBLIC_INFRA_API_URL = 'https://api.public.example' + + expect(resolveInfraApiUrl()).toBe('https://api.public.example') + }) + + it('prefers the runtime variable over the NEXT_PUBLIC override', () => { + process.env.NEXT_PUBLIC_INFRA_API_URL = 'https://api.public.example' + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + expect(resolveInfraApiUrl()).toBe('http://127.0.0.1:3000') + }) + + it('ignores an empty runtime variable', () => { + process.env.E2B_INFRA_API_URL = '' + process.env.NEXT_PUBLIC_INFRA_API_URL = 'https://api.public.example' + + expect(resolveInfraApiUrl()).toBe('https://api.public.example') + }) + + it('ignores a whitespace-only runtime variable', () => { + process.env.E2B_INFRA_API_URL = ' ' + process.env.NEXT_PUBLIC_INFRA_API_URL = 'https://api.public.example' + + expect(resolveInfraApiUrl()).toBe('https://api.public.example') + }) + + it('trims whitespace around the resolved value', () => { + process.env.E2B_INFRA_API_URL = ' http://127.0.0.1:3000\n' + + expect(resolveInfraApiUrl()).toBe('http://127.0.0.1:3000') + }) +}) + +describe('resolveDashboardApiUrl', () => { + it('derives the URL from the domain when nothing is set', () => { + expect(resolveDashboardApiUrl()).toBe('https://dashboard-api.example.dev') + }) + + it('falls back to the NEXT_PUBLIC override', () => { + process.env.NEXT_PUBLIC_DASHBOARD_API_URL = 'https://dash.public.example' + + expect(resolveDashboardApiUrl()).toBe('https://dash.public.example') + }) + + it('prefers the runtime variable over the NEXT_PUBLIC override', () => { + process.env.NEXT_PUBLIC_DASHBOARD_API_URL = 'https://dash.public.example' + process.env.E2B_DASHBOARD_API_URL = 'http://127.0.0.1:3010' + + expect(resolveDashboardApiUrl()).toBe('http://127.0.0.1:3010') + }) + + it('ignores an empty runtime variable', () => { + process.env.E2B_DASHBOARD_API_URL = '' + process.env.NEXT_PUBLIC_DASHBOARD_API_URL = 'https://dash.public.example' + + expect(resolveDashboardApiUrl()).toBe('https://dash.public.example') + }) +}) + +// The schema in src/lib/env.ts runs in dev, prebuild and tests but never in a +// running container, so a malformed URL has to fail here instead. +describe('URL validation', () => { + it('rejects a scheme-less runtime variable, naming it and its value', () => { + process.env.E2B_INFRA_API_URL = '127.0.0.1:3000' + + expect(() => resolveInfraApiUrl()).toThrow(/E2B_INFRA_API_URL/) + expect(() => resolveInfraApiUrl()).toThrow(/127\.0\.0\.1:3000/) + }) + + it('rejects a value whose scheme is not http(s)', () => { + process.env.E2B_DASHBOARD_API_URL = 'localhost:3010' + + expect(() => resolveDashboardApiUrl()).toThrow(/E2B_DASHBOARD_API_URL/) + }) + + it('names the NEXT_PUBLIC variable when that is the malformed one', () => { + process.env.NEXT_PUBLIC_INFRA_API_URL = 'api.public.example' + + expect(() => resolveInfraApiUrl()).toThrow(/NEXT_PUBLIC_INFRA_API_URL/) + }) + + it('accepts valid http and https URLs', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + process.env.E2B_DASHBOARD_API_URL = 'https://dashboard-api.example.dev' + + expect(resolveInfraApiUrl()).toBe('http://127.0.0.1:3000') + expect(resolveDashboardApiUrl()).toBe('https://dashboard-api.example.dev') + }) + + it('leaves the domain-derived fallback unvalidated', () => { + expect(resolveInfraApiUrl()).toBe('https://api.example.dev') + expect(resolveDashboardApiUrl()).toBe('https://dashboard-api.example.dev') + }) +}) diff --git a/tests/unit/sandbox-router-api-url.test.ts b/tests/unit/sandbox-router-api-url.test.ts new file mode 100644 index 000000000..d75bbd7d2 --- /dev/null +++ b/tests/unit/sandbox-router-api-url.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createTRPCContext } from '@/core/server/trpc/init' + +/** + * The sandbox router builds the E2B SDK connection options per request. A + * prebuilt image is configured with E2B_INFRA_API_URL at server start, so the + * control-plane calls have to resolve their API URL the same way the API + * clients do — reading the build-time NEXT_PUBLIC_ value directly would point + * a self-hosted install at whatever host the image was built for. + */ + +const sdkMock = vi.hoisted(() => ({ + connect: vi.fn(), + create: vi.fn(), + getFullInfo: vi.fn(), + pause: vi.fn(), +})) + +vi.mock('e2b', () => ({ + Sandbox: { + connect: sdkMock.connect, + create: sdkMock.create, + getFullInfo: sdkMock.getFullInfo, + pause: sdkMock.pause, + }, + TimeoutError: class TimeoutError extends Error {}, +})) + +const authMock = vi.hoisted(() => ({ getApiKey: vi.fn() })) +vi.mock('@/core/server/auth', () => ({ + getApiKey: authMock.getApiKey, +})) + +const { createCallerFactory } = await import('@/core/server/trpc/init') +const { sandboxRouter } = await import('@/core/server/api/routers/sandbox') + +const createCaller = createCallerFactory(sandboxRouter) + +async function caller() { + const ctx = await createTRPCContext({ headers: new Headers() }) + return createCaller(ctx) +} + +const RUNTIME_API_URL = 'http://127.0.0.1:3000' +const MANAGED_KEYS = ['E2B_INFRA_API_URL', 'NEXT_PUBLIC_INFRA_API_URL'] as const +const saved = new Map() + +const withRuntimeApiUrl = expect.objectContaining({ apiUrl: RUNTIME_API_URL }) + +beforeEach(() => { + vi.clearAllMocks() + + for (const key of MANAGED_KEYS) { + saved.set(key, process.env[key]) + delete process.env[key] + } + process.env.E2B_INFRA_API_URL = RUNTIME_API_URL + + authMock.getApiKey.mockResolvedValue('e2b_test_api_key') + sdkMock.connect.mockResolvedValue({ + sandboxId: 'sbxexisting', + pty: { kill: vi.fn().mockResolvedValue(true) }, + }) + sdkMock.create.mockResolvedValue({ sandboxId: 'sbxnew' }) + sdkMock.getFullInfo.mockResolvedValue({ + sandboxDomain: 'sandbox.example.com', + envdVersion: '0.2.0', + envdAccessToken: 'envd-token', + }) + sdkMock.pause.mockResolvedValue(true) +}) + +afterEach(() => { + for (const key of MANAGED_KEYS) { + const value = saved.get(key) + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } +}) + +describe('sandbox router control-plane API URL', () => { + it('openTerminal connects through the runtime API URL', async () => { + const c = await caller() + await c.openTerminal({ template: 'base', sandboxId: 'sbxexisting' }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + withRuntimeApiUrl + ) + }) + + it('openTerminal creates through the runtime API URL', async () => { + const c = await caller() + await c.openTerminal({ template: 'base' }) + + expect(sdkMock.create).toHaveBeenCalledWith('base', withRuntimeApiUrl) + }) + + it('resume connects and reads info through the runtime API URL', async () => { + const c = await caller() + await c.resume({ sandboxId: 'sbxexisting' }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + withRuntimeApiUrl + ) + expect(sdkMock.getFullInfo).toHaveBeenCalledWith( + 'sbxexisting', + withRuntimeApiUrl + ) + }) + + it('pause pauses through the runtime API URL', async () => { + const c = await caller() + await c.pause({ sandboxId: 'sbxexisting' }) + + expect(sdkMock.pause).toHaveBeenCalledWith('sbxexisting', withRuntimeApiUrl) + }) + + it('killTerminalPty connects through the runtime API URL', async () => { + const c = await caller() + await c.killTerminalPty({ sandboxId: 'sbxexisting', pid: 42 }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + withRuntimeApiUrl + ) + }) + + it('falls back to the NEXT_PUBLIC value when no runtime URL is set', async () => { + delete process.env.E2B_INFRA_API_URL + process.env.NEXT_PUBLIC_INFRA_API_URL = 'https://api.public.example' + + const c = await caller() + await c.resume({ sandboxId: 'sbxexisting' }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + expect.objectContaining({ apiUrl: 'https://api.public.example' }) + ) + }) +}) From 31732a4c76bee237409cc0c192c67379e3e786f7 Mon Sep 17 00:00:00 2001 From: Shan Valleru Date: Fri, 11 Sep 2026 15:28:19 -0700 Subject: [PATCH 4/7] feat(config): serve browser API URLs from a runtime config endpoint --- .env.example | 5 + README.md | 23 ++- src/app/api/config/route.ts | 18 ++ src/core/server/api/routers/sandbox.ts | 13 +- src/core/server/runtime-config.ts | 129 +++++++++++++- src/core/shared/runtime-config.ts | 79 +++++++++ .../dashboard/sandbox/inspect/context.tsx | 5 +- .../dashboard/terminal/sandbox-session.ts | 5 +- src/lib/env.ts | 1 + tests/integration/config-route.test.ts | 55 ++++++ tests/unit/dashboard-terminal.test.ts | 34 +++- tests/unit/runtime-config-client.test.ts | 123 +++++++++++++ tests/unit/runtime-config.test.ts | 165 ++++++++++++++++++ tests/unit/sandbox-router-api-url.test.ts | 67 ++++++- 14 files changed, 703 insertions(+), 19 deletions(-) create mode 100644 src/app/api/config/route.ts create mode 100644 src/core/shared/runtime-config.ts create mode 100644 tests/integration/config-route.test.ts create mode 100644 tests/unit/runtime-config-client.test.ts diff --git a/.env.example b/.env.example index 56ece385d..fba39212a 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,11 @@ NEXT_PUBLIC_E2B_DOMAIN=e2b.dev ### Optional sandbox traffic base URL for local development proxies. # NEXT_PUBLIC_E2B_SANDBOX_URL=http://sandbox.lvh.me:3002 +### Base URL the BROWSER uses to reach sandboxes (terminal and filesystem +### inspector). Unset on a runtime-configured install means "the host this +### page was served from, on port 3002". +# E2B_SANDBOX_URL=http://127.0.0.1:3002 + ### OpenTelemetry (disabled unless the endpoint is set). # OTEL_SERVICE_NAME=e2b-dashboard # OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 diff --git a/README.md b/README.md index b1aa62d39..a8b62619f 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,32 @@ Authentication is a single **team API key**: | `NEXT_PUBLIC_E2B_DOMAIN` | build | Derives `https://api.` and `https://dashboard-api.` | | `NEXT_PUBLIC_INFRA_API_URL` / `NEXT_PUBLIC_DASHBOARD_API_URL` | build | Explicit overrides of the derived URLs | | `E2B_INFRA_API_URL` / `E2B_DASHBOARD_API_URL` | server start | Explicit URLs for a prebuilt image; take precedence | +| `NEXT_PUBLIC_E2B_SANDBOX_URL` | build | Base URL the browser uses for sandbox traffic | +| `E2B_SANDBOX_URL` | per request | Same, for a prebuilt image; takes precedence, and is what the browser is told to use | Each URL resolves in that order: the runtime variable, then the `NEXT_PUBLIC_` override, then the value derived from the domain. Next inlines `NEXT_PUBLIC_*` into the bundles at build time, so a prebuilt image is -configured with the runtime variables. +configured with the runtime variables. Every explicit URL must carry an +`http://` or `https://` scheme, and the server rejects anything else naming +the variable. The infra and dashboard URLs are resolved at module scope, so a +malformed one fails on server start. The sandbox URL is resolved per request, +so a malformed one fails on first use, such as opening a terminal. + +The browser reads the sandbox URL from `GET /api/config`, which resolves it +per request. When `E2B_INFRA_API_URL` is set and no sandbox URL is given, it +defaults to the host the dashboard was reached on, port 3002. That default +routes only when the dashboard is reached over `localhost` or an IP address, +which is how the sandbox proxy accepts header-routed traffic. Reach the +dashboard on a domain name and you must set `E2B_SANDBOX_URL` yourself, to a +`localhost`, IP, or `sandbox.` base URL. `curl +http://:/api/config` shows what a deployment resolved. + +`E2B_SANDBOX_URL` is also read by the E2B SDK for its own connection config. +That is the same setting, so the dashboard deliberately shares the name. It +is served to the browser as-is, so the value has to be reachable from the +browser, not only from the server. A runtime-configured install should leave +it unset unless the port-3002 default is wrong. ## Features diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts new file mode 100644 index 000000000..a6f46ebc5 --- /dev/null +++ b/src/app/api/config/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server' +import { resolveBrowserRuntimeConfig } from '@/core/server/runtime-config' + +// Resolved from the environment and the request host on every call, so this +// must never be prerendered or cached. +export const dynamic = 'force-dynamic' + +export async function GET(request: Request) { + const config = resolveBrowserRuntimeConfig(request.headers, request.url) + + // Unauthenticated and readable by anyone who can reach the dashboard, so + // this payload must never grow a secret. + return NextResponse.json(config, { + headers: { + 'Cache-Control': 'no-store', + }, + }) +} diff --git a/src/core/server/api/routers/sandbox.ts b/src/core/server/api/routers/sandbox.ts index 5feaf44f1..83e0867ee 100644 --- a/src/core/server/api/routers/sandbox.ts +++ b/src/core/server/api/routers/sandbox.ts @@ -13,7 +13,10 @@ import { import { createSandboxesRepository } from '@/core/modules/sandboxes/repository.server' import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' import { withAuthedRequestRepository } from '@/core/server/api/middlewares/repository' -import { resolveInfraApiUrl } from '@/core/server/runtime-config' +import { + resolveInfraApiUrl, + resolveSandboxUrl, +} from '@/core/server/runtime-config' import { createTRPCRouter } from '@/core/server/trpc/init' import { protectedProcedure } from '@/core/server/trpc/procedures' import { SandboxIdSchema } from '@/core/shared/schemas/api' @@ -232,7 +235,7 @@ export const sandboxRouter = createTRPCRouter({ const connectionOpts = { apiUrl: resolveInfraApiUrl(), domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + sandboxUrl: resolveSandboxUrl(), apiKey, } @@ -320,7 +323,7 @@ export const sandboxRouter = createTRPCRouter({ const connectionOpts = { apiUrl: resolveInfraApiUrl(), domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + sandboxUrl: resolveSandboxUrl(), apiKey, } @@ -376,7 +379,7 @@ export const sandboxRouter = createTRPCRouter({ const connectionOpts = { apiUrl: resolveInfraApiUrl(), domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + sandboxUrl: resolveSandboxUrl(), apiKey, } @@ -415,7 +418,7 @@ export const sandboxRouter = createTRPCRouter({ const sandbox = await Sandbox.connect(input.sandboxId, { apiUrl: resolveInfraApiUrl(), domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + sandboxUrl: resolveSandboxUrl(), timeoutMs: TERMINAL_SANDBOX_TIMEOUT_MS, apiKey: ctx.apiKey, }) diff --git a/src/core/server/runtime-config.ts b/src/core/server/runtime-config.ts index a235339a6..d987d6a3b 100644 --- a/src/core/server/runtime-config.ts +++ b/src/core/server/runtime-config.ts @@ -1,5 +1,3 @@ -import 'server-only' - /** * Where this deployment's APIs live. * @@ -17,19 +15,29 @@ import 'server-only' * started, rather than surfacing later as an opaque fetch failure. */ +import 'server-only' +import type { BrowserRuntimeConfig } from '@/core/shared/runtime-config' + +const INFRA_API_DEFAULT_PORT = '3000' +const SANDBOX_DEFAULT_PORT = '3002' + interface ResolvedValue { name: string value: string } +function trimmed(value: string | null | undefined): string | undefined { + return value?.trim() || undefined +} + function firstSet( ...candidates: Array<[name: string, value: string | undefined]> ): ResolvedValue | undefined { for (const [name, value] of candidates) { - const trimmed = value?.trim() + const cleaned = trimmed(value) - if (trimmed) { - return { name, value: trimmed } + if (cleaned) { + return { name, value: cleaned } } } @@ -51,18 +59,22 @@ function isHttpUrl(value: string): boolean { function assertHttpUrl({ name, value }: ResolvedValue): string { if (!isHttpUrl(value)) { throw new Error( - `${name} is not a URL: "${value}" (include the scheme, e.g. http://127.0.0.1:3000)` + `${name} is not an http(s) URL: "${value}" (include the scheme, e.g. http://127.0.0.1:3000)` ) } return value } -export function resolveInfraApiUrl(): string { - const configured = firstSet( +function configuredInfraApiUrl(): ResolvedValue | undefined { + return firstSet( ['E2B_INFRA_API_URL', process.env.E2B_INFRA_API_URL], ['NEXT_PUBLIC_INFRA_API_URL', process.env.NEXT_PUBLIC_INFRA_API_URL] ) +} + +export function resolveInfraApiUrl(): string { + const configured = configuredInfraApiUrl() return configured ? assertHttpUrl(configured) @@ -79,3 +91,104 @@ export function resolveDashboardApiUrl(): string { ? assertHttpUrl(configured) : `https://dashboard-api.${process.env.NEXT_PUBLIC_E2B_DOMAIN}` } + +/** + * The base URL for sandbox traffic, or undefined to let the SDK derive one + * from the domain. E2B_SANDBOX_URL is also read by the SDK itself for the same + * purpose, so the shared name is deliberate. + */ +export function resolveSandboxUrl(): string | undefined { + const configured = firstSet( + ['E2B_SANDBOX_URL', process.env.E2B_SANDBOX_URL], + ['NEXT_PUBLIC_E2B_SANDBOX_URL', process.env.NEXT_PUBLIC_E2B_SANDBOX_URL] + ) + + return configured ? assertHttpUrl(configured) : undefined +} + +/** + * The forwarded protocol, accepted only when it is http or https. The result + * is served to the browser and handed to the SDK, so an unrecognised scheme + * from this header has to be dropped rather than echoed. + */ +function forwardedProtocol(headers: Headers): string | undefined { + const value = trimmed( + headers.get('x-forwarded-proto')?.split(',')[0] + )?.toLowerCase() + + return value === 'http' || value === 'https' ? value : undefined +} + +/** + * The hostname of `protocol://host`, or undefined when the host does not + * parse. A proxy header can carry anything, and an unparseable one must not + * take the whole endpoint down. + */ +function hostnameOf(protocol: string, host: string): string | undefined { + try { + // Through URL so an IPv6 literal keeps its brackets and any port on the + // incoming host is dropped before this one is appended. + return new URL(`${protocol}://${host}`).hostname || undefined + } catch { + return undefined + } +} + +/** + * The host the browser reached this server on, with `port` substituted. Built + * from the proxy headers first so a reverse-proxied install advertises the + * public host rather than its own internal one, falling back to the request + * URL, which is the one input guaranteed to parse. + */ +function requestOrigin( + headers: Headers, + requestUrl: string, + port: string +): string { + const url = new URL(requestUrl) + const protocol = forwardedProtocol(headers) ?? url.protocol.replace(/:$/, '') + const host = + trimmed(headers.get('x-forwarded-host')) ?? + trimmed(headers.get('host')) ?? + url.host + const hostname = hostnameOf(protocol, host) ?? url.hostname + + return `${protocol}://${hostname}:${port}` +} + +/** + * The URLs a browser needs, resolved per request. + * + * The request-host default for the sandbox URL applies only when + * E2B_INFRA_API_URL is set. Hosted deployments set none of the E2B_* variables + * and must keep passing no sandbox URL at all, so the SDK derives the sandbox + * host from the domain exactly as it does today; a self-hosted install + * configured at runtime is the only deployment that wants "the host you are + * reading this page from, on the sandbox port". + */ +export function resolveBrowserRuntimeConfig( + headers: Headers, + requestUrl: string +): BrowserRuntimeConfig { + const domain = trimmed(process.env.NEXT_PUBLIC_E2B_DOMAIN) + const isRuntimeConfigured = Boolean(trimmed(process.env.E2B_INFRA_API_URL)) + const configured = configuredInfraApiUrl() + + let infraApiUrl: string + + if (configured) { + infraApiUrl = assertHttpUrl(configured) + } else if (domain) { + infraApiUrl = `https://api.${domain}` + } else { + infraApiUrl = requestOrigin(headers, requestUrl, INFRA_API_DEFAULT_PORT) + } + + const sandboxUrl = + resolveSandboxUrl() ?? + (isRuntimeConfigured + ? requestOrigin(headers, requestUrl, SANDBOX_DEFAULT_PORT) + : null) + + return { infraApiUrl, sandboxUrl } +} diff --git a/src/core/shared/runtime-config.ts b/src/core/shared/runtime-config.ts new file mode 100644 index 000000000..ee26a0773 --- /dev/null +++ b/src/core/shared/runtime-config.ts @@ -0,0 +1,79 @@ +/** + * Server-resolved URLs the browser needs. Delivered by `GET /api/config` + * rather than inlined at build time, so one prebuilt image works on any host. + */ +export interface BrowserRuntimeConfig { + infraApiUrl: string | null + sandboxUrl: string | null +} + +const RUNTIME_CONFIG_URL = '/api/config' + +/** + * What a hosted deployment bakes into the browser bundle. Also the fallback + * when the endpoint cannot be reached, so a browser is never worse off than + * before the endpoint existed. + */ +function buildTimeConfig(): BrowserRuntimeConfig { + return { + infraApiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL ?? null, + sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL ?? null, + } +} + +let cached: Promise | null = null + +/** + * What the endpoint resolved, or null when it could not be reached. Never + * rejects, so the caller below always gets to decide what to cache. + */ +async function requestRuntimeConfig(): Promise { + try { + const response = await fetch(RUNTIME_CONFIG_URL, { cache: 'no-store' }) + + if (!response.ok) { + return null + } + + const body = (await response.json()) as Partial + const fallback = buildTimeConfig() + + return { + infraApiUrl: body.infraApiUrl ?? fallback.infraApiUrl, + sandboxUrl: body.sandboxUrl ?? fallback.sandboxUrl, + } + } catch { + return null + } +} + +export function fetchRuntimeConfig(): Promise { + if (!cached) { + const attempt: Promise = requestRuntimeConfig().then( + (resolved) => { + if (resolved) { + return resolved + } + + // The endpoint did not answer. Everyone already waiting on this + // attempt shares its fallback, but the cache is dropped so the next + // caller retries rather than being pinned to the build-time values + // for the life of the page. Guarded so a slow failure cannot clear a + // newer attempt that replaced it. + if (cached === attempt) { + cached = null + } + + return buildTimeConfig() + } + ) + + cached = attempt + } + + return cached +} + +export function resetRuntimeConfigCache(): void { + cached = null +} diff --git a/src/features/dashboard/sandbox/inspect/context.tsx b/src/features/dashboard/sandbox/inspect/context.tsx index 14c589ba0..b09c732c5 100644 --- a/src/features/dashboard/sandbox/inspect/context.tsx +++ b/src/features/dashboard/sandbox/inspect/context.tsx @@ -11,6 +11,7 @@ import { useState, } from 'react' import { createEnvdSandbox } from '@/core/shared/create-envd-sandbox' +import { fetchRuntimeConfig } from '@/core/shared/runtime-config' import { useSandboxInspectAnalytics } from '@/lib/hooks/use-analytics' import { getParentPath, normalizePath } from '@/lib/utils/filesystem' import { useTRPCClient } from '@/trpc/client' @@ -179,10 +180,12 @@ export default function SandboxInspectProvider({ sandboxManagerRef.current.stopWatching() } + const { sandboxUrl } = await fetchRuntimeConfig() + const sandbox = createEnvdSandbox({ ...creds, domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + sandboxUrl: sandboxUrl ?? undefined, }) const manager = new SandboxManager(store, sandbox, rootPath) sandboxManagerRef.current = manager diff --git a/src/features/dashboard/terminal/sandbox-session.ts b/src/features/dashboard/terminal/sandbox-session.ts index e7815badb..a65b22c17 100644 --- a/src/features/dashboard/terminal/sandbox-session.ts +++ b/src/features/dashboard/terminal/sandbox-session.ts @@ -1,5 +1,6 @@ import type { Sandbox } from 'e2b' import { createEnvdSandbox } from '@/core/shared/create-envd-sandbox' +import { fetchRuntimeConfig } from '@/core/shared/runtime-config' import type { TRPCRouterOutputs } from '@/trpc/client' import { clearStoredTerminalSession, @@ -121,9 +122,11 @@ async function acquireTerminalSandbox( throw error instanceof Error ? error : new Error(fallbackMessage) } + const { sandboxUrl } = await fetchRuntimeConfig() + return createEnvdSandbox({ ...connection, domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + sandboxUrl: sandboxUrl ?? undefined, }) } diff --git a/src/lib/env.ts b/src/lib/env.ts index b4d3097ac..2f846bd50 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -10,6 +10,7 @@ export const serverSchema = z.object({ // variables below, which stay the fallback. E2B_INFRA_API_URL: z.url().optional(), E2B_DASHBOARD_API_URL: z.url().optional(), + E2B_SANDBOX_URL: z.url().optional(), OTEL_SERVICE_NAME: z.string().optional(), OTEL_EXPORTER_OTLP_ENDPOINT: z.url().optional(), diff --git a/tests/integration/config-route.test.ts b/tests/integration/config-route.test.ts new file mode 100644 index 000000000..d2a3ce6e5 --- /dev/null +++ b/tests/integration/config-route.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { GET } from '@/app/api/config/route' + +const saved = new Map() +// tests/setup.ts loads .env files, so a developer's local sandbox URL would +// otherwise leak into these expectations. +const MANAGED_KEYS = [ + 'E2B_INFRA_API_URL', + 'E2B_SANDBOX_URL', + 'NEXT_PUBLIC_INFRA_API_URL', + 'NEXT_PUBLIC_E2B_SANDBOX_URL', +] as const + +beforeEach(() => { + for (const key of MANAGED_KEYS) { + saved.set(key, process.env[key]) + delete process.env[key] + } +}) + +afterEach(() => { + for (const key of MANAGED_KEYS) { + const value = saved.get(key) + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } +}) + +describe('/api/config', () => { + it('serves the browser config resolved from the request', async () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + const response = await GET( + new Request('http://dash.example:3001/api/config') + ) + + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('no-store') + await expect(response.json()).resolves.toEqual({ + infraApiUrl: 'http://127.0.0.1:3000', + sandboxUrl: 'http://dash.example:3002', + }) + }) + + it('reports no sandbox url when nothing configures one', async () => { + const response = await GET( + new Request('http://dash.example:3001/api/config') + ) + + await expect(response.json()).resolves.toMatchObject({ sandboxUrl: null }) + }) +}) diff --git a/tests/unit/dashboard-terminal.test.ts b/tests/unit/dashboard-terminal.test.ts index 29a5748c0..36b6f10b2 100644 --- a/tests/unit/dashboard-terminal.test.ts +++ b/tests/unit/dashboard-terminal.test.ts @@ -20,6 +20,13 @@ vi.mock('@/core/shared/create-envd-sandbox', () => ({ createEnvdSandbox: mockCreateEnvdSandbox, })) +vi.mock('@/core/shared/runtime-config', () => ({ + fetchRuntimeConfig: vi.fn(async () => ({ + infraApiUrl: 'http://127.0.0.1:3000', + sandboxUrl: 'http://host.example:3002', + })), +})) + // The `sandbox.openTerminal` tRPC mutation is injected into // openTerminalSandbox, so the test passes this mock directly instead of // mocking a module. @@ -282,7 +289,7 @@ describe('dashboard terminal helpers', () => { envdVersion: '0.2.0', envdAccessToken: 'envd-token', domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + sandboxUrl: 'http://host.example:3002', }) expect(readStoredTerminalSession()).toBeNull() expect(statuses).toEqual([ @@ -290,6 +297,29 @@ describe('dashboard terminal helpers', () => { ]) }) + it('prefers the runtime config sandbox url over the build-time one', async () => { + const savedSandboxUrl = process.env.NEXT_PUBLIC_E2B_SANDBOX_URL + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://build-time.example:3002' + + try { + await openTerminalSandbox({ + onStatus: () => {}, + openTerminal: mockOpenTerminal, + template: 'base', + }) + + expect(mockCreateEnvdSandbox).toHaveBeenCalledWith( + expect.objectContaining({ sandboxUrl: 'http://host.example:3002' }) + ) + } finally { + if (savedSandboxUrl === undefined) { + delete process.env.NEXT_PUBLIC_E2B_SANDBOX_URL + } else { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = savedSandboxUrl + } + } + }) + it('connects to a tokenless (secure: false) sandbox without an envd access token', async () => { mockOpenTerminal.mockResolvedValueOnce({ sandboxId: 'insecure-sandbox', @@ -311,7 +341,7 @@ describe('dashboard terminal helpers', () => { envdVersion: '0.2.0', envdAccessToken: undefined, domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + sandboxUrl: 'http://host.example:3002', }) }) diff --git a/tests/unit/runtime-config-client.test.ts b/tests/unit/runtime-config-client.test.ts new file mode 100644 index 000000000..7c267d859 --- /dev/null +++ b/tests/unit/runtime-config-client.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + fetchRuntimeConfig, + resetRuntimeConfigCache, +} from '@/core/shared/runtime-config' + +const savedSandboxUrl = process.env.NEXT_PUBLIC_E2B_SANDBOX_URL +const savedInfraUrl = process.env.NEXT_PUBLIC_INFRA_API_URL + +beforeEach(() => { + resetRuntimeConfigCache() + delete process.env.NEXT_PUBLIC_E2B_SANDBOX_URL + delete process.env.NEXT_PUBLIC_INFRA_API_URL +}) + +afterEach(() => { + vi.unstubAllGlobals() + if (savedSandboxUrl === undefined) { + delete process.env.NEXT_PUBLIC_E2B_SANDBOX_URL + } else { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = savedSandboxUrl + } + if (savedInfraUrl === undefined) { + delete process.env.NEXT_PUBLIC_INFRA_API_URL + } else { + process.env.NEXT_PUBLIC_INFRA_API_URL = savedInfraUrl + } +}) + +describe('fetchRuntimeConfig', () => { + it('returns what the config endpoint resolved', async () => { + const fetchMock = vi.fn(async () => + Response.json({ + infraApiUrl: 'http://127.0.0.1:3000', + sandboxUrl: 'http://host.example:3002', + }) + ) + vi.stubGlobal('fetch', fetchMock) + + await expect(fetchRuntimeConfig()).resolves.toEqual({ + infraApiUrl: 'http://127.0.0.1:3000', + sandboxUrl: 'http://host.example:3002', + }) + expect(fetchMock).toHaveBeenCalledWith('/api/config', { + cache: 'no-store', + }) + }) + + it('coalesces concurrent callers into one request', async () => { + const fetchMock = vi.fn(async () => + Response.json({ infraApiUrl: null, sandboxUrl: null }) + ) + vi.stubGlobal('fetch', fetchMock) + + await Promise.all([fetchRuntimeConfig(), fetchRuntimeConfig()]) + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('falls back to the build-time values when the endpoint fails', async () => { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://sandbox.lvh.me:3002' + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('nope', { status: 500 })) + ) + + await expect(fetchRuntimeConfig()).resolves.toEqual({ + infraApiUrl: null, + sandboxUrl: 'http://sandbox.lvh.me:3002', + }) + }) + + it('falls back to the build-time values when the request throws', async () => { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://sandbox.lvh.me:3002' + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('offline') + }) + ) + + await expect(fetchRuntimeConfig()).resolves.toEqual({ + infraApiUrl: null, + sandboxUrl: 'http://sandbox.lvh.me:3002', + }) + }) + + it('retries after a failed attempt instead of pinning the fallback', async () => { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://sandbox.lvh.me:3002' + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('nope', { status: 500 })) + .mockResolvedValueOnce( + Response.json({ + infraApiUrl: 'http://127.0.0.1:3000', + sandboxUrl: 'http://host.example:3002', + }) + ) + vi.stubGlobal('fetch', fetchMock) + + await expect(fetchRuntimeConfig()).resolves.toEqual({ + infraApiUrl: null, + sandboxUrl: 'http://sandbox.lvh.me:3002', + }) + await expect(fetchRuntimeConfig()).resolves.toEqual({ + infraApiUrl: 'http://127.0.0.1:3000', + sandboxUrl: 'http://host.example:3002', + }) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('keeps caching a successful result', async () => { + const fetchMock = vi.fn(async () => + Response.json({ infraApiUrl: null, sandboxUrl: 'http://ok.example:3002' }) + ) + vi.stubGlobal('fetch', fetchMock) + + await fetchRuntimeConfig() + await fetchRuntimeConfig() + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/unit/runtime-config.test.ts b/tests/unit/runtime-config.test.ts index 6291930fa..e0a9c4ed1 100644 --- a/tests/unit/runtime-config.test.ts +++ b/tests/unit/runtime-config.test.ts @@ -1,14 +1,18 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { + resolveBrowserRuntimeConfig, resolveDashboardApiUrl, resolveInfraApiUrl, + resolveSandboxUrl, } from '@/core/server/runtime-config' const MANAGED_KEYS = [ 'E2B_INFRA_API_URL', 'E2B_DASHBOARD_API_URL', + 'E2B_SANDBOX_URL', 'NEXT_PUBLIC_INFRA_API_URL', 'NEXT_PUBLIC_DASHBOARD_API_URL', + 'NEXT_PUBLIC_E2B_SANDBOX_URL', 'NEXT_PUBLIC_E2B_DOMAIN', ] as const @@ -98,6 +102,147 @@ describe('resolveDashboardApiUrl', () => { }) }) +describe('resolveSandboxUrl', () => { + it('reports no sandbox url when nothing is set', () => { + expect(resolveSandboxUrl()).toBeUndefined() + }) + + it('falls back to the NEXT_PUBLIC override', () => { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://sandbox.lvh.me:3002' + + expect(resolveSandboxUrl()).toBe('http://sandbox.lvh.me:3002') + }) + + it('prefers the runtime variable over the NEXT_PUBLIC override', () => { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://sandbox.lvh.me:3002' + process.env.E2B_SANDBOX_URL = 'https://sandbox.internal.example' + + expect(resolveSandboxUrl()).toBe('https://sandbox.internal.example') + }) + + it('ignores a whitespace-only runtime variable', () => { + process.env.E2B_SANDBOX_URL = ' ' + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://sandbox.lvh.me:3002' + + expect(resolveSandboxUrl()).toBe('http://sandbox.lvh.me:3002') + }) +}) + +describe('resolveBrowserRuntimeConfig', () => { + const requestUrl = 'http://dash.example:3001/api/config' + const headers = (init: Record = {}) => + new Headers({ host: 'dash.example:3001', ...init }) + + it('reports no sandbox url for a deployment that sets no runtime variables', () => { + expect(resolveBrowserRuntimeConfig(headers(), requestUrl)).toEqual({ + infraApiUrl: 'https://api.example.dev', + sandboxUrl: null, + }) + }) + + it('falls back to the NEXT_PUBLIC sandbox url', () => { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://sandbox.lvh.me:3002' + + expect(resolveBrowserRuntimeConfig(headers(), requestUrl).sandboxUrl).toBe( + 'http://sandbox.lvh.me:3002' + ) + }) + + it('prefers the runtime sandbox url over the NEXT_PUBLIC one', () => { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://sandbox.lvh.me:3002' + process.env.E2B_SANDBOX_URL = 'https://sandbox.internal.example' + + expect(resolveBrowserRuntimeConfig(headers(), requestUrl).sandboxUrl).toBe( + 'https://sandbox.internal.example' + ) + }) + + it('defaults to the request host on 3002 for a runtime-configured install', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + expect(resolveBrowserRuntimeConfig(headers(), requestUrl)).toEqual({ + infraApiUrl: 'http://127.0.0.1:3000', + sandboxUrl: 'http://dash.example:3002', + }) + }) + + it('honours x-forwarded-host and x-forwarded-proto', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + const config = resolveBrowserRuntimeConfig( + headers({ + 'x-forwarded-host': 'public.example:8443', + 'x-forwarded-proto': 'https,http', + }), + requestUrl + ) + + expect(config.sandboxUrl).toBe('https://public.example:3002') + }) + + // A proxy header is attacker-controllable in a misconfigured deployment, and + // whatever lands here is served to the browser and handed to the SDK. + it('ignores a malformed x-forwarded-host', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + const config = resolveBrowserRuntimeConfig( + headers({ host: 'other.example:3001', 'x-forwarded-host': 'foo bar' }), + requestUrl + ) + + expect(config.sandboxUrl).toBe('http://dash.example:3002') + }) + + it('ignores an x-forwarded-host whose port is out of range', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + const config = resolveBrowserRuntimeConfig( + headers({ 'x-forwarded-host': 'h:99999' }), + requestUrl + ) + + expect(config.sandboxUrl).toBe('http://dash.example:3002') + }) + + it('ignores an x-forwarded-proto that is not http(s)', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + const config = resolveBrowserRuntimeConfig( + headers({ 'x-forwarded-proto': 'javascript' }), + requestUrl + ) + + expect(config.sandboxUrl).toBe('http://dash.example:3002') + }) + + it('accepts an uppercase x-forwarded-proto', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + const config = resolveBrowserRuntimeConfig( + headers({ 'x-forwarded-proto': 'HTTPS' }), + requestUrl + ) + + expect(config.sandboxUrl).toBe('https://dash.example:3002') + }) + + it('falls back to the request host for the infra url with no domain set', () => { + delete process.env.NEXT_PUBLIC_E2B_DOMAIN + + expect(resolveBrowserRuntimeConfig(headers(), requestUrl).infraApiUrl).toBe( + 'http://dash.example:3000' + ) + }) + + it('reads the host from the request url when no host header is present', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + expect( + resolveBrowserRuntimeConfig(new Headers(), requestUrl).sandboxUrl + ).toBe('http://dash.example:3002') + }) +}) + // The schema in src/lib/env.ts runs in dev, prebuild and tests but never in a // running container, so a malformed URL has to fail here instead. describe('URL validation', () => { @@ -132,4 +277,24 @@ describe('URL validation', () => { expect(resolveInfraApiUrl()).toBe('https://api.example.dev') expect(resolveDashboardApiUrl()).toBe('https://dashboard-api.example.dev') }) + + it('rejects a malformed sandbox url, naming it and its value', () => { + process.env.E2B_SANDBOX_URL = 'sandbox.internal.example:3002' + + expect(() => resolveSandboxUrl()).toThrow(/E2B_SANDBOX_URL/) + expect(() => resolveSandboxUrl()).toThrow(/sandbox\.internal\.example:3002/) + }) + + // The request-host default is built from a parsed URL, not read from the + // environment, so it never reaches the validator. + it('leaves the request-host default unvalidated', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + expect( + resolveBrowserRuntimeConfig( + new Headers({ host: 'dash.example:3001' }), + 'http://dash.example:3001/api/config' + ).sandboxUrl + ).toBe('http://dash.example:3002') + }) }) diff --git a/tests/unit/sandbox-router-api-url.test.ts b/tests/unit/sandbox-router-api-url.test.ts index d75bbd7d2..1ff395e0e 100644 --- a/tests/unit/sandbox-router-api-url.test.ts +++ b/tests/unit/sandbox-router-api-url.test.ts @@ -42,7 +42,12 @@ async function caller() { } const RUNTIME_API_URL = 'http://127.0.0.1:3000' -const MANAGED_KEYS = ['E2B_INFRA_API_URL', 'NEXT_PUBLIC_INFRA_API_URL'] as const +const MANAGED_KEYS = [ + 'E2B_INFRA_API_URL', + 'E2B_SANDBOX_URL', + 'NEXT_PUBLIC_INFRA_API_URL', + 'NEXT_PUBLIC_E2B_SANDBOX_URL', +] as const const saved = new Map() const withRuntimeApiUrl = expect.objectContaining({ apiUrl: RUNTIME_API_URL }) @@ -143,3 +148,63 @@ describe('sandbox router control-plane API URL', () => { ) }) }) + +/** + * The sandbox URL travels in the same connection options, so a prebuilt image + * has to read it the same way — otherwise the server talks to one sandbox host + * and the browser, which reads `GET /api/config`, talks to another. + */ +describe('sandbox router sandbox URL', () => { + it('passes the runtime sandbox URL to the control plane', async () => { + process.env.E2B_SANDBOX_URL = 'https://sandbox.internal.example' + + const c = await caller() + await c.openTerminal({ template: 'base', sandboxId: 'sbxexisting' }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + expect.objectContaining({ + sandboxUrl: 'https://sandbox.internal.example', + }) + ) + }) + + it('prefers the runtime sandbox URL over the NEXT_PUBLIC one', async () => { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://sandbox.lvh.me:3002' + process.env.E2B_SANDBOX_URL = 'https://sandbox.internal.example' + + const c = await caller() + await c.pause({ sandboxId: 'sbxexisting' }) + + expect(sdkMock.pause).toHaveBeenCalledWith( + 'sbxexisting', + expect.objectContaining({ + sandboxUrl: 'https://sandbox.internal.example', + }) + ) + }) + + it('falls back to the NEXT_PUBLIC sandbox URL', async () => { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://sandbox.lvh.me:3002' + + const c = await caller() + await c.killTerminalPty({ sandboxId: 'sbxexisting', pid: 42 }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + expect.objectContaining({ sandboxUrl: 'http://sandbox.lvh.me:3002' }) + ) + }) + + // The request-host default is a browser convenience: the server cannot + // assume it can reach its own public host on the sandbox port. + it('passes no sandbox URL when none is configured', async () => { + const c = await caller() + await c.resume({ sandboxId: 'sbxexisting' }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + expect.objectContaining({ sandboxUrl: undefined }) + ) + }) +}) From b8bb9af1d1a1d9accbccb45e509a6d7e6516d8d0 Mon Sep 17 00:00:00 2001 From: Shan Valleru Date: Fri, 11 Sep 2026 15:57:07 -0700 Subject: [PATCH 5/7] feat(config): make the api key cookie's Secure flag configurable --- .env.example | 4 +++ README.md | 1 + src/configs/cookies.ts | 23 +++++++++++- src/lib/env.ts | 4 +++ tests/unit/cookie-options.test.ts | 60 +++++++++++++++++++++++++++++++ 5 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 tests/unit/cookie-options.test.ts diff --git a/.env.example b/.env.example index fba39212a..3639c30b5 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,10 @@ NEXT_PUBLIC_E2B_DOMAIN=e2b.dev ### page was served from, on port 3002". # E2B_SANDBOX_URL=http://127.0.0.1:3002 +### Set to "false" when the dashboard is served over plain http (a LAN address +### or an IP), or the browser drops the api key cookie and the key form loops. +# DASHBOARD_COOKIE_SECURE=false + ### OpenTelemetry (disabled unless the endpoint is set). # OTEL_SERVICE_NAME=e2b-dashboard # OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 diff --git a/README.md b/README.md index a8b62619f..f6576f157 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Authentication is a single **team API key**: | `E2B_INFRA_API_URL` / `E2B_DASHBOARD_API_URL` | server start | Explicit URLs for a prebuilt image; take precedence | | `NEXT_PUBLIC_E2B_SANDBOX_URL` | build | Base URL the browser uses for sandbox traffic | | `E2B_SANDBOX_URL` | per request | Same, for a prebuilt image; takes precedence, and is what the browser is told to use | +| `DASHBOARD_COOKIE_SECURE` | server start | `false` keeps the api key cookie usable over plain http; defaults to secure in production builds | Each URL resolves in that order: the runtime variable, then the `NEXT_PUBLIC_` override, then the value derived from the domain. Next inlines diff --git a/src/configs/cookies.ts b/src/configs/cookies.ts index cf285c598..89e616df6 100644 --- a/src/configs/cookies.ts +++ b/src/configs/cookies.ts @@ -17,11 +17,32 @@ export const COOKIE_KEYS = { export const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 // 1 year +/** + * Browsers drop a `Secure` cookie on a plain-http origin, so a self-hosted + * install served over http on a LAN address turns the key form into a login + * loop. DASHBOARD_COOKIE_SECURE overrides the flag; unset keeps the build-mode + * default, which is what every existing deployment already gets. + * + * The value is read case-insensitively rather than trusting the schema's + * narrowed type: a prebuilt image starts without the env check, so whatever + * the container was handed arrives here unvalidated. + */ +function isSecureCookie(): boolean { + const configured: string | undefined = + process.env.DASHBOARD_COOKIE_SECURE?.toLowerCase() + + if (configured !== undefined && configured !== '') { + return configured !== 'false' + } + + return process.env.NODE_ENV === 'production' +} + const BASE_COOKIE_OPTIONS: Partial = { path: '/', maxAge: COOKIE_MAX_AGE_SECONDS, sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', + secure: isSecureCookie(), } /** diff --git a/src/lib/env.ts b/src/lib/env.ts index 2f846bd50..09a3e2881 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -12,6 +12,10 @@ export const serverSchema = z.object({ E2B_DASHBOARD_API_URL: z.url().optional(), E2B_SANDBOX_URL: z.url().optional(), + // Overrides the api key cookie's Secure flag. Self-hosted installs served + // over plain http need "false", or the browser drops the cookie. + DASHBOARD_COOKIE_SECURE: z.enum(['true', 'false']).optional(), + OTEL_SERVICE_NAME: z.string().optional(), OTEL_EXPORTER_OTLP_ENDPOINT: z.url().optional(), OTEL_EXPORTER_OTLP_PROTOCOL: z diff --git a/tests/unit/cookie-options.test.ts b/tests/unit/cookie-options.test.ts new file mode 100644 index 000000000..49eb08d12 --- /dev/null +++ b/tests/unit/cookie-options.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +async function loadApiKeyCookieOptions() { + vi.resetModules() + const cookies = await import('@/configs/cookies') + return cookies.COOKIE_OPTIONS[cookies.COOKIE_KEYS.API_KEY] +} + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('api key cookie options', () => { + it('keeps the build-mode default when the flag is unset', async () => { + vi.stubEnv('DASHBOARD_COOKIE_SECURE', undefined) + vi.stubEnv('NODE_ENV', 'production') + + await expect(loadApiKeyCookieOptions()).resolves.toMatchObject({ + secure: true, + httpOnly: true, + sameSite: 'lax', + }) + }) + + it('is not secure outside production when the flag is unset', async () => { + vi.stubEnv('DASHBOARD_COOKIE_SECURE', undefined) + vi.stubEnv('NODE_ENV', 'development') + + await expect(loadApiKeyCookieOptions()).resolves.toMatchObject({ + secure: false, + }) + }) + + it('drops the Secure flag when DASHBOARD_COOKIE_SECURE is false', async () => { + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('DASHBOARD_COOKIE_SECURE', 'false') + + await expect(loadApiKeyCookieOptions()).resolves.toMatchObject({ + secure: false, + }) + }) + + it('accepts the flag case-insensitively', async () => { + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('DASHBOARD_COOKIE_SECURE', 'FALSE') + + await expect(loadApiKeyCookieOptions()).resolves.toMatchObject({ + secure: false, + }) + }) + + it('keeps the Secure flag when DASHBOARD_COOKIE_SECURE is true', async () => { + vi.stubEnv('NODE_ENV', 'development') + vi.stubEnv('DASHBOARD_COOKIE_SECURE', 'true') + + await expect(loadApiKeyCookieOptions()).resolves.toMatchObject({ + secure: true, + }) + }) +}) From 03121b0da716d65afa97585b35ff79a7100067c9 Mon Sep 17 00:00:00 2001 From: Shan Valleru Date: Fri, 11 Sep 2026 16:14:33 -0700 Subject: [PATCH 6/7] fix(config): harden forwarded-host fallback and document the runtime config trade-offs --- .env.example | 4 +++- README.md | 7 ++++++- src/configs/cookies.ts | 2 +- src/core/server/runtime-config.ts | 29 ++++++++++++++++++++--------- tests/unit/cookie-options.test.ts | 29 +++++++++++++++++++++++++++++ tests/unit/runtime-config.test.ts | 13 ++++++++++++- 6 files changed, 71 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 3639c30b5..fe8ee6df0 100644 --- a/.env.example +++ b/.env.example @@ -33,7 +33,9 @@ NEXT_PUBLIC_E2B_DOMAIN=e2b.dev ### Base URL the BROWSER uses to reach sandboxes (terminal and filesystem ### inspector). Unset on a runtime-configured install means "the host this -### page was served from, on port 3002". +### page was served from, on port 3002". The value is handed to the browser, +### so it has to be reachable from the browser and not only from the server — +### the loopback below works only when the two are the same machine. # E2B_SANDBOX_URL=http://127.0.0.1:3002 ### Set to "false" when the dashboard is served over plain http (a LAN address diff --git a/README.md b/README.md index f6576f157..86573b446 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Authentication is a single **team API key**: | `E2B_INFRA_API_URL` / `E2B_DASHBOARD_API_URL` | server start | Explicit URLs for a prebuilt image; take precedence | | `NEXT_PUBLIC_E2B_SANDBOX_URL` | build | Base URL the browser uses for sandbox traffic | | `E2B_SANDBOX_URL` | per request | Same, for a prebuilt image; takes precedence, and is what the browser is told to use | -| `DASHBOARD_COOKIE_SECURE` | server start | `false` keeps the api key cookie usable over plain http; defaults to secure in production builds | +| `DASHBOARD_COOKIE_SECURE` | server start | `false` only for a plain-http install; the api key cookie then travels unencrypted. Defaults to secure in production builds | Each URL resolves in that order: the runtime variable, then the `NEXT_PUBLIC_` override, then the value derived from the domain. Next inlines @@ -57,6 +57,11 @@ dashboard on a domain name and you must set `E2B_SANDBOX_URL` yourself, to a `localhost`, IP, or `sandbox.` base URL. `curl http://:/api/config` shows what a deployment resolved. +`/api/config` is unauthenticated and carries no secret. Behind a reverse +proxy, that proxy must set `X-Forwarded-Host` and `X-Forwarded-Proto` itself +rather than pass through whatever a client sent; `GET /api/config` trusts +them to describe the browser-facing origin. + `E2B_SANDBOX_URL` is also read by the E2B SDK for its own connection config. That is the same setting, so the dashboard deliberately shares the name. It is served to the browser as-is, so the value has to be reachable from the diff --git a/src/configs/cookies.ts b/src/configs/cookies.ts index 89e616df6..78e721a22 100644 --- a/src/configs/cookies.ts +++ b/src/configs/cookies.ts @@ -29,7 +29,7 @@ export const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 // 1 year */ function isSecureCookie(): boolean { const configured: string | undefined = - process.env.DASHBOARD_COOKIE_SECURE?.toLowerCase() + process.env.DASHBOARD_COOKIE_SECURE?.trim().toLowerCase() if (configured !== undefined && configured !== '') { return configured !== 'false' diff --git a/src/core/server/runtime-config.ts b/src/core/server/runtime-config.ts index d987d6a3b..e72abb7b7 100644 --- a/src/core/server/runtime-config.ts +++ b/src/core/server/runtime-config.ts @@ -120,11 +120,20 @@ function forwardedProtocol(headers: Headers): string | undefined { } /** - * The hostname of `protocol://host`, or undefined when the host does not - * parse. A proxy header can carry anything, and an unparseable one must not - * take the whole endpoint down. + * The hostname of `protocol://host`, or undefined when the host is absent or + * does not parse. A proxy header can carry anything, and an unparseable one + * must fall through to the next candidate rather than take the endpoint down. */ -function hostnameOf(protocol: string, host: string): string | undefined { +function hostnameOf( + protocol: string, + host: string | undefined +): string | undefined { + // Without this guard `http://undefined` parses, to the hostname + // "undefined". + if (!host) { + return undefined + } + try { // Through URL so an IPv6 literal keeps its brackets and any port on the // incoming host is dropped before this one is appended. @@ -147,11 +156,13 @@ function requestOrigin( ): string { const url = new URL(requestUrl) const protocol = forwardedProtocol(headers) ?? url.protocol.replace(/:$/, '') - const host = - trimmed(headers.get('x-forwarded-host')) ?? - trimmed(headers.get('host')) ?? - url.host - const hostname = hostnameOf(protocol, host) ?? url.hostname + + // Each candidate is parsed in turn, so a malformed proxy header falls + // through to the next one instead of discarding a good host below it. + const hostname = + hostnameOf(protocol, trimmed(headers.get('x-forwarded-host'))) ?? + hostnameOf(protocol, trimmed(headers.get('host'))) ?? + url.hostname return `${protocol}://${hostname}:${port}` } diff --git a/tests/unit/cookie-options.test.ts b/tests/unit/cookie-options.test.ts index 49eb08d12..2315c0946 100644 --- a/tests/unit/cookie-options.test.ts +++ b/tests/unit/cookie-options.test.ts @@ -49,6 +49,35 @@ describe('api key cookie options', () => { }) }) + it('ignores whitespace around the flag', async () => { + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('DASHBOARD_COOKIE_SECURE', ' false ') + + await expect(loadApiKeyCookieOptions()).resolves.toMatchObject({ + secure: false, + }) + }) + + // An orchestrator that always passes the variable sends "" when it is + // unset, which has to mean "unset" rather than "not false". + it('treats an empty flag as unset', async () => { + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('DASHBOARD_COOKIE_SECURE', '') + + await expect(loadApiKeyCookieOptions()).resolves.toMatchObject({ + secure: true, + }) + }) + + it('treats a whitespace-only flag as unset', async () => { + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('DASHBOARD_COOKIE_SECURE', ' ') + + await expect(loadApiKeyCookieOptions()).resolves.toMatchObject({ + secure: true, + }) + }) + it('keeps the Secure flag when DASHBOARD_COOKIE_SECURE is true', async () => { vi.stubEnv('NODE_ENV', 'development') vi.stubEnv('DASHBOARD_COOKIE_SECURE', 'true') diff --git a/tests/unit/runtime-config.test.ts b/tests/unit/runtime-config.test.ts index e0a9c4ed1..a90397471 100644 --- a/tests/unit/runtime-config.test.ts +++ b/tests/unit/runtime-config.test.ts @@ -182,7 +182,7 @@ describe('resolveBrowserRuntimeConfig', () => { // A proxy header is attacker-controllable in a misconfigured deployment, and // whatever lands here is served to the browser and handed to the SDK. - it('ignores a malformed x-forwarded-host', () => { + it('ignores a malformed x-forwarded-host and uses the host header', () => { process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' const config = resolveBrowserRuntimeConfig( @@ -190,6 +190,17 @@ describe('resolveBrowserRuntimeConfig', () => { requestUrl ) + expect(config.sandboxUrl).toBe('http://other.example:3002') + }) + + it('falls back to the request url when every host candidate is malformed', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + const config = resolveBrowserRuntimeConfig( + headers({ host: 'also bad', 'x-forwarded-host': 'foo bar' }), + requestUrl + ) + expect(config.sandboxUrl).toBe('http://dash.example:3002') }) From d022696e66f18dcde7d19478383f37aa00e792d2 Mon Sep 17 00:00:00 2001 From: Independence Check Date: Fri, 11 Sep 2026 18:41:32 -0700 Subject: [PATCH 7/7] fix(config): resolve the server-side sandbox URL like the browser does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sandbox router read the sandbox URL from the environment alone, so an install that leaves E2B_SANDBOX_URL unset — the runtime-configured case, where each browser is told the host it reached the dashboard on — sent every server-side envd call to the build-time domain instead. Killing a terminal's pty on leaving the page failed every time. resolveServerSandboxUrl applies the browser's rule to the request the procedure is serving, and the browser config is now expressed through it so the two cannot drift. --- README.md | 12 ++- src/core/server/api/routers/sandbox.ts | 10 +-- src/core/server/runtime-config.ts | 103 +++++++++++++++------ tests/unit/runtime-config.test.ts | 101 +++++++++++++++++++++ tests/unit/sandbox-router-api-url.test.ts | 104 ++++++++++++++++++++-- 5 files changed, 289 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 86573b446..f98cb1ee9 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,11 @@ dashboard on a domain name and you must set `E2B_SANDBOX_URL` yourself, to a `localhost`, IP, or `sandbox.` base URL. `curl http://:/api/config` shows what a deployment resolved. +The dashboard's own server-side sandbox calls, such as killing a terminal's +pty when you leave the page, resolve the URL from the same request by the same +rule, so a self-hosted install needs no `E2B_SANDBOX_URL` unless the request +host is the wrong one for sandbox traffic. + `/api/config` is unauthenticated and carries no secret. Behind a reverse proxy, that proxy must set `X-Forwarded-Host` and `X-Forwarded-Proto` itself rather than pass through whatever a client sent; `GET /api/config` trusts @@ -134,10 +139,9 @@ docker run --rm -p 3001:3001 e2b-dashboard domain that resolves nowhere, so an unconfigured container fails loudly instead of talking to a deployment that is not yours. - An image built this way resolves both APIs from `NEXT_PUBLIC_E2B_DOMAIN` at - build time; pass `NEXT_PUBLIC_INFRA_API_URL`, `NEXT_PUBLIC_E2B_SANDBOX_URL` - or `NEXT_PUBLIC_DASHBOARD_API_URL` as extra `--build-arg`s only if you also - add matching `ARG` lines, until runtime configuration of those URLs lands in - a separate change. + build time. A container configured through the runtime variables in + [Configuration](#configuration) resolves them at runtime instead, so it + needs no build-time value beyond the default. - The build needs outbound HTTPS for the three Google Fonts families in `src/app/fonts.ts`; an air-gapped build fails there. - `GET /api/health` reports dashboard-api's health and answers 503 while diff --git a/src/core/server/api/routers/sandbox.ts b/src/core/server/api/routers/sandbox.ts index 83e0867ee..ec28789a9 100644 --- a/src/core/server/api/routers/sandbox.ts +++ b/src/core/server/api/routers/sandbox.ts @@ -15,7 +15,7 @@ import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' import { withAuthedRequestRepository } from '@/core/server/api/middlewares/repository' import { resolveInfraApiUrl, - resolveSandboxUrl, + resolveServerSandboxUrl, } from '@/core/server/runtime-config' import { createTRPCRouter } from '@/core/server/trpc/init' import { protectedProcedure } from '@/core/server/trpc/procedures' @@ -235,7 +235,7 @@ export const sandboxRouter = createTRPCRouter({ const connectionOpts = { apiUrl: resolveInfraApiUrl(), domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: resolveSandboxUrl(), + sandboxUrl: resolveServerSandboxUrl(ctx.headers, ctx.requestUrl), apiKey, } @@ -323,7 +323,7 @@ export const sandboxRouter = createTRPCRouter({ const connectionOpts = { apiUrl: resolveInfraApiUrl(), domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: resolveSandboxUrl(), + sandboxUrl: resolveServerSandboxUrl(ctx.headers, ctx.requestUrl), apiKey, } @@ -379,7 +379,7 @@ export const sandboxRouter = createTRPCRouter({ const connectionOpts = { apiUrl: resolveInfraApiUrl(), domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: resolveSandboxUrl(), + sandboxUrl: resolveServerSandboxUrl(ctx.headers, ctx.requestUrl), apiKey, } @@ -418,7 +418,7 @@ export const sandboxRouter = createTRPCRouter({ const sandbox = await Sandbox.connect(input.sandboxId, { apiUrl: resolveInfraApiUrl(), domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: resolveSandboxUrl(), + sandboxUrl: resolveServerSandboxUrl(ctx.headers, ctx.requestUrl), timeoutMs: TERMINAL_SANDBOX_TIMEOUT_MS, apiKey: ctx.apiKey, }) diff --git a/src/core/server/runtime-config.ts b/src/core/server/runtime-config.ts index e72abb7b7..86bc48e04 100644 --- a/src/core/server/runtime-config.ts +++ b/src/core/server/runtime-config.ts @@ -44,6 +44,18 @@ function firstSet( return undefined } +function parsedUrl(value: string | undefined): URL | undefined { + if (!value) { + return undefined + } + + try { + return new URL(value) + } catch { + return undefined + } +} + function isHttpUrl(value: string): boolean { try { const { protocol } = new URL(value) @@ -144,62 +156,99 @@ function hostnameOf( } /** - * The host the browser reached this server on, with `port` substituted. Built - * from the proxy headers first so a reverse-proxied install advertises the - * public host rather than its own internal one, falling back to the request - * URL, which is the one input guaranteed to parse. + * The host the client reached this server on, with `port` substituted, or + * undefined when the request carries no usable host at all. Built from the + * proxy headers first so a reverse-proxied install advertises the public host + * rather than its own internal one, falling back to the request URL. + * + * The request URL is optional because a tRPC procedure called from a server + * component is given the request headers but no URL. http is the guess for a + * request that carries neither a forwarded protocol nor a URL, which is the + * plain self-hosted case; a proxied install sets X-Forwarded-Proto. */ function requestOrigin( headers: Headers, - requestUrl: string, + requestUrl: string | undefined, port: string -): string { - const url = new URL(requestUrl) - const protocol = forwardedProtocol(headers) ?? url.protocol.replace(/:$/, '') +): string | undefined { + const url = parsedUrl(requestUrl) + const protocol = + forwardedProtocol(headers) ?? url?.protocol.replace(/:$/, '') ?? 'http' // Each candidate is parsed in turn, so a malformed proxy header falls // through to the next one instead of discarding a good host below it. const hostname = hostnameOf(protocol, trimmed(headers.get('x-forwarded-host'))) ?? hostnameOf(protocol, trimmed(headers.get('host'))) ?? - url.hostname + url?.hostname + + return hostname ? `${protocol}://${hostname}:${port}` : undefined +} + +/** + * The base URL for sandbox traffic that a server-side SDK call should use, + * resolved per request: the configured value, then the request host on the + * sandbox port for a runtime-configured install, then undefined so the SDK + * derives the host from the domain. + * + * The request-host default applies only when E2B_INFRA_API_URL is set. Hosted + * deployments set none of the E2B_* variables and must keep passing no sandbox + * URL at all; a self-hosted install configured at runtime is the only + * deployment that wants "the host this request arrived on, on the sandbox + * port". + * + * The browser is told the same thing by `GET /api/config`, and the two have to + * agree. A self-hosted install leaves E2B_SANDBOX_URL unset precisely so every + * browser gets the host it reached the dashboard on, remote ones included — + * resolving the server side from the environment alone left it on the + * build-time domain, and calls such as killing a terminal's pty went to a host + * that does not exist. + */ +export function resolveServerSandboxUrl( + headers: Headers, + requestUrl: string | undefined +): string | undefined { + const configured = resolveSandboxUrl() + + if (configured) { + return configured + } - return `${protocol}://${hostname}:${port}` + if (!trimmed(process.env.E2B_INFRA_API_URL)) { + return undefined + } + + return requestOrigin(headers, requestUrl, SANDBOX_DEFAULT_PORT) } /** - * The URLs a browser needs, resolved per request. + * The URLs a browser needs, resolved per request. The sandbox URL is whatever + * the server itself would use, so the browser and the server-side SDK calls + * never talk to different sandbox hosts. * - * The request-host default for the sandbox URL applies only when - * E2B_INFRA_API_URL is set. Hosted deployments set none of the E2B_* variables - * and must keep passing no sandbox URL at all, so the SDK derives the sandbox - * host from the domain exactly as it does today; a self-hosted install - * configured at runtime is the only deployment that wants "the host you are - * reading this page from, on the sandbox port". + * A null infra URL means the request carried no host to fall back to, which + * leaves the browser on its build-time value rather than on a guess. */ export function resolveBrowserRuntimeConfig( headers: Headers, requestUrl: string ): BrowserRuntimeConfig { const domain = trimmed(process.env.NEXT_PUBLIC_E2B_DOMAIN) - const isRuntimeConfigured = Boolean(trimmed(process.env.E2B_INFRA_API_URL)) const configured = configuredInfraApiUrl() - let infraApiUrl: string + let infraApiUrl: string | null if (configured) { infraApiUrl = assertHttpUrl(configured) } else if (domain) { infraApiUrl = `https://api.${domain}` } else { - infraApiUrl = requestOrigin(headers, requestUrl, INFRA_API_DEFAULT_PORT) + infraApiUrl = + requestOrigin(headers, requestUrl, INFRA_API_DEFAULT_PORT) ?? null } - const sandboxUrl = - resolveSandboxUrl() ?? - (isRuntimeConfigured - ? requestOrigin(headers, requestUrl, SANDBOX_DEFAULT_PORT) - : null) - - return { infraApiUrl, sandboxUrl } + return { + infraApiUrl, + sandboxUrl: resolveServerSandboxUrl(headers, requestUrl) ?? null, + } } diff --git a/tests/unit/runtime-config.test.ts b/tests/unit/runtime-config.test.ts index a90397471..040990bcc 100644 --- a/tests/unit/runtime-config.test.ts +++ b/tests/unit/runtime-config.test.ts @@ -4,6 +4,7 @@ import { resolveDashboardApiUrl, resolveInfraApiUrl, resolveSandboxUrl, + resolveServerSandboxUrl, } from '@/core/server/runtime-config' const MANAGED_KEYS = [ @@ -254,6 +255,106 @@ describe('resolveBrowserRuntimeConfig', () => { }) }) +/** + * The server-side SDK calls resolve the sandbox URL exactly as the browser + * does. A runtime-configured install leaves E2B_SANDBOX_URL unset so every + * browser is told the host it reached the dashboard on; a server that read + * only the environment would fall back to the build-time domain, and its envd + * calls would go nowhere. + */ +describe('resolveServerSandboxUrl', () => { + const requestUrl = 'http://dash.example:3001/api/trpc/sandbox.killTerminalPty' + const headers = (init: Record = {}) => + new Headers({ host: 'dash.example:3001', ...init }) + + it('reports no sandbox url for a deployment that sets no runtime variables', () => { + expect(resolveServerSandboxUrl(headers(), requestUrl)).toBeUndefined() + }) + + it('uses the explicit runtime value', () => { + process.env.E2B_SANDBOX_URL = 'https://sandbox.internal.example' + + expect(resolveServerSandboxUrl(headers(), requestUrl)).toBe( + 'https://sandbox.internal.example' + ) + }) + + it('falls back to the NEXT_PUBLIC sandbox url', () => { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://sandbox.lvh.me:3002' + + expect(resolveServerSandboxUrl(headers(), requestUrl)).toBe( + 'http://sandbox.lvh.me:3002' + ) + }) + + it('defaults to the request host on 3002 for a runtime-configured install', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + expect(resolveServerSandboxUrl(headers(), requestUrl)).toBe( + 'http://dash.example:3002' + ) + }) + + it('prefers the explicit value over the request-host default', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + process.env.E2B_SANDBOX_URL = 'https://sandbox.internal.example' + + expect(resolveServerSandboxUrl(headers(), requestUrl)).toBe( + 'https://sandbox.internal.example' + ) + }) + + it('honours x-forwarded-host and x-forwarded-proto', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + expect( + resolveServerSandboxUrl( + headers({ + 'x-forwarded-host': 'public.example:8443', + 'x-forwarded-proto': 'https,http', + }), + requestUrl + ) + ).toBe('https://public.example:3002') + }) + + // A procedure called from a server component has the request headers but no + // request URL, so the host header has to carry the default on its own. + it('resolves the host from the headers when there is no request url', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + expect(resolveServerSandboxUrl(headers(), undefined)).toBe( + 'http://dash.example:3002' + ) + }) + + it('honours x-forwarded-proto when there is no request url', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + expect( + resolveServerSandboxUrl( + headers({ 'x-forwarded-proto': 'https' }), + undefined + ) + ).toBe('https://dash.example:3002') + }) + + it('reports no sandbox url when the request carries no host at all', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + expect(resolveServerSandboxUrl(new Headers(), undefined)).toBeUndefined() + }) + + // The whole point of the helper: the two resolutions cannot drift. + it('resolves to what the browser is told', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + expect(resolveServerSandboxUrl(headers(), requestUrl)).toBe( + resolveBrowserRuntimeConfig(headers(), requestUrl).sandboxUrl + ) + }) +}) + // The schema in src/lib/env.ts runs in dev, prebuild and tests but never in a // running container, so a malformed URL has to fail here instead. describe('URL validation', () => { diff --git a/tests/unit/sandbox-router-api-url.test.ts b/tests/unit/sandbox-router-api-url.test.ts index 1ff395e0e..5ed3ee4cc 100644 --- a/tests/unit/sandbox-router-api-url.test.ts +++ b/tests/unit/sandbox-router-api-url.test.ts @@ -36,11 +36,27 @@ const { sandboxRouter } = await import('@/core/server/api/routers/sandbox') const createCaller = createCallerFactory(sandboxRouter) -async function caller() { - const ctx = await createTRPCContext({ headers: new Headers() }) +const REQUEST_HOST = 'dash.example:3001' +const REQUEST_URL = `http://${REQUEST_HOST}/api/trpc/sandbox.killTerminalPty` +const REQUEST_HOST_SANDBOX_URL = 'http://dash.example:3002' + +async function caller(opts: { headers?: Headers; requestUrl?: string } = {}) { + const ctx = await createTRPCContext({ + headers: opts.headers ?? new Headers(), + requestUrl: opts.requestUrl, + }) return createCaller(ctx) } +// A caller for a mutation that arrived over HTTP from a browser on +// REQUEST_HOST, which is how every one of these procedures is reached. +function requestCaller() { + return caller({ + headers: new Headers({ host: REQUEST_HOST }), + requestUrl: REQUEST_URL, + }) +} + const RUNTIME_API_URL = 'http://127.0.0.1:3000' const MANAGED_KEYS = [ 'E2B_INFRA_API_URL', @@ -51,6 +67,9 @@ const MANAGED_KEYS = [ const saved = new Map() const withRuntimeApiUrl = expect.objectContaining({ apiUrl: RUNTIME_API_URL }) +const withRequestHostSandboxUrl = expect.objectContaining({ + sandboxUrl: REQUEST_HOST_SANDBOX_URL, +}) beforeEach(() => { vi.clearAllMocks() @@ -196,9 +215,84 @@ describe('sandbox router sandbox URL', () => { ) }) - // The request-host default is a browser convenience: the server cannot - // assume it can reach its own public host on the sandbox port. - it('passes no sandbox URL when none is configured', async () => { + // With no sandbox URL configured, a runtime-configured install falls back to + // the host the request arrived on, which is what the browser is told too. + // Reading only the environment here left the SDK on the build-time domain, + // and every server-side envd call — killing a terminal's pty on leaving the + // page, above all — went to a host that does not exist. + it('defaults killTerminalPty to the request host on the sandbox port', async () => { + const c = await requestCaller() + await c.killTerminalPty({ sandboxId: 'sbxexisting', pid: 42 }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + withRequestHostSandboxUrl + ) + }) + + it('defaults resume to the request host on the sandbox port', async () => { + const c = await requestCaller() + await c.resume({ sandboxId: 'sbxexisting' }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + withRequestHostSandboxUrl + ) + expect(sdkMock.getFullInfo).toHaveBeenCalledWith( + 'sbxexisting', + withRequestHostSandboxUrl + ) + }) + + it('defaults openTerminal to the request host on the sandbox port', async () => { + const c = await requestCaller() + await c.openTerminal({ template: 'base' }) + + expect(sdkMock.create).toHaveBeenCalledWith( + 'base', + withRequestHostSandboxUrl + ) + }) + + it('defaults pause to the request host on the sandbox port', async () => { + const c = await requestCaller() + await c.pause({ sandboxId: 'sbxexisting' }) + + expect(sdkMock.pause).toHaveBeenCalledWith( + 'sbxexisting', + withRequestHostSandboxUrl + ) + }) + + it('prefers the explicit sandbox URL over the request host', async () => { + process.env.E2B_SANDBOX_URL = 'https://sandbox.internal.example' + + const c = await requestCaller() + await c.killTerminalPty({ sandboxId: 'sbxexisting', pid: 42 }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + expect.objectContaining({ + sandboxUrl: 'https://sandbox.internal.example', + }) + ) + }) + + // Hosted deployments set none of the E2B_* variables and must keep passing + // no sandbox URL at all, so the SDK derives the host from the domain. + it('passes no sandbox URL when no runtime variable is set', async () => { + delete process.env.E2B_INFRA_API_URL + + const c = await requestCaller() + await c.killTerminalPty({ sandboxId: 'sbxexisting', pid: 42 }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + expect.objectContaining({ sandboxUrl: undefined }) + ) + }) + + it('passes no sandbox URL when the call carries no request host', async () => { const c = await caller() await c.resume({ sandboxId: 'sbxexisting' })