From 46976f5a4025bbf85aacee325ac9f6c7f92de5d2 Mon Sep 17 00:00:00 2001 From: Shan Valleru Date: Fri, 11 Sep 2026 14:25:35 -0700 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 1011bb3881084a51e3daeee3942d0ee7f322ddb7 Mon Sep 17 00:00:00 2001 From: Shan Valleru Date: Tue, 15 Sep 2026 09:37:06 -0700 Subject: [PATCH 07/10] 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' }) From dd4de20c9dc118f17e917105991e42c0db386eac Mon Sep 17 00:00:00 2001 From: Aliaksandr Drankou Date: Tue, 15 Sep 2026 12:49:05 +0200 Subject: [PATCH 08/10] feat(config): pass public runtime settings through ClientConfigProvider --- .env.example | 14 +- Dockerfile | 8 +- README.md | 105 ++++++++------- src/app/(dashboard)/layout.tsx | 37 +++--- src/app/api/config/route.ts | 18 --- src/core/server/api/routers/sandbox.ts | 9 +- src/core/server/runtime-config.ts | 62 +++------ src/core/shared/runtime-config.ts | 77 +---------- src/features/client-config-provider.tsx | 30 +++++ .../dashboard/sandbox/inspect/context.tsx | 9 +- .../dashboard/terminal/dashboard-terminal.tsx | 4 + .../dashboard/terminal/sandbox-session.ts | 15 ++- src/lib/env.ts | 17 ++- tests/integration/config-route.test.ts | 55 -------- .../runtime-config-layout.test.tsx | 92 +++++++++++++ tests/unit/dashboard-terminal.test.ts | 37 ++++-- tests/unit/env.test.ts | 32 +++++ tests/unit/runtime-config-client.test.ts | 123 ------------------ tests/unit/runtime-config.test.ts | 90 +++++++++++-- tests/unit/sandbox-router-api-url.test.ts | 32 ++++- 20 files changed, 442 insertions(+), 424 deletions(-) delete mode 100644 src/app/api/config/route.ts create mode 100644 src/features/client-config-provider.tsx delete mode 100644 tests/integration/config-route.test.ts create mode 100644 tests/integration/runtime-config-layout.test.tsx create mode 100644 tests/unit/env.test.ts delete mode 100644 tests/unit/runtime-config-client.test.ts diff --git a/.env.example b/.env.example index fe8ee6df0..446bff5b9 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,8 @@ ### Domain for the E2B cluster. ### Resolves infra-api (`https://api.`) and dashboard-api ### (`https://dashboard-api.`) unless overridden below. -NEXT_PUBLIC_E2B_DOMAIN=e2b.dev +PUBLIC_E2B_DOMAIN=e2b.dev +### Legacy fallback: NEXT_PUBLIC_E2B_DOMAIN (frozen at build time). ### ================================= ### OPTIONAL ENVIRONMENT VARIABLES @@ -16,7 +17,7 @@ NEXT_PUBLIC_E2B_DOMAIN=e2b.dev ### and sign-out is hidden. # E2B_API_KEY=e2b_your_team_api_key -### Explicit API base URLs (override the NEXT_PUBLIC_E2B_DOMAIN resolution; +### Explicit API base URLs (override the cluster domain resolution; ### useful for local infra development). # NEXT_PUBLIC_INFRA_API_URL=http://localhost:3000 # NEXT_PUBLIC_DASHBOARD_API_URL=http://localhost:3001 @@ -28,15 +29,14 @@ NEXT_PUBLIC_E2B_DOMAIN=e2b.dev # 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 - ### Base URL the BROWSER uses to reach sandboxes (terminal and filesystem -### inspector). Unset on a runtime-configured install means "the host this +### inspector). If E2B_INFRA_API_URL is set, leaving this unset means "the host this ### 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 +# PUBLIC_SANDBOX_URL=http://127.0.0.1:3002 +### Legacy fallbacks, in order: E2B_SANDBOX_URL, NEXT_PUBLIC_E2B_SANDBOX_URL. +### With no sandbox URL or E2B_INFRA_API_URL, the SDK uses the cluster domain. ### 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. diff --git a/Dockerfile b/Dockerfile index 1726f0779..b462faec7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,11 +25,9 @@ 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. +# Retain the legacy build argument as a fallback. PUBLIC_E2B_DOMAIN overrides +# it at runtime. The default resolves nowhere so an unconfigured container +# cannot accidentally reach someone else's deployment. ARG NEXT_PUBLIC_E2B_DOMAIN=unset.invalid ENV NEXT_PUBLIC_E2B_DOMAIN=${NEXT_PUBLIC_E2B_DOMAIN} ENV NEXT_TELEMETRY_DISABLED=1 diff --git a/README.md b/README.md index f98cb1ee9..bc3d612ac 100644 --- a/README.md +++ b/README.md @@ -32,46 +32,50 @@ Authentication is a single **team API key**: | 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 | -| `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` 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 -`NEXT_PUBLIC_*` into the bundles at build time, so a prebuilt image is -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 +| `PUBLIC_E2B_DOMAIN` | runtime | E2B cluster domain; used by the SDK and to derive `https://api.` and `https://dashboard-api.` | +| `PUBLIC_SANDBOX_URL` | per request | Optional sandbox traffic base URL, reachable from both the browser and server | +| `E2B_INFRA_API_URL` / `E2B_DASHBOARD_API_URL` | server start | Explicit server-side API URLs; override domain-derived URLs | +| `E2B_SANDBOX_URL` | per request | Legacy alias for `PUBLIC_SANDBOX_URL` | +| `NEXT_PUBLIC_E2B_DOMAIN` | build | Legacy fallback for `PUBLIC_E2B_DOMAIN` | +| `NEXT_PUBLIC_INFRA_API_URL` / `NEXT_PUBLIC_DASHBOARD_API_URL` | build | Legacy API overrides, below the corresponding `E2B_*` variables | +| `NEXT_PUBLIC_E2B_SANDBOX_URL` | build | Legacy sandbox URL, below both runtime names | +| `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 | + +Configure a prebuilt image with `PUBLIC_*` and `E2B_*` variables when starting +the container. Next does not give `PUBLIC_` any special behavior: the server +explicitly reads these values at runtime. `NEXT_PUBLIC_*` aliases remain +supported for existing builds, but their values are frozen by `next build`. +Restart the container and reload open pages after changing its configuration. + +Resolution order (blank values are skipped): + +- Domain: `PUBLIC_E2B_DOMAIN` → `NEXT_PUBLIC_E2B_DOMAIN`. +- Sandbox URL: `PUBLIC_SANDBOX_URL` → `E2B_SANDBOX_URL` → `NEXT_PUBLIC_E2B_SANDBOX_URL` → the fallback below. +- API URLs: corresponding `E2B_*` override → `NEXT_PUBLIC_*` override → URL derived from the resolved domain. + +Every explicit URL must include `http://` or `https://`. The server rejects +invalid URLs, naming the variable. API URLs resolve when their server modules +load; sandbox URLs resolve when a dashboard request or SDK call needs them. + +The dashboard's Server Component layout resolves **only the domain and +sandbox URL** and passes them as props to a client `ClientConfigProvider`. +The terminal and filesystem inspector read this provider on their first +render, without a separate config request. API endpoints and team credentials +stay on the server. Both public settings are visible to browser users and +must contain no secrets. + +When `E2B_INFRA_API_URL` is set and no sandbox URL is given, the sandbox URL 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. - -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 -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 -browser, not only from the server. A runtime-configured install should leave -it unset unless the port-3002 default is wrong. +which is how the sandbox proxy accepts header-routed traffic. For a domain +name, set `PUBLIC_SANDBOX_URL` to a `localhost`, IP, or `sandbox.` base +URL that both the browser and server can reach. Without `E2B_INFRA_API_URL`, +leaving the sandbox URL unset preserves the SDK's domain-based routing. + +Server-side sandbox calls, such as terminal PTY cleanup, use the same domain +and sandbox URL resolution. Behind a reverse proxy, set `X-Forwarded-Host` +and `X-Forwarded-Proto` at the proxy instead of forwarding client-supplied +values: the request-host fallback trusts these headers. ## Features @@ -102,8 +106,8 @@ bun install 3. Set up environment variables ```bash cp .env.example .env -# set NEXT_PUBLIC_E2B_DOMAIN (or explicit NEXT_PUBLIC_INFRA_API_URL / -# NEXT_PUBLIC_DASHBOARD_API_URL) to point at your infrastructure +# set PUBLIC_E2B_DOMAIN (and optionally E2B_INFRA_API_URL / +# E2B_DASHBOARD_API_URL) to point at your infrastructure ``` 4. Start the development server @@ -127,21 +131,22 @@ 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 +docker build -t e2b-dashboard . +docker run --rm -p 3001:3001 \ + -e PUBLIC_E2B_DOMAIN=your-domain.com \ + 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. 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. +- `PUBLIC_E2B_DOMAIN` configures the cluster at container start, so the same + image can serve different installations. Use `PUBLIC_SANDBOX_URL` when the + default SDK routing does not fit your deployment. +- The legacy `NEXT_PUBLIC_E2B_DOMAIN` build argument is still supported. Its + default, `unset.invalid`, resolves nowhere so an unconfigured container + cannot accidentally talk to another deployment. Runtime configuration takes + precedence over that build-time fallback. - 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/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index d27445499..5415aa428 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -1,9 +1,11 @@ -import { cookies } from 'next/headers' +import { cookies, headers } from 'next/headers' import { redirect } from 'next/navigation' import type { Metadata } from 'next/types' import { COOKIE_KEYS } from '@/configs/cookies' import { METADATA } from '@/configs/metadata' import { getApiKey } from '@/core/server/auth' +import { resolveBrowserRuntimeConfig } from '@/core/server/runtime-config' +import { ClientConfigProvider } from '@/features/client-config-provider' import DashboardLayoutView from '@/features/dashboard/layouts/layout' import Sidebar from '@/features/dashboard/sidebar/sidebar' import { TimezoneProvider } from '@/features/dashboard/timezone/context' @@ -33,6 +35,7 @@ export default async function DashboardLayout({ redirect('/') } + const runtimeConfig = resolveBrowserRuntimeConfig(await headers()) const sidebarState = cookieStore.get(COOKIE_KEYS.SIDEBAR_STATE)?.value const defaultOpen = sidebarState === 'true' const timezone = parseTimezone( @@ -40,21 +43,23 @@ export default async function DashboardLayout({ ) return ( - - -
-
- - - - {children} - - + + + +
+
+ + + + {children} + + +
-
- - + + + ) } diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts deleted file mode 100644 index a6f46ebc5..000000000 --- a/src/app/api/config/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 ec28789a9..931cf70ab 100644 --- a/src/core/server/api/routers/sandbox.ts +++ b/src/core/server/api/routers/sandbox.ts @@ -14,6 +14,7 @@ import { createSandboxesRepository } from '@/core/modules/sandboxes/repository.s import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' import { withAuthedRequestRepository } from '@/core/server/api/middlewares/repository' import { + resolveE2BDomain, resolveInfraApiUrl, resolveServerSandboxUrl, } from '@/core/server/runtime-config' @@ -234,7 +235,7 @@ export const sandboxRouter = createTRPCRouter({ const connectionOpts = { apiUrl: resolveInfraApiUrl(), - domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, + domain: resolveE2BDomain(), sandboxUrl: resolveServerSandboxUrl(ctx.headers, ctx.requestUrl), apiKey, } @@ -322,7 +323,7 @@ export const sandboxRouter = createTRPCRouter({ const connectionOpts = { apiUrl: resolveInfraApiUrl(), - domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, + domain: resolveE2BDomain(), sandboxUrl: resolveServerSandboxUrl(ctx.headers, ctx.requestUrl), apiKey, } @@ -378,7 +379,7 @@ export const sandboxRouter = createTRPCRouter({ const connectionOpts = { apiUrl: resolveInfraApiUrl(), - domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, + domain: resolveE2BDomain(), sandboxUrl: resolveServerSandboxUrl(ctx.headers, ctx.requestUrl), apiKey, } @@ -417,7 +418,7 @@ export const sandboxRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { const sandbox = await Sandbox.connect(input.sandboxId, { apiUrl: resolveInfraApiUrl(), - domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, + domain: resolveE2BDomain(), 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 86bc48e04..c92556252 100644 --- a/src/core/server/runtime-config.ts +++ b/src/core/server/runtime-config.ts @@ -2,9 +2,8 @@ * 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 + * inlines into the bundles at build time. PUBLIC_* and E2B_* variables have no + * special meaning to Next, so Node reads them at runtime 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. * @@ -18,7 +17,6 @@ import 'server-only' import type { BrowserRuntimeConfig } from '@/core/shared/runtime-config' -const INFRA_API_DEFAULT_PORT = '3000' const SANDBOX_DEFAULT_PORT = '3002' interface ResolvedValue { @@ -85,12 +83,19 @@ function configuredInfraApiUrl(): ResolvedValue | undefined { ) } +export function resolveE2BDomain(): string | undefined { + return firstSet( + ['PUBLIC_E2B_DOMAIN', process.env.PUBLIC_E2B_DOMAIN], + ['NEXT_PUBLIC_E2B_DOMAIN', process.env.NEXT_PUBLIC_E2B_DOMAIN] + )?.value +} + export function resolveInfraApiUrl(): string { const configured = configuredInfraApiUrl() return configured ? assertHttpUrl(configured) - : `https://api.${process.env.NEXT_PUBLIC_E2B_DOMAIN}` + : `https://api.${resolveE2BDomain()}` } export function resolveDashboardApiUrl(): string { @@ -101,16 +106,17 @@ export function resolveDashboardApiUrl(): string { return configured ? assertHttpUrl(configured) - : `https://dashboard-api.${process.env.NEXT_PUBLIC_E2B_DOMAIN}` + : `https://dashboard-api.${resolveE2BDomain()}` } /** * 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. + * from the domain. E2B_SANDBOX_URL stays supported as an alias, including for + * installs that share the setting with other E2B SDK consumers. */ export function resolveSandboxUrl(): string | undefined { const configured = firstSet( + ['PUBLIC_SANDBOX_URL', process.env.PUBLIC_SANDBOX_URL], ['E2B_SANDBOX_URL', process.env.E2B_SANDBOX_URL], ['NEXT_PUBLIC_E2B_SANDBOX_URL', process.env.NEXT_PUBLIC_E2B_SANDBOX_URL] ) @@ -192,17 +198,9 @@ function requestOrigin( * 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. + * deployments configured with a domain keep the SDK's domain-based URLs. + * The dashboard layout passes this same URL to browser SDK consumers so + * terminal connections and server-side PTY cleanup reach the same host. */ export function resolveServerSandboxUrl( headers: Headers, @@ -222,33 +220,15 @@ export function resolveServerSandboxUrl( } /** - * 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. - * - * 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. + * Explicitly allowlist browser-visible settings; API endpoints and team + * credentials stay on the server. */ export function resolveBrowserRuntimeConfig( headers: Headers, - requestUrl: string + requestUrl?: string ): BrowserRuntimeConfig { - const domain = trimmed(process.env.NEXT_PUBLIC_E2B_DOMAIN) - const configured = configuredInfraApiUrl() - - 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) ?? null - } - return { - infraApiUrl, + domain: resolveE2BDomain() ?? null, sandboxUrl: resolveServerSandboxUrl(headers, requestUrl) ?? null, } } diff --git a/src/core/shared/runtime-config.ts b/src/core/shared/runtime-config.ts index ee26a0773..419abdfc3 100644 --- a/src/core/shared/runtime-config.ts +++ b/src/core/shared/runtime-config.ts @@ -1,79 +1,4 @@ -/** - * 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 + domain: 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/client-config-provider.tsx b/src/features/client-config-provider.tsx new file mode 100644 index 000000000..e7b788714 --- /dev/null +++ b/src/features/client-config-provider.tsx @@ -0,0 +1,30 @@ +'use client' + +import { createContext, type ReactNode, useContext } from 'react' +import type { BrowserRuntimeConfig } from '@/core/shared/runtime-config' + +const ClientConfigContext = createContext(null) + +export function ClientConfigProvider({ + children, + value, +}: { + children: ReactNode + value: BrowserRuntimeConfig +}) { + return ( + + {children} + + ) +} + +export function useClientConfig(): BrowserRuntimeConfig { + const config = useContext(ClientConfigContext) + + if (!config) { + throw new Error('useClientConfig must be used within ClientConfigProvider') + } + + return config +} diff --git a/src/features/dashboard/sandbox/inspect/context.tsx b/src/features/dashboard/sandbox/inspect/context.tsx index b09c732c5..d1abe523d 100644 --- a/src/features/dashboard/sandbox/inspect/context.tsx +++ b/src/features/dashboard/sandbox/inspect/context.tsx @@ -11,7 +11,7 @@ import { useState, } from 'react' import { createEnvdSandbox } from '@/core/shared/create-envd-sandbox' -import { fetchRuntimeConfig } from '@/core/shared/runtime-config' +import { useClientConfig } from '@/features/client-config-provider' import { useSandboxInspectAnalytics } from '@/lib/hooks/use-analytics' import { getParentPath, normalizePath } from '@/lib/utils/filesystem' import { useTRPCClient } from '@/trpc/client' @@ -64,6 +64,7 @@ export default function SandboxInspectProvider({ rootPath, }: SandboxInspectProviderProps) { const trpcClient = useTRPCClient() + const runtimeConfig = useClientConfig() const { sandboxInfo, isRunning, refetchSandboxInfo } = useSandboxContext() const sandboxId = sandboxInfo?.sandboxID @@ -180,12 +181,10 @@ export default function SandboxInspectProvider({ sandboxManagerRef.current.stopWatching() } - const { sandboxUrl } = await fetchRuntimeConfig() - const sandbox = createEnvdSandbox({ ...creds, - domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: sandboxUrl ?? undefined, + domain: runtimeConfig.domain ?? undefined, + sandboxUrl: runtimeConfig.sandboxUrl ?? undefined, }) const manager = new SandboxManager(store, sandbox, rootPath) sandboxManagerRef.current = manager diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index 7c36e240c..de112336b 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -2,6 +2,7 @@ import type { CommandHandle, Sandbox } from 'e2b' import { useCallback, useEffect, useEffectEvent, useRef, useState } from 'react' +import { useClientConfig } from '@/features/client-config-provider' import { useTRPCClient } from '@/trpc/client' import { DEFAULT_CWD, @@ -48,6 +49,7 @@ export default function DashboardTerminal({ sandboxScoped = false, }: DashboardTerminalProps) { const trpcClient = useTRPCClient() + const runtimeConfig = useClientConfig() const [status, setStatus] = useState('idle') const [activeSandboxId, setActiveSandboxId] = useState() @@ -304,6 +306,7 @@ export default function DashboardTerminal({ sandbox = await getSandbox() } else { const terminalSandbox = await openTerminalSandbox({ + runtimeConfig, forceNewSandbox: shouldForceNewSandbox, onStatus: appendOutput, openTerminal: (mutationInput) => @@ -414,6 +417,7 @@ export default function DashboardTerminal({ getSandbox, runCommand, trpcClient, + runtimeConfig, sandboxScoped, sandboxConnectRequestTimeoutMs, template, diff --git a/src/features/dashboard/terminal/sandbox-session.ts b/src/features/dashboard/terminal/sandbox-session.ts index a65b22c17..2b1ffc827 100644 --- a/src/features/dashboard/terminal/sandbox-session.ts +++ b/src/features/dashboard/terminal/sandbox-session.ts @@ -1,6 +1,6 @@ import type { Sandbox } from 'e2b' import { createEnvdSandbox } from '@/core/shared/create-envd-sandbox' -import { fetchRuntimeConfig } from '@/core/shared/runtime-config' +import type { BrowserRuntimeConfig } from '@/core/shared/runtime-config' import type { TRPCRouterOutputs } from '@/trpc/client' import { clearStoredTerminalSession, @@ -29,6 +29,7 @@ interface OpenTerminalSandboxOptions { forceNewSandbox?: boolean onStatus: (message: string) => void openTerminal: OpenTerminalMutation + runtimeConfig: BrowserRuntimeConfig requestTimeoutMs?: number shouldStoreSession?: boolean sandboxId?: string @@ -39,6 +40,7 @@ export async function openTerminalSandbox({ forceNewSandbox = false, onStatus, openTerminal, + runtimeConfig, requestTimeoutMs, shouldStoreSession, sandboxId, @@ -48,6 +50,7 @@ export async function openTerminalSandbox({ onStatus(`Connecting to terminal sandbox ${sandboxId}...\r\n`) const sandbox = await acquireTerminalSandbox( openTerminal, + runtimeConfig, { template, sandboxId, requestTimeoutMs }, 'Failed to connect to terminal sandbox' ) @@ -71,6 +74,7 @@ export async function openTerminalSandbox({ try { sandbox = await acquireTerminalSandbox( openTerminal, + runtimeConfig, { template, sandboxId: storedTerminalSession.sandboxId, @@ -84,6 +88,7 @@ export async function openTerminalSandbox({ onStatus(`Starting ${template} terminal sandbox...\r\n`) sandbox = await acquireTerminalSandbox( openTerminal, + runtimeConfig, { template }, 'Failed to create terminal sandbox' ) @@ -92,6 +97,7 @@ export async function openTerminalSandbox({ onStatus(`Starting ${template} terminal sandbox...\r\n`) sandbox = await acquireTerminalSandbox( openTerminal, + runtimeConfig, { template }, 'Failed to create terminal sandbox' ) @@ -111,6 +117,7 @@ export async function openTerminalSandbox({ async function acquireTerminalSandbox( openTerminal: OpenTerminalMutation, + runtimeConfig: BrowserRuntimeConfig, input: OpenTerminalMutationInput, fallbackMessage: string ): Promise { @@ -122,11 +129,9 @@ 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: sandboxUrl ?? undefined, + domain: runtimeConfig.domain ?? undefined, + sandboxUrl: runtimeConfig.sandboxUrl ?? undefined, }) } diff --git a/src/lib/env.ts b/src/lib/env.ts index 09a3e2881..b37ca5450 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(), + // Read on the server and explicitly passed to the browser by the layout. + PUBLIC_E2B_DOMAIN: z.string().optional(), + PUBLIC_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(), @@ -43,7 +47,7 @@ export const serverSchema = z.object({ }) export const clientSchema = z.object({ - NEXT_PUBLIC_E2B_DOMAIN: z.string(), + NEXT_PUBLIC_E2B_DOMAIN: z.string().optional(), NEXT_PUBLIC_VERCEL_ENV: z .enum(['production', 'preview', 'development']) @@ -56,7 +60,16 @@ export const clientSchema = z.object({ const merged = serverSchema.merge(clientSchema) -export const appEnvSchema = merged +export const appEnvSchema = merged.refine( + (env) => + Boolean( + env.PUBLIC_E2B_DOMAIN?.trim() || env.NEXT_PUBLIC_E2B_DOMAIN?.trim() + ), + { + message: + 'Set PUBLIC_E2B_DOMAIN (or NEXT_PUBLIC_E2B_DOMAIN for legacy builds)', + } +) export type Env = z.infer diff --git a/tests/integration/config-route.test.ts b/tests/integration/config-route.test.ts deleted file mode 100644 index d2a3ce6e5..000000000 --- a/tests/integration/config-route.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -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/integration/runtime-config-layout.test.tsx b/tests/integration/runtime-config-layout.test.tsx new file mode 100644 index 000000000..c5d7f8d18 --- /dev/null +++ b/tests/integration/runtime-config-layout.test.tsx @@ -0,0 +1,92 @@ +import type { ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, describe, expect, it, vi } from 'vitest' +import DashboardLayout from '@/app/(dashboard)/layout' +import { useClientConfig } from '@/features/client-config-provider' + +const request = vi.hoisted(() => ({ headers: new Headers() })) + +vi.mock('next/headers', () => ({ + cookies: async () => ({ get: () => undefined }), + headers: async () => request.headers, +})) +vi.mock('@/core/server/auth', () => ({ getApiKey: async () => 'e2b_test_key' })) +vi.mock('@/features/dashboard/sidebar/sidebar', () => ({ default: () => null })) +vi.mock('@/features/dashboard/layouts/layout', () => ({ + default: ({ children }: { children: ReactNode }) => children, +})) +vi.mock('@/features/dashboard/timezone/context', () => ({ + TimezoneProvider: ({ children }: { children: ReactNode }) => children, +})) +vi.mock('@/ui/primitives/sidebar', () => ({ + SidebarProvider: ({ children }: { children: ReactNode }) => children, + SidebarInset: ({ children }: { children: ReactNode }) => children, +})) +vi.mock('@/ui/error', () => ({ + CatchErrorBoundary: ({ children }: { children: ReactNode }) => children, +})) + +function ConfigConsumer() { + const { domain, sandboxUrl } = useClientConfig() + return ( + + {domain}|{sandboxUrl} + + ) +} + +afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() +}) + +describe('dashboard layout runtime config', () => { + it('delivers request-time values to a client consumer on its first render without fetching config', async () => { + const fetchSpy = vi.fn() + vi.stubGlobal('fetch', fetchSpy) + vi.stubEnv('NEXT_PUBLIC_E2B_DOMAIN', 'build.example') + vi.stubEnv('E2B_API_KEY', 'e2b_test_private_key') + vi.stubEnv('E2B_INFRA_API_URL', 'http://infra-api.internal:3000') + vi.stubEnv('E2B_DASHBOARD_API_URL', 'http://dashboard-api.internal:3010') + + for (const domain of ['first.example', 'second.example']) { + vi.stubEnv('PUBLIC_E2B_DOMAIN', domain) + vi.stubEnv('PUBLIC_SANDBOX_URL', `https://sandbox.${domain}`) + const layout = await DashboardLayout({ children: }) + + expect(layout.props.value).toEqual({ + domain, + sandboxUrl: `https://sandbox.${domain}`, + }) + const html = renderToStaticMarkup(layout) + expect(html).toContain( + `${domain}|https://sandbox.${domain}` + ) + expect(html).not.toMatch( + /build\.example|e2b_test_private_key|infra-api\.internal|dashboard-api\.internal/ + ) + } + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('keeps host-derived sandbox URLs isolated between requests', async () => { + vi.stubEnv('PUBLIC_E2B_DOMAIN', 'cluster.example') + vi.stubEnv('PUBLIC_SANDBOX_URL', '') + vi.stubEnv('E2B_SANDBOX_URL', '') + vi.stubEnv('NEXT_PUBLIC_E2B_SANDBOX_URL', '') + vi.stubEnv('E2B_INFRA_API_URL', 'http://infra-api.internal:3000') + + for (const host of ['192.0.2.1', '192.0.2.2']) { + request.headers = new Headers({ + host: 'dashboard.internal:3001', + 'x-forwarded-host': `${host}:8443`, + 'x-forwarded-proto': 'https', + }) + const layout = await DashboardLayout({ children: }) + + expect(renderToStaticMarkup(layout)).toContain( + `cluster.example|https://${host}:3002` + ) + } + }) +}) diff --git a/tests/unit/dashboard-terminal.test.ts b/tests/unit/dashboard-terminal.test.ts index 36b6f10b2..68635141e 100644 --- a/tests/unit/dashboard-terminal.test.ts +++ b/tests/unit/dashboard-terminal.test.ts @@ -20,12 +20,10 @@ 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', - })), -})) +const runtimeConfig = { + domain: 'runtime.example', + sandboxUrl: 'http://host.example:3002', +} // The `sandbox.openTerminal` tRPC mutation is injected into // openTerminalSandbox, so the test passes this mock directly instead of @@ -268,10 +266,27 @@ describe('dashboard terminal helpers', () => { }) describe('openTerminalSandbox', () => { + it('leaves SDK sandbox routing unset when the provider has no override', async () => { + await openTerminalSandbox({ + runtimeConfig: { domain: 'runtime.example', sandboxUrl: null }, + onStatus: () => {}, + openTerminal: mockOpenTerminal, + template: 'base', + }) + + expect(mockCreateEnvdSandbox).toHaveBeenCalledWith( + expect.objectContaining({ + domain: 'runtime.example', + sandboxUrl: undefined, + }) + ) + }) + it('connects to an explicit sandbox without writing a stored session', async () => { const statuses: string[] = [] await openTerminalSandbox({ + runtimeConfig, onStatus: (message) => statuses.push(message), openTerminal: mockOpenTerminal, sandboxId: 'sandbox-from-url', @@ -288,7 +303,7 @@ describe('dashboard terminal helpers', () => { sandboxDomain: 'sandbox.example.com', envdVersion: '0.2.0', envdAccessToken: 'envd-token', - domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, + domain: runtimeConfig.domain, sandboxUrl: 'http://host.example:3002', }) expect(readStoredTerminalSession()).toBeNull() @@ -303,6 +318,7 @@ describe('dashboard terminal helpers', () => { try { await openTerminalSandbox({ + runtimeConfig, onStatus: () => {}, openTerminal: mockOpenTerminal, template: 'base', @@ -329,6 +345,7 @@ describe('dashboard terminal helpers', () => { }) await openTerminalSandbox({ + runtimeConfig, onStatus: vi.fn(), openTerminal: mockOpenTerminal, sandboxId: 'insecure-sandbox', @@ -340,13 +357,14 @@ describe('dashboard terminal helpers', () => { sandboxDomain: 'sandbox.example.com', envdVersion: '0.2.0', envdAccessToken: undefined, - domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, + domain: runtimeConfig.domain, sandboxUrl: 'http://host.example:3002', }) }) it('creates and stores a terminal sandbox when no reusable session exists', async () => { await openTerminalSandbox({ + runtimeConfig, onStatus: vi.fn(), openTerminal: mockOpenTerminal, template: 'base', @@ -368,6 +386,7 @@ describe('dashboard terminal helpers', () => { }) await openTerminalSandbox({ + runtimeConfig, onStatus: vi.fn(), openTerminal: mockOpenTerminal, template: 'base', @@ -387,6 +406,7 @@ describe('dashboard terminal helpers', () => { }) await openTerminalSandbox({ + runtimeConfig, forceNewSandbox: true, onStatus: vi.fn(), openTerminal: mockOpenTerminal, @@ -413,6 +433,7 @@ describe('dashboard terminal helpers', () => { }) await openTerminalSandbox({ + runtimeConfig, onStatus: vi.fn(), openTerminal: mockOpenTerminal, template: 'base', diff --git a/tests/unit/env.test.ts b/tests/unit/env.test.ts new file mode 100644 index 000000000..26c99921d --- /dev/null +++ b/tests/unit/env.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { appEnvSchema } from '@/lib/env' + +describe('dashboard environment validation', () => { + it('accepts the public runtime names without requiring a legacy domain', () => { + expect( + appEnvSchema.safeParse({ + PUBLIC_E2B_DOMAIN: 'cluster.example', + PUBLIC_SANDBOX_URL: 'https://sandbox.cluster.example', + }).success + ).toBe(true) + }) + + it('keeps legacy build configuration valid', () => { + expect( + appEnvSchema.safeParse({ + NEXT_PUBLIC_E2B_DOMAIN: 'cluster.example', + E2B_SANDBOX_URL: 'https://sandbox.cluster.example', + }).success + ).toBe(true) + }) + + it('requires a nonempty domain through either name', () => { + expect(appEnvSchema.safeParse({}).success).toBe(false) + expect( + appEnvSchema.safeParse({ + PUBLIC_E2B_DOMAIN: ' ', + NEXT_PUBLIC_E2B_DOMAIN: '', + }).success + ).toBe(false) + }) +}) diff --git a/tests/unit/runtime-config-client.test.ts b/tests/unit/runtime-config-client.test.ts deleted file mode 100644 index 7c267d859..000000000 --- a/tests/unit/runtime-config-client.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -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 040990bcc..154750bba 100644 --- a/tests/unit/runtime-config.test.ts +++ b/tests/unit/runtime-config.test.ts @@ -2,12 +2,16 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { resolveBrowserRuntimeConfig, resolveDashboardApiUrl, + resolveE2BDomain, resolveInfraApiUrl, resolveSandboxUrl, resolveServerSandboxUrl, } from '@/core/server/runtime-config' const MANAGED_KEYS = [ + 'PUBLIC_E2B_DOMAIN', + 'PUBLIC_SANDBOX_URL', + 'E2B_API_KEY', 'E2B_INFRA_API_URL', 'E2B_DASHBOARD_API_URL', 'E2B_SANDBOX_URL', @@ -38,6 +42,30 @@ afterEach(() => { } }) +describe('resolveE2BDomain', () => { + it('falls back to the legacy build-time domain', () => { + expect(resolveE2BDomain()).toBe('example.dev') + }) + + it('reads the public domain at runtime for both API defaults', () => { + for (const domain of ['first.example', 'second.example']) { + process.env.PUBLIC_E2B_DOMAIN = ` ${domain} ` + expect(resolveE2BDomain()).toBe(domain) + expect(resolveInfraApiUrl()).toBe(`https://api.${domain}`) + expect(resolveDashboardApiUrl()).toBe(`https://dashboard-api.${domain}`) + expect(resolveBrowserRuntimeConfig(new Headers())).toEqual({ + domain, + sandboxUrl: null, + }) + } + }) + + it('ignores an empty public domain', () => { + process.env.PUBLIC_E2B_DOMAIN = ' ' + expect(resolveE2BDomain()).toBe('example.dev') + }) +}) + describe('resolveInfraApiUrl', () => { it('derives the URL from the domain when nothing is set', () => { expect(resolveInfraApiUrl()).toBe('https://api.example.dev') @@ -104,6 +132,28 @@ describe('resolveDashboardApiUrl', () => { }) describe('resolveSandboxUrl', () => { + it('prefers the public sandbox URL over both legacy aliases', () => { + process.env.PUBLIC_SANDBOX_URL = ' https://sandbox.runtime.example ' + process.env.E2B_SANDBOX_URL = 'https://sandbox.old.example' + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'https://sandbox.build.example' + + expect(resolveSandboxUrl()).toBe('https://sandbox.runtime.example') + }) + + it('falls back to the runtime alias when the public value is blank', () => { + process.env.PUBLIC_SANDBOX_URL = ' ' + process.env.E2B_SANDBOX_URL = 'https://sandbox.old.example' + + expect(resolveSandboxUrl()).toBe('https://sandbox.old.example') + }) + + it('rejects an invalid public URL instead of falling back silently', () => { + process.env.PUBLIC_SANDBOX_URL = 'sandbox.runtime.example:3002' + process.env.E2B_SANDBOX_URL = 'https://sandbox.old.example' + + expect(() => resolveSandboxUrl()).toThrow(/PUBLIC_SANDBOX_URL/) + }) + it('reports no sandbox url when nothing is set', () => { expect(resolveSandboxUrl()).toBeUndefined() }) @@ -130,13 +180,39 @@ describe('resolveSandboxUrl', () => { }) describe('resolveBrowserRuntimeConfig', () => { - const requestUrl = 'http://dash.example:3001/api/config' + it('exposes only the domain and sandbox URL, with no server endpoints or credentials', () => { + process.env.PUBLIC_E2B_DOMAIN = 'runtime.example' + process.env.PUBLIC_SANDBOX_URL = 'https://sandbox.runtime.example' + process.env.E2B_INFRA_API_URL = 'http://infra-api.internal:3000' + process.env.E2B_DASHBOARD_API_URL = 'http://dashboard-api.internal:3010' + process.env.E2B_API_KEY = 'e2b_test_private_key' + + expect(resolveBrowserRuntimeConfig(new Headers())).toEqual({ + domain: 'runtime.example', + sandboxUrl: 'https://sandbox.runtime.example', + }) + }) + + it('resolves the sandbox host from layout headers without a request URL', () => { + process.env.E2B_INFRA_API_URL = 'http://infra-api.internal:3000' + const requestHeaders = new Headers({ + host: 'dashboard.internal:3001', + 'x-forwarded-host': '192.0.2.1:8443', + 'x-forwarded-proto': 'https', + }) + + expect(resolveBrowserRuntimeConfig(requestHeaders).sandboxUrl).toBe( + 'https://192.0.2.1:3002' + ) + }) + + const requestUrl = 'http://dash.example:3001/sandboxes' 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', + domain: 'example.dev', sandboxUrl: null, }) }) @@ -162,7 +238,7 @@ describe('resolveBrowserRuntimeConfig', () => { process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' expect(resolveBrowserRuntimeConfig(headers(), requestUrl)).toEqual({ - infraApiUrl: 'http://127.0.0.1:3000', + domain: 'example.dev', sandboxUrl: 'http://dash.example:3002', }) }) @@ -238,12 +314,10 @@ describe('resolveBrowserRuntimeConfig', () => { expect(config.sandboxUrl).toBe('https://dash.example:3002') }) - it('falls back to the request host for the infra url with no domain set', () => { + it('passes no domain when none is configured', () => { delete process.env.NEXT_PUBLIC_E2B_DOMAIN - expect(resolveBrowserRuntimeConfig(headers(), requestUrl).infraApiUrl).toBe( - 'http://dash.example:3000' - ) + expect(resolveBrowserRuntimeConfig(headers(), requestUrl).domain).toBeNull() }) it('reads the host from the request url when no host header is present', () => { @@ -405,7 +479,7 @@ describe('URL validation', () => { expect( resolveBrowserRuntimeConfig( new Headers({ host: 'dash.example:3001' }), - 'http://dash.example:3001/api/config' + 'http://dash.example:3001/sandboxes' ).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 5ed3ee4cc..c68cb47fa 100644 --- a/tests/unit/sandbox-router-api-url.test.ts +++ b/tests/unit/sandbox-router-api-url.test.ts @@ -59,6 +59,8 @@ function requestCaller() { const RUNTIME_API_URL = 'http://127.0.0.1:3000' const MANAGED_KEYS = [ + 'PUBLIC_E2B_DOMAIN', + 'PUBLIC_SANDBOX_URL', 'E2B_INFRA_API_URL', 'E2B_SANDBOX_URL', 'NEXT_PUBLIC_INFRA_API_URL', @@ -171,7 +173,7 @@ 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. + * and the browser, which gets its config from the layout, talks to another. */ describe('sandbox router sandbox URL', () => { it('passes the runtime sandbox URL to the control plane', async () => { @@ -302,3 +304,31 @@ describe('sandbox router sandbox URL', () => { ) }) }) + +describe('sandbox router public runtime settings', () => { + it('uses the same public domain and sandbox URL for all SDK operations', async () => { + process.env.PUBLIC_E2B_DOMAIN = 'runtime.example' + process.env.PUBLIC_SANDBOX_URL = 'https://sandbox.runtime.example' + process.env.E2B_SANDBOX_URL = 'https://sandbox.old.example' + delete process.env.E2B_INFRA_API_URL + + const c = await requestCaller() + await c.openTerminal({ template: 'base' }) + await c.resume({ sandboxId: 'sbxexisting' }) + await c.pause({ sandboxId: 'sbxexisting' }) + await c.killTerminalPty({ sandboxId: 'sbxexisting', pid: 42 }) + + const options = expect.objectContaining({ + domain: 'runtime.example', + sandboxUrl: 'https://sandbox.runtime.example', + apiUrl: 'https://api.runtime.example', + }) + expect(sdkMock.create).toHaveBeenCalledWith('base', options) + expect(sdkMock.connect).toHaveBeenCalledTimes(2) + for (const call of sdkMock.connect.mock.calls) { + expect(call).toEqual(['sbxexisting', options]) + } + expect(sdkMock.getFullInfo).toHaveBeenCalledWith('sbxexisting', options) + expect(sdkMock.pause).toHaveBeenCalledWith('sbxexisting', options) + }) +}) From de4b2e02f3d1dd087094d5961a3b9590272324de Mon Sep 17 00:00:00 2001 From: Aliaksandr Drankou Date: Tue, 15 Sep 2026 17:14:30 +0200 Subject: [PATCH 09/10] fix(config): validate startup settings and use configured sandbox URLs --- .env.example | 8 +- .github/workflows/container.yml | 6 + README.md | 27 +- scripts/container-smoke.sh | 30 ++- src/app/(dashboard)/layout.tsx | 4 +- src/configs/cookies.ts | 12 +- src/core/server/api/routers/sandbox.ts | 10 +- src/core/server/runtime-config.ts | 136 +--------- src/instrumentation.ts | 13 + .../runtime-config-layout.test.tsx | 4 +- .../runtime-config-startup.test.ts | 70 +++++ tests/unit/cookie-options.test.ts | 15 ++ tests/unit/runtime-config.test.ts | 248 +----------------- tests/unit/sandbox-router-api-url.test.ts | 97 +++---- 14 files changed, 229 insertions(+), 451 deletions(-) create mode 100644 tests/integration/runtime-config-startup.test.ts diff --git a/.env.example b/.env.example index 446bff5b9..f2b0daba1 100644 --- a/.env.example +++ b/.env.example @@ -30,13 +30,13 @@ PUBLIC_E2B_DOMAIN=e2b.dev # E2B_DASHBOARD_API_URL=http://127.0.0.1:3010 ### Base URL the BROWSER uses to reach sandboxes (terminal and filesystem -### inspector). If E2B_INFRA_API_URL is set, leaving this unset means "the host this -### 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 — +### inspector). Set this explicitly for local sandbox proxies. It must be +### reachable from both the browser and the server — ### the loopback below works only when the two are the same machine. # PUBLIC_SANDBOX_URL=http://127.0.0.1:3002 ### Legacy fallbacks, in order: E2B_SANDBOX_URL, NEXT_PUBLIC_E2B_SANDBOX_URL. -### With no sandbox URL or E2B_INFRA_API_URL, the SDK uses the cluster domain. +### With no sandbox URL, the SDK uses the cluster domain. +### Request headers never select this URL. ### 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. diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index 0982bf653..a21004d3f 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -17,6 +17,9 @@ on: - scripts/check-app-env.ts - scripts/container-smoke.sh - src/lib/env.ts + - src/instrumentation.ts + - src/core/server/runtime-config.ts + - src/configs/cookies.ts - .github/workflows/container.yml pull_request: branches: [main] @@ -30,6 +33,9 @@ on: - scripts/check-app-env.ts - scripts/container-smoke.sh - src/lib/env.ts + - src/instrumentation.ts + - src/core/server/runtime-config.ts + - src/configs/cookies.ts - .github/workflows/container.yml workflow_dispatch: diff --git a/README.md b/README.md index bc3d612ac..934d1e9bf 100644 --- a/README.md +++ b/README.md @@ -50,12 +50,14 @@ Restart the container and reload open pages after changing its configuration. Resolution order (blank values are skipped): - Domain: `PUBLIC_E2B_DOMAIN` → `NEXT_PUBLIC_E2B_DOMAIN`. -- Sandbox URL: `PUBLIC_SANDBOX_URL` → `E2B_SANDBOX_URL` → `NEXT_PUBLIC_E2B_SANDBOX_URL` → the fallback below. +- Sandbox URL: `PUBLIC_SANDBOX_URL` → `E2B_SANDBOX_URL` → `NEXT_PUBLIC_E2B_SANDBOX_URL` → SDK domain routing. - API URLs: corresponding `E2B_*` override → `NEXT_PUBLIC_*` override → URL derived from the resolved domain. -Every explicit URL must include `http://` or `https://`. The server rejects -invalid URLs, naming the variable. API URLs resolve when their server modules -load; sandbox URLs resolve when a dashboard request or SDK call needs them. +Every explicit URL must include `http://` or `https://`. Server initialization +validates the resolved API and sandbox URLs and `DASHBOARD_COOKIE_SECURE`, +even when telemetry is disabled. Invalid values stop startup and name the +variable. The cookie flag accepts `true` or `false` (case-insensitive, with +surrounding whitespace ignored); an empty value keeps the default. The dashboard's Server Component layout resolves **only the domain and sandbox URL** and passes them as props to a client `ClientConfigProvider`. @@ -64,18 +66,15 @@ render, without a separate config request. API endpoints and team credentials stay on the server. Both public settings are visible to browser users and must contain no secrets. -When `E2B_INFRA_API_URL` is set and no sandbox URL is given, the sandbox URL -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. For a domain -name, set `PUBLIC_SANDBOX_URL` to a `localhost`, IP, or `sandbox.` base -URL that both the browser and server can reach. Without `E2B_INFRA_API_URL`, -leaving the sandbox URL unset preserves the SDK's domain-based routing. +For a local sandbox proxy, explicitly set `PUBLIC_SANDBOX_URL`, for example +`http://127.0.0.1:3002` when the browser and server run on the same machine. +Use an address reachable from both the browser and server. When a sandbox +URL is unset, the SDK uses domain-based routing, including when +`E2B_INFRA_API_URL` is set. Server-side sandbox calls, such as terminal PTY cleanup, use the same domain -and sandbox URL resolution. Behind a reverse proxy, set `X-Forwarded-Host` -and `X-Forwarded-Proto` at the proxy instead of forwarding client-supplied -values: the request-host fallback trusts these headers. +and sandbox URL resolution. `Host`, `X-Forwarded-Host`, and +`X-Forwarded-Proto` never determine sandbox destinations. ## Features diff --git a/scripts/container-smoke.sh b/scripts/container-smoke.sh index af326157f..878d52073 100755 --- a/scripts/container-smoke.sh +++ b/scripts/container-smoke.sh @@ -7,10 +7,11 @@ set -euo pipefail IMAGE="${IMAGE:-e2b-dashboard:smoke}" PORT="${PORT:-3001}" CONTAINER="e2b-dashboard-smoke-$$" +INVALID_CONTAINER="${CONTAINER}-invalid" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cleanup() { - docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true + docker rm -f "${CONTAINER}" "${INVALID_CONTAINER}" >/dev/null 2>&1 || true } trap cleanup EXIT @@ -64,4 +65,31 @@ if [ "${fail}" != 0 ]; then exit 1 fi +check_invalid_config() { + local variable="$1" value="$2" status="" exit_code logs + docker run -d --name "${INVALID_CONTAINER}" --network none \ + -e "${variable}=${value}" "${IMAGE}" >/dev/null + + for _ in $(seq 1 50); do + status="$(docker inspect -f '{{.State.Status}}' "${INVALID_CONTAINER}")" + if [ "${status}" = "exited" ]; then break; fi + sleep 0.2 + done + + exit_code="$(docker inspect -f '{{.State.ExitCode}}' "${INVALID_CONTAINER}")" + logs="$(docker logs "${INVALID_CONTAINER}" 2>&1)" + if [ "${status}" != "exited" ] || [ "${exit_code}" = "0" ] || \ + [[ "${logs}" != *"${variable}"* ]]; then + echo "FAIL: invalid ${variable} must stop startup and name the variable" >&2 + echo "${logs}" >&2 + exit 1 + fi + echo "ok invalid ${variable} rejected at startup" + docker rm "${INVALID_CONTAINER}" >/dev/null +} + +check_invalid_config PUBLIC_SANDBOX_URL missing-scheme.example:3002 +check_invalid_config E2B_SANDBOX_URL ftp://sandbox.example +check_invalid_config DASHBOARD_COOKIE_SECURE off + echo "==> container smoke test passed" diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 5415aa428..121020bfb 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -1,4 +1,4 @@ -import { cookies, headers } from 'next/headers' +import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import type { Metadata } from 'next/types' import { COOKIE_KEYS } from '@/configs/cookies' @@ -35,7 +35,7 @@ export default async function DashboardLayout({ redirect('/') } - const runtimeConfig = resolveBrowserRuntimeConfig(await headers()) + const runtimeConfig = resolveBrowserRuntimeConfig() const sidebarState = cookieStore.get(COOKIE_KEYS.SIDEBAR_STATE)?.value const defaultOpen = sidebarState === 'true' const timezone = parseTimezone( diff --git a/src/configs/cookies.ts b/src/configs/cookies.ts index 78e721a22..1b4eb32c5 100644 --- a/src/configs/cookies.ts +++ b/src/configs/cookies.ts @@ -23,16 +23,18 @@ export const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 // 1 year * 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. + * Validate runtime values too: the build-time schema cannot check variables + * supplied when starting a prebuilt image. */ -function isSecureCookie(): boolean { +export function isSecureCookie(): boolean { const configured: string | undefined = process.env.DASHBOARD_COOKIE_SECURE?.trim().toLowerCase() if (configured !== undefined && configured !== '') { - return configured !== 'false' + if (configured === 'true') return true + if (configured === 'false') return false + + throw new Error('DASHBOARD_COOKIE_SECURE must be true or false') } return process.env.NODE_ENV === 'production' diff --git a/src/core/server/api/routers/sandbox.ts b/src/core/server/api/routers/sandbox.ts index 931cf70ab..3f302b12a 100644 --- a/src/core/server/api/routers/sandbox.ts +++ b/src/core/server/api/routers/sandbox.ts @@ -16,7 +16,7 @@ import { withAuthedRequestRepository } from '@/core/server/api/middlewares/repos import { resolveE2BDomain, resolveInfraApiUrl, - resolveServerSandboxUrl, + resolveSandboxUrl, } from '@/core/server/runtime-config' import { createTRPCRouter } from '@/core/server/trpc/init' import { protectedProcedure } from '@/core/server/trpc/procedures' @@ -236,7 +236,7 @@ export const sandboxRouter = createTRPCRouter({ const connectionOpts = { apiUrl: resolveInfraApiUrl(), domain: resolveE2BDomain(), - sandboxUrl: resolveServerSandboxUrl(ctx.headers, ctx.requestUrl), + sandboxUrl: resolveSandboxUrl(), apiKey, } @@ -324,7 +324,7 @@ export const sandboxRouter = createTRPCRouter({ const connectionOpts = { apiUrl: resolveInfraApiUrl(), domain: resolveE2BDomain(), - sandboxUrl: resolveServerSandboxUrl(ctx.headers, ctx.requestUrl), + sandboxUrl: resolveSandboxUrl(), apiKey, } @@ -380,7 +380,7 @@ export const sandboxRouter = createTRPCRouter({ const connectionOpts = { apiUrl: resolveInfraApiUrl(), domain: resolveE2BDomain(), - sandboxUrl: resolveServerSandboxUrl(ctx.headers, ctx.requestUrl), + sandboxUrl: resolveSandboxUrl(), apiKey, } @@ -419,7 +419,7 @@ export const sandboxRouter = createTRPCRouter({ const sandbox = await Sandbox.connect(input.sandboxId, { apiUrl: resolveInfraApiUrl(), domain: resolveE2BDomain(), - sandboxUrl: resolveServerSandboxUrl(ctx.headers, ctx.requestUrl), + 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 c92556252..5a3933b37 100644 --- a/src/core/server/runtime-config.ts +++ b/src/core/server/runtime-config.ts @@ -7,18 +7,14 @@ * 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. + * Runtime values are checked by the instrumentation hook before the server + * is ready, and by these resolvers whenever they are used. */ import 'server-only' +import { isSecureCookie } from '@/configs/cookies' import type { BrowserRuntimeConfig } from '@/core/shared/runtime-config' -const SANDBOX_DEFAULT_PORT = '3002' - interface ResolvedValue { name: string value: string @@ -42,18 +38,6 @@ 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) @@ -124,111 +108,17 @@ export function resolveSandboxUrl(): string | undefined { 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 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 | 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. - return new URL(`${protocol}://${host}`).hostname || undefined - } catch { - return undefined - } -} - -/** - * 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 | undefined, - port: string -): 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 - - 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 configured with a domain keep the SDK's domain-based URLs. - * The dashboard layout passes this same URL to browser SDK consumers so - * terminal connections and server-side PTY cleanup reach the same host. - */ -export function resolveServerSandboxUrl( - headers: Headers, - requestUrl: string | undefined -): string | undefined { - const configured = resolveSandboxUrl() - - if (configured) { - return configured - } - - if (!trimmed(process.env.E2B_INFRA_API_URL)) { - return undefined - } - - return requestOrigin(headers, requestUrl, SANDBOX_DEFAULT_PORT) -} - -/** - * Explicitly allowlist browser-visible settings; API endpoints and team - * credentials stay on the server. - */ -export function resolveBrowserRuntimeConfig( - headers: Headers, - requestUrl?: string -): BrowserRuntimeConfig { +/** Only operator configuration may select a sandbox destination. */ +export function resolveBrowserRuntimeConfig(): BrowserRuntimeConfig { return { domain: resolveE2BDomain() ?? null, - sandboxUrl: resolveServerSandboxUrl(headers, requestUrl) ?? null, + sandboxUrl: resolveSandboxUrl() ?? null, } } + +export function validateRuntimeConfig(): void { + resolveInfraApiUrl() + resolveDashboardApiUrl() + resolveSandboxUrl() + isSecureCookie() +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 4d45de743..364c9a901 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -1,6 +1,19 @@ import { registerOTel } from '@vercel/otel' export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + try { + const { validateRuntimeConfig } = await import( + './core/server/runtime-config' + ) + validateRuntimeConfig() + } catch (error) { + console.error('Invalid runtime configuration:', error) + // Next can catch hook errors and leave a standalone server running. + process.exit(1) + } + } + if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) return if (process.env.NEXT_RUNTIME === 'nodejs') { diff --git a/tests/integration/runtime-config-layout.test.tsx b/tests/integration/runtime-config-layout.test.tsx index c5d7f8d18..3b3774713 100644 --- a/tests/integration/runtime-config-layout.test.tsx +++ b/tests/integration/runtime-config-layout.test.tsx @@ -69,7 +69,7 @@ describe('dashboard layout runtime config', () => { expect(fetchSpy).not.toHaveBeenCalled() }) - it('keeps host-derived sandbox URLs isolated between requests', async () => { + it('does not select a sandbox destination from request headers', async () => { vi.stubEnv('PUBLIC_E2B_DOMAIN', 'cluster.example') vi.stubEnv('PUBLIC_SANDBOX_URL', '') vi.stubEnv('E2B_SANDBOX_URL', '') @@ -85,7 +85,7 @@ describe('dashboard layout runtime config', () => { const layout = await DashboardLayout({ children: }) expect(renderToStaticMarkup(layout)).toContain( - `cluster.example|https://${host}:3002` + 'cluster.example|' ) } }) diff --git a/tests/integration/runtime-config-startup.test.ts b/tests/integration/runtime-config-startup.test.ts new file mode 100644 index 000000000..e097e8558 --- /dev/null +++ b/tests/integration/runtime-config-startup.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { register } from '@/instrumentation' + +vi.mock('@vercel/otel', () => ({ registerOTel: vi.fn() })) + +const URL_KEYS = [ + 'PUBLIC_SANDBOX_URL', + 'E2B_SANDBOX_URL', + 'NEXT_PUBLIC_E2B_SANDBOX_URL', + 'E2B_INFRA_API_URL', + 'NEXT_PUBLIC_INFRA_API_URL', + 'E2B_DASHBOARD_API_URL', + 'NEXT_PUBLIC_DASHBOARD_API_URL', +] as const + +beforeEach(() => { + vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${code})`) + }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.stubEnv('NEXT_RUNTIME', 'nodejs') + vi.stubEnv('OTEL_EXPORTER_OTLP_ENDPOINT', '') + vi.stubEnv('DASHBOARD_COOKIE_SECURE', '') + for (const key of URL_KEYS) vi.stubEnv(key, '') +}) + +afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() +}) + +async function expectStartupFailure(variable: string) { + await expect(register()).rejects.toThrow('process.exit(1)') + expect(process.exit).toHaveBeenCalledWith(1) + expect(console.error).toHaveBeenCalledWith( + 'Invalid runtime configuration:', + expect.objectContaining({ message: expect.stringContaining(variable) }) + ) +} + +describe('runtime configuration at server startup', () => { + it.each( + URL_KEYS + )('rejects invalid %s with telemetry disabled', async (key) => { + vi.stubEnv(key, 'missing-scheme.example:3002') + + await expectStartupFailure(key) + }) + + it('rejects an invalid cookie flag with telemetry disabled', async () => { + vi.stubEnv('DASHBOARD_COOKIE_SECURE', 'off') + + await expectStartupFailure('DASHBOARD_COOKIE_SECURE') + }) + + it('accepts a valid runtime configuration with telemetry disabled', async () => { + vi.stubEnv('PUBLIC_SANDBOX_URL', 'https://sandbox.example') + vi.stubEnv('E2B_INFRA_API_URL', 'http://127.0.0.1:3000') + vi.stubEnv('E2B_DASHBOARD_API_URL', 'http://127.0.0.1:3010') + vi.stubEnv('DASHBOARD_COOKIE_SECURE', ' false ') + + await expect(register()).resolves.toBeUndefined() + expect(process.exit).not.toHaveBeenCalled() + }) + + it('allows SDK domain routing with no sandbox override', async () => { + await expect(register()).resolves.toBeUndefined() + expect(process.exit).not.toHaveBeenCalled() + }) +}) diff --git a/tests/unit/cookie-options.test.ts b/tests/unit/cookie-options.test.ts index 2315c0946..833050289 100644 --- a/tests/unit/cookie-options.test.ts +++ b/tests/unit/cookie-options.test.ts @@ -86,4 +86,19 @@ describe('api key cookie options', () => { secure: true, }) }) + + it.each([ + '0', + '1', + 'no', + 'off', + 'typo', + ])('rejects an unrecognized runtime value: %s', async (value) => { + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('DASHBOARD_COOKIE_SECURE', value) + + await expect(loadApiKeyCookieOptions()).rejects.toThrow( + 'DASHBOARD_COOKIE_SECURE must be true or false' + ) + }) }) diff --git a/tests/unit/runtime-config.test.ts b/tests/unit/runtime-config.test.ts index 154750bba..3dba1bf37 100644 --- a/tests/unit/runtime-config.test.ts +++ b/tests/unit/runtime-config.test.ts @@ -5,7 +5,6 @@ import { resolveE2BDomain, resolveInfraApiUrl, resolveSandboxUrl, - resolveServerSandboxUrl, } from '@/core/server/runtime-config' const MANAGED_KEYS = [ @@ -53,7 +52,7 @@ describe('resolveE2BDomain', () => { expect(resolveE2BDomain()).toBe(domain) expect(resolveInfraApiUrl()).toBe(`https://api.${domain}`) expect(resolveDashboardApiUrl()).toBe(`https://dashboard-api.${domain}`) - expect(resolveBrowserRuntimeConfig(new Headers())).toEqual({ + expect(resolveBrowserRuntimeConfig()).toEqual({ domain, sandboxUrl: null, }) @@ -187,250 +186,34 @@ describe('resolveBrowserRuntimeConfig', () => { process.env.E2B_DASHBOARD_API_URL = 'http://dashboard-api.internal:3010' process.env.E2B_API_KEY = 'e2b_test_private_key' - expect(resolveBrowserRuntimeConfig(new Headers())).toEqual({ + expect(resolveBrowserRuntimeConfig()).toEqual({ domain: 'runtime.example', sandboxUrl: 'https://sandbox.runtime.example', }) }) - it('resolves the sandbox host from layout headers without a request URL', () => { - process.env.E2B_INFRA_API_URL = 'http://infra-api.internal:3000' - const requestHeaders = new Headers({ - host: 'dashboard.internal:3001', - 'x-forwarded-host': '192.0.2.1:8443', - 'x-forwarded-proto': 'https', - }) - - expect(resolveBrowserRuntimeConfig(requestHeaders).sandboxUrl).toBe( - 'https://192.0.2.1:3002' - ) - }) - - const requestUrl = 'http://dash.example:3001/sandboxes' - 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({ - domain: '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', () => { + it('keeps SDK routing when API overrides are set without a sandbox URL', () => { process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' - expect(resolveBrowserRuntimeConfig(headers(), requestUrl)).toEqual({ + expect(resolveBrowserRuntimeConfig()).toEqual({ domain: 'example.dev', - sandboxUrl: 'http://dash.example:3002', + sandboxUrl: null, }) }) - 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 and uses the host header', () => { - 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://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') - }) - - 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 - ) + it('uses the same configured sandbox URL for browser and server consumers', () => { + process.env.PUBLIC_SANDBOX_URL = 'https://sandbox.example.dev' - expect(config.sandboxUrl).toBe('https://dash.example:3002') + expect(resolveBrowserRuntimeConfig().sandboxUrl).toBe(resolveSandboxUrl()) }) it('passes no domain when none is configured', () => { delete process.env.NEXT_PUBLIC_E2B_DOMAIN - expect(resolveBrowserRuntimeConfig(headers(), requestUrl).domain).toBeNull() - }) - - 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 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 - ) + expect(resolveBrowserRuntimeConfig().domain).toBeNull() }) }) -// 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' @@ -470,17 +253,4 @@ describe('URL validation', () => { 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/sandboxes' - ).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 c68cb47fa..293fc33d7 100644 --- a/tests/unit/sandbox-router-api-url.test.ts +++ b/tests/unit/sandbox-router-api-url.test.ts @@ -38,7 +38,6 @@ const createCaller = createCallerFactory(sandboxRouter) 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({ @@ -69,9 +68,6 @@ 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() @@ -170,11 +166,6 @@ 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 gets its config from the layout, 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' @@ -217,53 +208,47 @@ describe('sandbox router sandbox URL', () => { ) }) - // 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 - ) + describe.each([ + undefined, + 'https://sandbox.configured.example', + ])('request headers with sandbox URL %s', (sandboxUrl) => { + it.each([ + { host: 'untrusted.example:3001' }, + { + host: REQUEST_HOST, + 'x-forwarded-host': 'untrusted.example', + 'x-forwarded-proto': 'http', + }, + { + host: REQUEST_HOST, + 'x-forwarded-host': 'untrusted.example, proxy.example', + 'x-forwarded-proto': 'https,http', + }, + ])('never uses request metadata for SDK destinations: %j', async (headers) => { + if (sandboxUrl) process.env.PUBLIC_SANDBOX_URL = sandboxUrl + const c = await caller({ + headers: new Headers(headers), + requestUrl: 'http://untrusted-url.example/api/trpc', + }) + await c.openTerminal({ template: 'base' }) + await c.resume({ sandboxId: 'sbxexisting' }) + await c.pause({ sandboxId: 'sbxexisting' }) + await c.killTerminalPty({ sandboxId: 'sbxexisting', pid: 42 }) + + const options = expect.objectContaining({ + apiUrl: RUNTIME_API_URL, + sandboxUrl, + }) + expect(sdkMock.create).toHaveBeenCalledWith('base', options) + expect(sdkMock.connect).toHaveBeenCalledTimes(2) + for (const call of sdkMock.connect.mock.calls) { + expect(call).toEqual(['sbxexisting', options]) + } + expect(sdkMock.getFullInfo).toHaveBeenCalledWith('sbxexisting', options) + expect(sdkMock.pause).toHaveBeenCalledWith('sbxexisting', options) + const sandbox = await sdkMock.connect.mock.results.at(-1)?.value + expect(sandbox.pty.kill).toHaveBeenCalledWith(42) + }) }) it('prefers the explicit sandbox URL over the request host', async () => { From 6b115fde51818dbab06afb4023c08f46cbc27d3f Mon Sep 17 00:00:00 2001 From: Aliaksandr Drankou Date: Wed, 16 Sep 2026 17:16:39 +0200 Subject: [PATCH 10/10] fix(config): centralize validation and require runtime configuration --- .env.example | 11 +- .github/workflows/test.yml | 2 +- Dockerfile | 11 +- README.md | 51 ++-- scripts/container-smoke.sh | 13 +- src/configs/cookies.ts | 28 +- src/core/server/runtime-config.ts | 117 +------- src/instrumentation.ts | 12 +- src/lib/env.ts | 81 +++--- src/types/env.d.ts | 7 +- .../runtime-config-layout.test.tsx | 5 +- .../runtime-config-startup.test.ts | 34 ++- tests/setup.ts | 2 +- tests/unit/env.test.ts | 99 +++++-- tests/unit/runtime-config.test.ts | 259 ++++-------------- tests/unit/sandbox-router-api-url.test.ts | 44 +-- 16 files changed, 280 insertions(+), 496 deletions(-) diff --git a/.env.example b/.env.example index f2b0daba1..20ae72159 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,6 @@ ### Resolves infra-api (`https://api.`) and dashboard-api ### (`https://dashboard-api.`) unless overridden below. PUBLIC_E2B_DOMAIN=e2b.dev -### Legacy fallback: NEXT_PUBLIC_E2B_DOMAIN (frozen at build time). ### ================================= ### OPTIONAL ENVIRONMENT VARIABLES @@ -17,15 +16,8 @@ PUBLIC_E2B_DOMAIN=e2b.dev ### and sign-out is hidden. # E2B_API_KEY=e2b_your_team_api_key -### Explicit API base URLs (override the cluster domain resolution; +### Runtime API base URLs (override the cluster domain resolution; ### useful for local infra development). -# 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 @@ -34,7 +26,6 @@ PUBLIC_E2B_DOMAIN=e2b.dev ### reachable from both the browser and the server — ### the loopback below works only when the two are the same machine. # PUBLIC_SANDBOX_URL=http://127.0.0.1:3002 -### Legacy fallbacks, in order: E2B_SANDBOX_URL, NEXT_PUBLIC_E2B_SANDBOX_URL. ### With no sandbox URL, the SDK uses the cluster domain. ### Request headers never select this URL. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9558fb127..2194764ae 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,7 +45,7 @@ jobs: runs-on: ubuntu-latest needs: unit-tests env: - NEXT_PUBLIC_E2B_DOMAIN: e2b-test.dev + PUBLIC_E2B_DOMAIN: e2b-test.dev steps: - name: Checkout code diff --git a/Dockerfile b/Dockerfile index b462faec7..c40dc9a19 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,15 +25,12 @@ COPY --from=deps /usr/local/bin/bun /usr/local/bin/bun COPY --from=deps /app/node_modules ./node_modules COPY . . -# Retain the legacy build argument as a fallback. PUBLIC_E2B_DOMAIN overrides -# it at runtime. The default resolves nowhere so an unconfigured container -# cannot accidentally reach someone else's deployment. -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 +# Page-data collection imports API clients. This build-only domain is not +# carried into the runtime image; each installation must provide its own. +RUN PUBLIC_E2B_DOMAIN=build.invalid bun scripts/check-app-env.ts +RUN PUBLIC_E2B_DOMAIN=build.invalid node node_modules/next/dist/bin/next build --webpack FROM node:22-bookworm-slim AS runtime diff --git a/README.md b/README.md index 934d1e9bf..4b627ab8b 100644 --- a/README.md +++ b/README.md @@ -32,31 +32,35 @@ Authentication is a single **team API key**: | Variable | Read | Purpose | |---|---|---| -| `PUBLIC_E2B_DOMAIN` | runtime | E2B cluster domain; used by the SDK and to derive `https://api.` and `https://dashboard-api.` | +| `PUBLIC_E2B_DOMAIN` | runtime | Required E2B cluster domain; used by the SDK and to derive `https://api.` and `https://dashboard-api.` | | `PUBLIC_SANDBOX_URL` | per request | Optional sandbox traffic base URL, reachable from both the browser and server | -| `E2B_INFRA_API_URL` / `E2B_DASHBOARD_API_URL` | server start | Explicit server-side API URLs; override domain-derived URLs | -| `E2B_SANDBOX_URL` | per request | Legacy alias for `PUBLIC_SANDBOX_URL` | -| `NEXT_PUBLIC_E2B_DOMAIN` | build | Legacy fallback for `PUBLIC_E2B_DOMAIN` | -| `NEXT_PUBLIC_INFRA_API_URL` / `NEXT_PUBLIC_DASHBOARD_API_URL` | build | Legacy API overrides, below the corresponding `E2B_*` variables | -| `NEXT_PUBLIC_E2B_SANDBOX_URL` | build | Legacy sandbox URL, below both runtime names | +| `E2B_INFRA_API_URL` / `E2B_DASHBOARD_API_URL` | server start | Optional server-side API URLs; override domain-derived URLs | | `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 | -Configure a prebuilt image with `PUBLIC_*` and `E2B_*` variables when starting -the container. Next does not give `PUBLIC_` any special behavior: the server -explicitly reads these values at runtime. `NEXT_PUBLIC_*` aliases remain -supported for existing builds, but their values are frozen by `next build`. -Restart the container and reload open pages after changing its configuration. +Set `PUBLIC_E2B_DOMAIN` when starting the container. Next does not give +`PUBLIC_` any special behavior: the server explicitly reads these values at +runtime. Restart the container and reload open pages after changing its +configuration. Missing or blank domains stop startup. -Resolution order (blank values are skipped): +Legacy variables no longer act as fallbacks. Rename them before upgrading; +validation reports the replacement for each deprecated variable that is still +set, even if the new name is also present. -- Domain: `PUBLIC_E2B_DOMAIN` → `NEXT_PUBLIC_E2B_DOMAIN`. -- Sandbox URL: `PUBLIC_SANDBOX_URL` → `E2B_SANDBOX_URL` → `NEXT_PUBLIC_E2B_SANDBOX_URL` → SDK domain routing. -- API URLs: corresponding `E2B_*` override → `NEXT_PUBLIC_*` override → URL derived from the resolved domain. - -Every explicit URL must include `http://` or `https://`. Server initialization -validates the resolved API and sandbox URLs and `DASHBOARD_COOKIE_SECURE`, -even when telemetry is disabled. Invalid values stop startup and name the -variable. The cookie flag accepts `true` or `false` (case-insensitive, with +| Deprecated variable | Replacement | +|---|---| +| `NEXT_PUBLIC_E2B_DOMAIN` | `PUBLIC_E2B_DOMAIN` | +| `NEXT_PUBLIC_INFRA_API_URL` | `E2B_INFRA_API_URL` | +| `NEXT_PUBLIC_DASHBOARD_API_URL` | `E2B_DASHBOARD_API_URL` | +| `NEXT_PUBLIC_E2B_SANDBOX_URL` / `E2B_SANDBOX_URL` | `PUBLIC_SANDBOX_URL` | + +API URL overrides remain optional; without them the domain determines both +API URLs. An absent or blank `PUBLIC_SANDBOX_URL` lets the SDK use domain +routing. + +Every API or sandbox URL must include `http://` or `https://`. The schema in +`src/lib/env.ts` validates configuration for development, builds, and +server startup, even when telemetry is disabled. Invalid values stop startup +and name the variable. The cookie flag accepts `true` or `false` (case-insensitive, with surrounding whitespace ignored); an empty value keeps the default. The dashboard's Server Component layout resolves **only the domain and @@ -142,10 +146,9 @@ docker run --rm -p 3001:3001 \ - `PUBLIC_E2B_DOMAIN` configures the cluster at container start, so the same image can serve different installations. Use `PUBLIC_SANDBOX_URL` when the default SDK routing does not fit your deployment. -- The legacy `NEXT_PUBLIC_E2B_DOMAIN` build argument is still supported. Its - default, `unset.invalid`, resolves nowhere so an unconfigured container - cannot accidentally talk to another deployment. Runtime configuration takes - precedence over that build-time fallback. +- The image builds without installation settings. Its temporary build domain + is not carried into the runtime image, so a container started without + `PUBLIC_E2B_DOMAIN` fails validation. - 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/scripts/container-smoke.sh b/scripts/container-smoke.sh index 878d52073..b07c7af36 100755 --- a/scripts/container-smoke.sh +++ b/scripts/container-smoke.sh @@ -19,7 +19,7 @@ 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 +docker run -d --name "${CONTAINER}" -e PORT="${PORT}" -e PUBLIC_E2B_DOMAIN=smoke.invalid -p "${PORT}:${PORT}" "${IMAGE}" >/dev/null ready=0 for _ in $(seq 1 60); do @@ -68,7 +68,7 @@ fi check_invalid_config() { local variable="$1" value="$2" status="" exit_code logs docker run -d --name "${INVALID_CONTAINER}" --network none \ - -e "${variable}=${value}" "${IMAGE}" >/dev/null + -e PUBLIC_E2B_DOMAIN=smoke.invalid -e "${variable}=${value}" "${IMAGE}" >/dev/null for _ in $(seq 1 50); do status="$(docker inspect -f '{{.State.Status}}' "${INVALID_CONTAINER}")" @@ -89,7 +89,14 @@ check_invalid_config() { } check_invalid_config PUBLIC_SANDBOX_URL missing-scheme.example:3002 -check_invalid_config E2B_SANDBOX_URL ftp://sandbox.example +check_invalid_config PUBLIC_E2B_DOMAIN '' +check_invalid_config E2B_INFRA_API_URL ftp://api.example +check_invalid_config E2B_DASHBOARD_API_URL missing-scheme.example:3010 +check_invalid_config E2B_SANDBOX_URL https://sandbox.example +check_invalid_config NEXT_PUBLIC_E2B_DOMAIN old.example +check_invalid_config NEXT_PUBLIC_INFRA_API_URL https://api.old.example +check_invalid_config NEXT_PUBLIC_DASHBOARD_API_URL https://dashboard-api.old.example +check_invalid_config NEXT_PUBLIC_E2B_SANDBOX_URL https://sandbox.old.example check_invalid_config DASHBOARD_COOKIE_SECURE off echo "==> container smoke test passed" diff --git a/src/configs/cookies.ts b/src/configs/cookies.ts index 1b4eb32c5..c6d237fbf 100644 --- a/src/configs/cookies.ts +++ b/src/configs/cookies.ts @@ -1,4 +1,5 @@ import type { ResponseCookie } from 'next/dist/compiled/@edge-runtime/cookies' +import { serverSchema } from '@/lib/env' /** * Cookie keys used throughout the application. @@ -17,27 +18,14 @@ 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. - * - * Validate runtime values too: the build-time schema cannot check variables - * supplied when starting a prebuilt image. - */ -export function isSecureCookie(): boolean { - const configured: string | undefined = - process.env.DASHBOARD_COOKIE_SECURE?.trim().toLowerCase() - - if (configured !== undefined && configured !== '') { - if (configured === 'true') return true - if (configured === 'false') return false - - throw new Error('DASHBOARD_COOKIE_SECURE must be true or false') - } +function isSecureCookie(): boolean { + const { DASHBOARD_COOKIE_SECURE } = serverSchema + .pick({ DASHBOARD_COOKIE_SECURE: true }) + .parse(process.env) - return process.env.NODE_ENV === 'production' + return DASHBOARD_COOKIE_SECURE === undefined + ? process.env.NODE_ENV === 'production' + : DASHBOARD_COOKIE_SECURE === 'true' } const BASE_COOKIE_OPTIONS: Partial = { diff --git a/src/core/server/runtime-config.ts b/src/core/server/runtime-config.ts index 5a3933b37..0b0f1923b 100644 --- a/src/core/server/runtime-config.ts +++ b/src/core/server/runtime-config.ts @@ -1,124 +1,33 @@ -/** - * Where this deployment's APIs live. - * - * Hosted deployments are configured with NEXT_PUBLIC_* variables, which Next - * inlines into the bundles at build time. PUBLIC_* and E2B_* variables have no - * special meaning to Next, so Node reads them at runtime 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. - * - * Runtime values are checked by the instrumentation hook before the server - * is ready, and by these resolvers whenever they are used. - */ - import 'server-only' -import { isSecureCookie } from '@/configs/cookies' import type { BrowserRuntimeConfig } from '@/core/shared/runtime-config' +import { runtimeEnvSchema } from '@/lib/env' -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 cleaned = trimmed(value) - - if (cleaned) { - return { name, value: cleaned } - } - } - - 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 an http(s) URL: "${value}" (include the scheme, e.g. http://127.0.0.1:3000)` - ) - } - - return value -} - -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 resolveE2BDomain(): string | undefined { - return firstSet( - ['PUBLIC_E2B_DOMAIN', process.env.PUBLIC_E2B_DOMAIN], - ['NEXT_PUBLIC_E2B_DOMAIN', process.env.NEXT_PUBLIC_E2B_DOMAIN] - )?.value +export function resolveE2BDomain(): string { + return runtimeEnvSchema.parse(process.env).PUBLIC_E2B_DOMAIN } export function resolveInfraApiUrl(): string { - const configured = configuredInfraApiUrl() - - return configured - ? assertHttpUrl(configured) - : `https://api.${resolveE2BDomain()}` + const env = runtimeEnvSchema.parse(process.env) + return env.E2B_INFRA_API_URL ?? `https://api.${env.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] + const env = runtimeEnvSchema.parse(process.env) + return ( + env.E2B_DASHBOARD_API_URL ?? + `https://dashboard-api.${env.PUBLIC_E2B_DOMAIN}` ) - - return configured - ? assertHttpUrl(configured) - : `https://dashboard-api.${resolveE2BDomain()}` } -/** - * The base URL for sandbox traffic, or undefined to let the SDK derive one - * from the domain. E2B_SANDBOX_URL stays supported as an alias, including for - * installs that share the setting with other E2B SDK consumers. - */ export function resolveSandboxUrl(): string | undefined { - const configured = firstSet( - ['PUBLIC_SANDBOX_URL', process.env.PUBLIC_SANDBOX_URL], - ['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 + return runtimeEnvSchema.parse(process.env).PUBLIC_SANDBOX_URL } /** Only operator configuration may select a sandbox destination. */ export function resolveBrowserRuntimeConfig(): BrowserRuntimeConfig { + const env = runtimeEnvSchema.parse(process.env) return { - domain: resolveE2BDomain() ?? null, - sandboxUrl: resolveSandboxUrl() ?? null, + domain: env.PUBLIC_E2B_DOMAIN, + sandboxUrl: env.PUBLIC_SANDBOX_URL ?? null, } } - -export function validateRuntimeConfig(): void { - resolveInfraApiUrl() - resolveDashboardApiUrl() - resolveSandboxUrl() - isSecureCookie() -} diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 364c9a901..adb59de9a 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -2,16 +2,8 @@ import { registerOTel } from '@vercel/otel' export async function register() { if (process.env.NEXT_RUNTIME === 'nodejs') { - try { - const { validateRuntimeConfig } = await import( - './core/server/runtime-config' - ) - validateRuntimeConfig() - } catch (error) { - console.error('Invalid runtime configuration:', error) - // Next can catch hook errors and leave a standalone server running. - process.exit(1) - } + const { appEnvSchema, validateEnv } = await import('./lib/env') + validateEnv(appEnvSchema) } if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) return diff --git a/src/lib/env.ts b/src/lib/env.ts index b37ca5450..e01e3eb20 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -1,27 +1,55 @@ import { z } from 'zod' -export const serverSchema = z.object({ - // Pre-authenticates the dashboard with a fixed team API key. When set, the - // key form on `/` is skipped entirely (single-user self-hosted deployments). - E2B_API_KEY: z.string().min(1).optional(), +function optionalValue(schema: T) { + return z.preprocess( + (value) => (typeof value === 'string' ? value.trim() || undefined : value), + schema.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(), - E2B_SANDBOX_URL: z.url().optional(), +const httpUrl = optionalValue( + z.url({ + protocol: /^https?$/, + error: 'Must be an http(s) URL, including the scheme', + }) +) - // Read on the server and explicitly passed to the browser by the layout. - PUBLIC_E2B_DOMAIN: z.string().optional(), - PUBLIC_SANDBOX_URL: z.url().optional(), +function deprecatedVariable(replacement: string) { + return z.undefined({ + error: `Deprecated variable; remove it and use ${replacement} instead`, + }) +} - // 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(), +export const runtimeEnvSchema = z.object({ + PUBLIC_E2B_DOMAIN: z.string().trim().min(1, 'Set PUBLIC_E2B_DOMAIN'), + PUBLIC_SANDBOX_URL: httpUrl, + E2B_INFRA_API_URL: httpUrl, + E2B_DASHBOARD_API_URL: httpUrl, + + // Reject the SDK alias too, or its own environment fallback can override routing. + E2B_SANDBOX_URL: deprecatedVariable('PUBLIC_SANDBOX_URL'), + NEXT_PUBLIC_E2B_DOMAIN: deprecatedVariable('PUBLIC_E2B_DOMAIN'), + NEXT_PUBLIC_INFRA_API_URL: deprecatedVariable('E2B_INFRA_API_URL'), + NEXT_PUBLIC_DASHBOARD_API_URL: deprecatedVariable('E2B_DASHBOARD_API_URL'), + NEXT_PUBLIC_E2B_SANDBOX_URL: deprecatedVariable('PUBLIC_SANDBOX_URL'), +}) + +export const serverSchema = runtimeEnvSchema.extend({ + // Pre-authenticates the deployment and skips the API key form. + E2B_API_KEY: z.string().min(1).optional(), + DASHBOARD_COOKIE_SECURE: optionalValue( + z + .string() + .toLowerCase() + .pipe( + z.enum(['true', 'false'], { + error: 'DASHBOARD_COOKIE_SECURE must be true or false', + }) + ) + ), OTEL_SERVICE_NAME: z.string().optional(), - OTEL_EXPORTER_OTLP_ENDPOINT: z.url().optional(), + OTEL_EXPORTER_OTLP_ENDPOINT: optionalValue(z.url()), OTEL_EXPORTER_OTLP_PROTOCOL: z .enum(['grpc', 'http/protobuf', 'http/json']) .optional(), @@ -47,29 +75,12 @@ export const serverSchema = z.object({ }) export const clientSchema = z.object({ - NEXT_PUBLIC_E2B_DOMAIN: z.string().optional(), - NEXT_PUBLIC_VERCEL_ENV: z .enum(['production', 'preview', 'development']) .optional(), - - NEXT_PUBLIC_INFRA_API_URL: z.url().optional(), - NEXT_PUBLIC_E2B_SANDBOX_URL: z.url().optional(), - NEXT_PUBLIC_DASHBOARD_API_URL: z.url().optional(), }) -const merged = serverSchema.merge(clientSchema) - -export const appEnvSchema = merged.refine( - (env) => - Boolean( - env.PUBLIC_E2B_DOMAIN?.trim() || env.NEXT_PUBLIC_E2B_DOMAIN?.trim() - ), - { - message: - 'Set PUBLIC_E2B_DOMAIN (or NEXT_PUBLIC_E2B_DOMAIN for legacy builds)', - } -) +export const appEnvSchema = serverSchema.merge(clientSchema) export type Env = z.infer diff --git a/src/types/env.d.ts b/src/types/env.d.ts index 13bea65cc..e8096e342 100644 --- a/src/types/env.d.ts +++ b/src/types/env.d.ts @@ -1,10 +1,13 @@ import type { Env } from '@/lib/env' +// process.env contains raw strings until validation runs. +type RawEnv = { [Key in keyof Env]?: string } + declare global { namespace NodeJS { - interface ProcessEnv extends Env { + interface ProcessEnv extends RawEnv { /** - * @deprecated Use NEXT_PUBLIC_INFRA_API_URL instead. This will be removed in a future version. + * @deprecated Use E2B_INFRA_API_URL instead. This will be removed in a future version. * TODO: Remove INFRA_API_URL support */ INFRA_API_URL?: string diff --git a/tests/integration/runtime-config-layout.test.tsx b/tests/integration/runtime-config-layout.test.tsx index 3b3774713..b57c8d8dd 100644 --- a/tests/integration/runtime-config-layout.test.tsx +++ b/tests/integration/runtime-config-layout.test.tsx @@ -44,7 +44,6 @@ describe('dashboard layout runtime config', () => { it('delivers request-time values to a client consumer on its first render without fetching config', async () => { const fetchSpy = vi.fn() vi.stubGlobal('fetch', fetchSpy) - vi.stubEnv('NEXT_PUBLIC_E2B_DOMAIN', 'build.example') vi.stubEnv('E2B_API_KEY', 'e2b_test_private_key') vi.stubEnv('E2B_INFRA_API_URL', 'http://infra-api.internal:3000') vi.stubEnv('E2B_DASHBOARD_API_URL', 'http://dashboard-api.internal:3010') @@ -63,7 +62,7 @@ describe('dashboard layout runtime config', () => { `${domain}|https://sandbox.${domain}` ) expect(html).not.toMatch( - /build\.example|e2b_test_private_key|infra-api\.internal|dashboard-api\.internal/ + /e2b_test_private_key|infra-api\.internal|dashboard-api\.internal/ ) } expect(fetchSpy).not.toHaveBeenCalled() @@ -72,8 +71,6 @@ describe('dashboard layout runtime config', () => { it('does not select a sandbox destination from request headers', async () => { vi.stubEnv('PUBLIC_E2B_DOMAIN', 'cluster.example') vi.stubEnv('PUBLIC_SANDBOX_URL', '') - vi.stubEnv('E2B_SANDBOX_URL', '') - vi.stubEnv('NEXT_PUBLIC_E2B_SANDBOX_URL', '') vi.stubEnv('E2B_INFRA_API_URL', 'http://infra-api.internal:3000') for (const host of ['192.0.2.1', '192.0.2.2']) { diff --git a/tests/integration/runtime-config-startup.test.ts b/tests/integration/runtime-config-startup.test.ts index e097e8558..06ddd5200 100644 --- a/tests/integration/runtime-config-startup.test.ts +++ b/tests/integration/runtime-config-startup.test.ts @@ -5,12 +5,16 @@ vi.mock('@vercel/otel', () => ({ registerOTel: vi.fn() })) const URL_KEYS = [ 'PUBLIC_SANDBOX_URL', - 'E2B_SANDBOX_URL', - 'NEXT_PUBLIC_E2B_SANDBOX_URL', 'E2B_INFRA_API_URL', - 'NEXT_PUBLIC_INFRA_API_URL', 'E2B_DASHBOARD_API_URL', +] as const + +const DEPRECATED_KEYS = [ + 'NEXT_PUBLIC_E2B_DOMAIN', + 'NEXT_PUBLIC_INFRA_API_URL', 'NEXT_PUBLIC_DASHBOARD_API_URL', + 'NEXT_PUBLIC_E2B_SANDBOX_URL', + 'E2B_SANDBOX_URL', ] as const beforeEach(() => { @@ -18,6 +22,9 @@ beforeEach(() => { throw new Error(`process.exit(${code})`) }) vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.stubEnv('PUBLIC_E2B_DOMAIN', 'cluster.example') + for (const key of DEPRECATED_KEYS) vi.stubEnv(key, undefined) vi.stubEnv('NEXT_RUNTIME', 'nodejs') vi.stubEnv('OTEL_EXPORTER_OTLP_ENDPOINT', '') vi.stubEnv('DASHBOARD_COOKIE_SECURE', '') @@ -32,10 +39,7 @@ afterEach(() => { async function expectStartupFailure(variable: string) { await expect(register()).rejects.toThrow('process.exit(1)') expect(process.exit).toHaveBeenCalledWith(1) - expect(console.error).toHaveBeenCalledWith( - 'Invalid runtime configuration:', - expect.objectContaining({ message: expect.stringContaining(variable) }) - ) + expect(console.error).toHaveBeenCalledWith(expect.stringContaining(variable)) } describe('runtime configuration at server startup', () => { @@ -47,6 +51,22 @@ describe('runtime configuration at server startup', () => { await expectStartupFailure(key) }) + it.each([ + undefined, + '', + ' ', + ])('rejects a missing or blank runtime domain: %s', async (value) => { + vi.stubEnv('PUBLIC_E2B_DOMAIN', value) + await expectStartupFailure('PUBLIC_E2B_DOMAIN') + }) + + it.each( + DEPRECATED_KEYS + )('rejects deprecated %s even with valid runtime settings', async (key) => { + vi.stubEnv(key, 'https://old.example') + await expectStartupFailure(key) + }) + it('rejects an invalid cookie flag with telemetry disabled', async () => { vi.stubEnv('DASHBOARD_COOKIE_SECURE', 'off') diff --git a/tests/setup.ts b/tests/setup.ts index 5113d27c4..09af87042 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -5,7 +5,7 @@ const projectDir = process.cwd() loadEnvConfig(projectDir) // fall back to placeholder values for env-coupled clients that initialize at module load -process.env.NEXT_PUBLIC_E2B_DOMAIN ??= 'e2b-test.dev' +process.env.PUBLIC_E2B_DOMAIN ??= 'e2b-test.dev' // mock server-only to prevent vitest errors vi.mock('server-only', () => ({})) diff --git a/tests/unit/env.test.ts b/tests/unit/env.test.ts index 26c99921d..def474faa 100644 --- a/tests/unit/env.test.ts +++ b/tests/unit/env.test.ts @@ -1,32 +1,93 @@ import { describe, expect, it } from 'vitest' import { appEnvSchema } from '@/lib/env' +const domain = { PUBLIC_E2B_DOMAIN: 'cluster.example' } +const deprecatedVariables = [ + ['NEXT_PUBLIC_E2B_DOMAIN', 'PUBLIC_E2B_DOMAIN'], + ['NEXT_PUBLIC_INFRA_API_URL', 'E2B_INFRA_API_URL'], + ['NEXT_PUBLIC_DASHBOARD_API_URL', 'E2B_DASHBOARD_API_URL'], + ['NEXT_PUBLIC_E2B_SANDBOX_URL', 'PUBLIC_SANDBOX_URL'], + ['E2B_SANDBOX_URL', 'PUBLIC_SANDBOX_URL'], +] as const + describe('dashboard environment validation', () => { - it('accepts the public runtime names without requiring a legacy domain', () => { - expect( - appEnvSchema.safeParse({ - PUBLIC_E2B_DOMAIN: 'cluster.example', - PUBLIC_SANDBOX_URL: 'https://sandbox.cluster.example', - }).success - ).toBe(true) + it('requires the runtime domain even when a legacy domain is set', () => { + for (const value of [undefined, '', ' ']) { + const parsed = appEnvSchema.safeParse({ + PUBLIC_E2B_DOMAIN: value, + NEXT_PUBLIC_E2B_DOMAIN: 'legacy.example', + }) + expect(parsed.success).toBe(false) + if (!parsed.success) { + expect(parsed.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: ['PUBLIC_E2B_DOMAIN'] }), + ]) + ) + } + } + }) + + it('allows domain routing without API or sandbox overrides', () => { + expect(appEnvSchema.parse(domain)).toEqual(domain) }) - it('keeps legacy build configuration valid', () => { + it('normalizes runtime settings consistently', () => { expect( - appEnvSchema.safeParse({ - NEXT_PUBLIC_E2B_DOMAIN: 'cluster.example', - E2B_SANDBOX_URL: 'https://sandbox.cluster.example', - }).success - ).toBe(true) + appEnvSchema.parse({ + PUBLIC_E2B_DOMAIN: ' cluster.example ', + PUBLIC_SANDBOX_URL: ' https://sandbox.cluster.example ', + E2B_INFRA_API_URL: ' http://127.0.0.1:3000 ', + E2B_DASHBOARD_API_URL: ' ', + DASHBOARD_COOKIE_SECURE: ' FALSE ', + OTEL_EXPORTER_OTLP_ENDPOINT: '', + }) + ).toMatchObject({ + ...domain, + PUBLIC_SANDBOX_URL: 'https://sandbox.cluster.example', + E2B_INFRA_API_URL: 'http://127.0.0.1:3000', + E2B_DASHBOARD_API_URL: undefined, + DASHBOARD_COOKIE_SECURE: 'false', + OTEL_EXPORTER_OTLP_ENDPOINT: undefined, + }) + }) + + it.each( + deprecatedVariables + )('reports how to migrate %s', (key, replacement) => { + const parsed = appEnvSchema.safeParse({ + ...domain, + [key]: 'https://old.example', + }) + expect(parsed.success).toBe(false) + if (!parsed.success) { + expect(parsed.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: [key], + message: expect.stringContaining(replacement), + }), + ]) + ) + } + }) + + it.each([ + 'PUBLIC_SANDBOX_URL', + 'E2B_INFRA_API_URL', + 'E2B_DASHBOARD_API_URL', + ])('requires an HTTP(S) URL for %s', (key) => { + for (const value of ['localhost:3002', 'ftp://sandbox.example']) { + const parsed = appEnvSchema.safeParse({ ...domain, [key]: value }) + expect(parsed.success).toBe(false) + if (!parsed.success) expect(parsed.error.issues[0].path).toEqual([key]) + } }) - it('requires a nonempty domain through either name', () => { - expect(appEnvSchema.safeParse({}).success).toBe(false) + it('rejects an invalid cookie flag', () => { expect( - appEnvSchema.safeParse({ - PUBLIC_E2B_DOMAIN: ' ', - NEXT_PUBLIC_E2B_DOMAIN: '', - }).success + appEnvSchema.safeParse({ ...domain, DASHBOARD_COOKIE_SECURE: 'off' }) + .success ).toBe(false) }) }) diff --git a/tests/unit/runtime-config.test.ts b/tests/unit/runtime-config.test.ts index 3dba1bf37..40d4810f2 100644 --- a/tests/unit/runtime-config.test.ts +++ b/tests/unit/runtime-config.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { resolveBrowserRuntimeConfig, resolveDashboardApiUrl, @@ -7,48 +7,29 @@ import { resolveSandboxUrl, } from '@/core/server/runtime-config' -const MANAGED_KEYS = [ - 'PUBLIC_E2B_DOMAIN', - 'PUBLIC_SANDBOX_URL', - 'E2B_API_KEY', - '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 - -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' + for (const key of [ + 'PUBLIC_SANDBOX_URL', + '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', + ]) + vi.stubEnv(key, undefined) + vi.stubEnv('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 - } - } + vi.unstubAllEnvs() }) -describe('resolveE2BDomain', () => { - it('falls back to the legacy build-time domain', () => { - expect(resolveE2BDomain()).toBe('example.dev') - }) - - it('reads the public domain at runtime for both API defaults', () => { +describe('runtime configuration', () => { + it('reads the public domain at runtime for the browser and both API defaults', () => { for (const domain of ['first.example', 'second.example']) { - process.env.PUBLIC_E2B_DOMAIN = ` ${domain} ` + vi.stubEnv('PUBLIC_E2B_DOMAIN', ` ${domain} `) expect(resolveE2BDomain()).toBe(domain) expect(resolveInfraApiUrl()).toBe(`https://api.${domain}`) expect(resolveDashboardApiUrl()).toBe(`https://dashboard-api.${domain}`) @@ -59,198 +40,54 @@ describe('resolveE2BDomain', () => { } }) - it('ignores an empty public domain', () => { - process.env.PUBLIC_E2B_DOMAIN = ' ' - expect(resolveE2BDomain()).toBe('example.dev') - }) -}) - -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('requires a runtime domain instead of using the legacy build value', () => { + vi.stubEnv('PUBLIC_E2B_DOMAIN', undefined) + vi.stubEnv('NEXT_PUBLIC_E2B_DOMAIN', 'build.example') + expect(() => resolveE2BDomain()).toThrow(/PUBLIC_E2B_DOMAIN/) }) - 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.each([ + ['E2B_INFRA_API_URL', resolveInfraApiUrl, 'https://api.example.dev'], + [ + 'E2B_DASHBOARD_API_URL', + resolveDashboardApiUrl, + 'https://dashboard-api.example.dev', + ], + ['PUBLIC_SANDBOX_URL', resolveSandboxUrl, undefined], + ] as const)('uses the runtime override for %s and its default when blank', (key, resolve, fallback) => { + vi.stubEnv(key, ' http://127.0.0.1:3002 ') + expect(resolve()).toBe('http://127.0.0.1:3002') + vi.stubEnv(key, ' ') + expect(resolve()).toBe(fallback) + vi.stubEnv(key, 'ftp://sandbox.example') + expect(() => resolve()).toThrow(key) }) - 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') - }) -}) - -describe('resolveSandboxUrl', () => { - it('prefers the public sandbox URL over both legacy aliases', () => { - process.env.PUBLIC_SANDBOX_URL = ' https://sandbox.runtime.example ' - process.env.E2B_SANDBOX_URL = 'https://sandbox.old.example' - process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'https://sandbox.build.example' - - expect(resolveSandboxUrl()).toBe('https://sandbox.runtime.example') - }) - - it('falls back to the runtime alias when the public value is blank', () => { - process.env.PUBLIC_SANDBOX_URL = ' ' - process.env.E2B_SANDBOX_URL = 'https://sandbox.old.example' - - expect(resolveSandboxUrl()).toBe('https://sandbox.old.example') - }) - - it('rejects an invalid public URL instead of falling back silently', () => { - process.env.PUBLIC_SANDBOX_URL = 'sandbox.runtime.example:3002' - process.env.E2B_SANDBOX_URL = 'https://sandbox.old.example' - + it('rejects the deprecated SDK alias before it can select a different server destination', () => { + vi.stubEnv('E2B_SANDBOX_URL', 'https://old.example') expect(() => resolveSandboxUrl()).toThrow(/PUBLIC_SANDBOX_URL/) + expect(() => resolveBrowserRuntimeConfig()).toThrow(/PUBLIC_SANDBOX_URL/) }) - 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', () => { it('exposes only the domain and sandbox URL, with no server endpoints or credentials', () => { - process.env.PUBLIC_E2B_DOMAIN = 'runtime.example' - process.env.PUBLIC_SANDBOX_URL = 'https://sandbox.runtime.example' - process.env.E2B_INFRA_API_URL = 'http://infra-api.internal:3000' - process.env.E2B_DASHBOARD_API_URL = 'http://dashboard-api.internal:3010' - process.env.E2B_API_KEY = 'e2b_test_private_key' - + vi.stubEnv('PUBLIC_E2B_DOMAIN', 'runtime.example') + vi.stubEnv('PUBLIC_SANDBOX_URL', 'https://sandbox.runtime.example') + vi.stubEnv('E2B_INFRA_API_URL', 'http://infra-api.internal:3000') + vi.stubEnv('E2B_DASHBOARD_API_URL', 'http://dashboard-api.internal:3010') + vi.stubEnv('E2B_API_KEY', 'e2b_test_private_key') expect(resolveBrowserRuntimeConfig()).toEqual({ domain: 'runtime.example', sandboxUrl: 'https://sandbox.runtime.example', }) + expect(resolveBrowserRuntimeConfig().sandboxUrl).toBe(resolveSandboxUrl()) }) - it('keeps SDK routing when API overrides are set without a sandbox URL', () => { - process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' - + it('keeps SDK domain routing when API overrides are set without a sandbox URL', () => { + vi.stubEnv('E2B_INFRA_API_URL', 'http://127.0.0.1:3000') + expect(resolveSandboxUrl()).toBeUndefined() expect(resolveBrowserRuntimeConfig()).toEqual({ domain: 'example.dev', sandboxUrl: null, }) }) - - it('uses the same configured sandbox URL for browser and server consumers', () => { - process.env.PUBLIC_SANDBOX_URL = 'https://sandbox.example.dev' - - expect(resolveBrowserRuntimeConfig().sandboxUrl).toBe(resolveSandboxUrl()) - }) - - it('passes no domain when none is configured', () => { - delete process.env.NEXT_PUBLIC_E2B_DOMAIN - - expect(resolveBrowserRuntimeConfig().domain).toBeNull() - }) -}) - -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') - }) - - 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/) - }) }) diff --git a/tests/unit/sandbox-router-api-url.test.ts b/tests/unit/sandbox-router-api-url.test.ts index 293fc33d7..55f7b886d 100644 --- a/tests/unit/sandbox-router-api-url.test.ts +++ b/tests/unit/sandbox-router-api-url.test.ts @@ -77,6 +77,7 @@ beforeEach(() => { delete process.env[key] } process.env.E2B_INFRA_API_URL = RUNTIME_API_URL + process.env.PUBLIC_E2B_DOMAIN = 'example.dev' authMock.getApiKey.mockResolvedValue('e2b_test_api_key') sdkMock.connect.mockResolvedValue({ @@ -152,23 +153,20 @@ describe('sandbox router control-plane API URL', () => { ) }) - it('falls back to the NEXT_PUBLIC value when no runtime URL is set', async () => { + it('uses the domain-derived API URL when no override 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' }) + expect.objectContaining({ apiUrl: 'https://api.example.dev' }) ) }) }) 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' + process.env.PUBLIC_SANDBOX_URL = 'https://sandbox.internal.example' const c = await caller() await c.openTerminal({ template: 'base', sandboxId: 'sbxexisting' }) @@ -181,33 +179,6 @@ describe('sandbox router sandbox URL', () => { ) }) - 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' }) - ) - }) - describe.each([ undefined, 'https://sandbox.configured.example', @@ -252,7 +223,7 @@ describe('sandbox router sandbox URL', () => { }) it('prefers the explicit sandbox URL over the request host', async () => { - process.env.E2B_SANDBOX_URL = 'https://sandbox.internal.example' + process.env.PUBLIC_SANDBOX_URL = 'https://sandbox.internal.example' const c = await requestCaller() await c.killTerminalPty({ sandboxId: 'sbxexisting', pid: 42 }) @@ -265,9 +236,7 @@ describe('sandbox router sandbox URL', () => { ) }) - // 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 () => { + it('passes no sandbox URL when only the domain is configured', async () => { delete process.env.E2B_INFRA_API_URL const c = await requestCaller() @@ -294,7 +263,6 @@ describe('sandbox router public runtime settings', () => { it('uses the same public domain and sandbox URL for all SDK operations', async () => { process.env.PUBLIC_E2B_DOMAIN = 'runtime.example' process.env.PUBLIC_SANDBOX_URL = 'https://sandbox.runtime.example' - process.env.E2B_SANDBOX_URL = 'https://sandbox.old.example' delete process.env.E2B_INFRA_API_URL const c = await requestCaller()