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/.env.example b/.env.example index a60fc5946..f2b0daba1 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,13 +17,30 @@ 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 -### Optional sandbox traffic base URL for local development proxies. -# NEXT_PUBLIC_E2B_SANDBOX_URL=http://sandbox.lvh.me:3002 +### 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 + +### Base URL the BROWSER uses to reach sandboxes (terminal and filesystem +### 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, 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. +# DASHBOARD_COOKIE_SECURE=false ### OpenTelemetry (disabled unless the endpoint is set). # OTEL_SERVICE_NAME=e2b-dashboard diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml new file mode 100644 index 000000000..a21004d3f --- /dev/null +++ b/.github/workflows/container.yml @@ -0,0 +1,60 @@ +# 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 + - src/instrumentation.ts + - src/core/server/runtime-config.ts + - src/configs/cookies.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 + - src/instrumentation.ts + - src/core/server/runtime-config.ts + - src/configs/cookies.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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..b462faec7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,61 @@ +# 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 . . + +# 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 + +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..934d1e9bf 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,54 @@ 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 | +|---|---|---| +| `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` → 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 +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`. +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. + +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. `Host`, `X-Forwarded-Host`, and +`X-Forwarded-Proto` never determine sandbox destinations. + ## Features - **Sandboxes**: paginated live list, per-sandbox monitoring (CPU/memory/disk), logs, filesystem inspector, and an in-browser terminal @@ -57,8 +105,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 @@ -75,6 +123,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 -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. +- `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 + 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..878d52073 --- /dev/null +++ b/scripts/container-smoke.sh @@ -0,0 +1,95 @@ +#!/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-$$" +INVALID_CONTAINER="${CONTAINER}-invalid" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +cleanup() { + docker rm -f "${CONTAINER}" "${INVALID_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 + +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 d27445499..121020bfb 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -4,6 +4,8 @@ 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() 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/configs/cookies.ts b/src/configs/cookies.ts index cf285c598..1b4eb32c5 100644 --- a/src/configs/cookies.ts +++ b/src/configs/cookies.ts @@ -17,11 +17,34 @@ 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') + } + + 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/core/server/api/routers/sandbox.ts b/src/core/server/api/routers/sandbox.ts index caa02644d..3f302b12a 100644 --- a/src/core/server/api/routers/sandbox.ts +++ b/src/core/server/api/routers/sandbox.ts @@ -13,6 +13,11 @@ 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 { + resolveE2BDomain, + 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' @@ -229,9 +234,9 @@ export const sandboxRouter = createTRPCRouter({ } const connectionOpts = { - apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL, - domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + apiUrl: resolveInfraApiUrl(), + domain: resolveE2BDomain(), + sandboxUrl: resolveSandboxUrl(), apiKey, } @@ -317,9 +322,9 @@ export const sandboxRouter = createTRPCRouter({ const { apiKey } = ctx const connectionOpts = { - apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL, - domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + apiUrl: resolveInfraApiUrl(), + domain: resolveE2BDomain(), + sandboxUrl: resolveSandboxUrl(), apiKey, } @@ -373,9 +378,9 @@ export const sandboxRouter = createTRPCRouter({ const { apiKey } = ctx const connectionOpts = { - apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL, - domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + apiUrl: resolveInfraApiUrl(), + domain: resolveE2BDomain(), + sandboxUrl: resolveSandboxUrl(), apiKey, } @@ -412,9 +417,9 @@ export const sandboxRouter = createTRPCRouter({ ) .mutation(async ({ ctx, input }) => { const sandbox = await Sandbox.connect(input.sandboxId, { - apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL, - domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + apiUrl: resolveInfraApiUrl(), + domain: resolveE2BDomain(), + 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 new file mode 100644 index 000000000..5a3933b37 --- /dev/null +++ b/src/core/server/runtime-config.ts @@ -0,0 +1,124 @@ +/** + * 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' + +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 resolveInfraApiUrl(): string { + const configured = configuredInfraApiUrl() + + return configured + ? assertHttpUrl(configured) + : `https://api.${resolveE2BDomain()}` +} + +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.${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 +} + +/** Only operator configuration may select a sandbox destination. */ +export function resolveBrowserRuntimeConfig(): BrowserRuntimeConfig { + return { + domain: resolveE2BDomain() ?? null, + sandboxUrl: resolveSandboxUrl() ?? null, + } +} + +export function validateRuntimeConfig(): void { + resolveInfraApiUrl() + resolveDashboardApiUrl() + resolveSandboxUrl() + isSecureCookie() +} 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/core/shared/runtime-config.ts b/src/core/shared/runtime-config.ts new file mode 100644 index 000000000..419abdfc3 --- /dev/null +++ b/src/core/shared/runtime-config.ts @@ -0,0 +1,4 @@ +export interface BrowserRuntimeConfig { + domain: string | null + sandboxUrl: string | 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 14c589ba0..d1abe523d 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 { 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' @@ -63,6 +64,7 @@ export default function SandboxInspectProvider({ rootPath, }: SandboxInspectProviderProps) { const trpcClient = useTRPCClient() + const runtimeConfig = useClientConfig() const { sandboxInfo, isRunning, refetchSandboxInfo } = useSandboxContext() const sandboxId = sandboxInfo?.sandboxID @@ -181,8 +183,8 @@ export default function SandboxInspectProvider({ const sandbox = createEnvdSandbox({ ...creds, - domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + 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 e7815badb..2b1ffc827 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 type { BrowserRuntimeConfig } from '@/core/shared/runtime-config' import type { TRPCRouterOutputs } from '@/trpc/client' import { clearStoredTerminalSession, @@ -28,6 +29,7 @@ interface OpenTerminalSandboxOptions { forceNewSandbox?: boolean onStatus: (message: string) => void openTerminal: OpenTerminalMutation + runtimeConfig: BrowserRuntimeConfig requestTimeoutMs?: number shouldStoreSession?: boolean sandboxId?: string @@ -38,6 +40,7 @@ export async function openTerminalSandbox({ forceNewSandbox = false, onStatus, openTerminal, + runtimeConfig, requestTimeoutMs, shouldStoreSession, sandboxId, @@ -47,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' ) @@ -70,6 +74,7 @@ export async function openTerminalSandbox({ try { sandbox = await acquireTerminalSandbox( openTerminal, + runtimeConfig, { template, sandboxId: storedTerminalSession.sandboxId, @@ -83,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' ) @@ -91,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' ) @@ -110,6 +117,7 @@ export async function openTerminalSandbox({ async function acquireTerminalSandbox( openTerminal: OpenTerminalMutation, + runtimeConfig: BrowserRuntimeConfig, input: OpenTerminalMutationInput, fallbackMessage: string ): Promise { @@ -123,7 +131,7 @@ async function acquireTerminalSandbox( return createEnvdSandbox({ ...connection, - domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + domain: runtimeConfig.domain ?? undefined, + sandboxUrl: runtimeConfig.sandboxUrl ?? undefined, }) } 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/src/lib/env.ts b/src/lib/env.ts index 7ba741251..b37ca5450 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -5,6 +5,21 @@ 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(), + 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(), + OTEL_SERVICE_NAME: z.string().optional(), OTEL_EXPORTER_OTLP_ENDPOINT: z.url().optional(), OTEL_EXPORTER_OTLP_PROTOCOL: z @@ -32,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']) @@ -45,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/runtime-config-layout.test.tsx b/tests/integration/runtime-config-layout.test.tsx new file mode 100644 index 000000000..3b3774713 --- /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('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']) { + 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|' + ) + } + }) +}) 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 new file mode 100644 index 000000000..833050289 --- /dev/null +++ b/tests/unit/cookie-options.test.ts @@ -0,0 +1,104 @@ +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('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') + + await expect(loadApiKeyCookieOptions()).resolves.toMatchObject({ + 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/dashboard-terminal.test.ts b/tests/unit/dashboard-terminal.test.ts index 29a5748c0..68635141e 100644 --- a/tests/unit/dashboard-terminal.test.ts +++ b/tests/unit/dashboard-terminal.test.ts @@ -20,6 +20,11 @@ vi.mock('@/core/shared/create-envd-sandbox', () => ({ createEnvdSandbox: mockCreateEnvdSandbox, })) +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 // mocking a module. @@ -261,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', @@ -281,8 +303,8 @@ describe('dashboard terminal helpers', () => { sandboxDomain: 'sandbox.example.com', envdVersion: '0.2.0', envdAccessToken: 'envd-token', - domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + domain: runtimeConfig.domain, + sandboxUrl: 'http://host.example:3002', }) expect(readStoredTerminalSession()).toBeNull() expect(statuses).toEqual([ @@ -290,6 +312,30 @@ 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({ + runtimeConfig, + 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', @@ -299,6 +345,7 @@ describe('dashboard terminal helpers', () => { }) await openTerminalSandbox({ + runtimeConfig, onStatus: vi.fn(), openTerminal: mockOpenTerminal, sandboxId: 'insecure-sandbox', @@ -310,13 +357,14 @@ describe('dashboard terminal helpers', () => { sandboxDomain: 'sandbox.example.com', envdVersion: '0.2.0', envdAccessToken: undefined, - domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + 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', @@ -338,6 +386,7 @@ describe('dashboard terminal helpers', () => { }) await openTerminalSandbox({ + runtimeConfig, onStatus: vi.fn(), openTerminal: mockOpenTerminal, template: 'base', @@ -357,6 +406,7 @@ describe('dashboard terminal helpers', () => { }) await openTerminalSandbox({ + runtimeConfig, forceNewSandbox: true, onStatus: vi.fn(), openTerminal: mockOpenTerminal, @@ -383,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.test.ts b/tests/unit/runtime-config.test.ts new file mode 100644 index 000000000..3dba1bf37 --- /dev/null +++ b/tests/unit/runtime-config.test.ts @@ -0,0 +1,256 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + resolveBrowserRuntimeConfig, + resolveDashboardApiUrl, + resolveE2BDomain, + resolveInfraApiUrl, + 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' +}) + +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('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()).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') + }) + + 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') + }) +}) + +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() + }) + + 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' + + expect(resolveBrowserRuntimeConfig()).toEqual({ + domain: 'runtime.example', + sandboxUrl: 'https://sandbox.runtime.example', + }) + }) + + 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()).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 new file mode 100644 index 000000000..293fc33d7 --- /dev/null +++ b/tests/unit/sandbox-router-api-url.test.ts @@ -0,0 +1,319 @@ +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) + +const REQUEST_HOST = 'dash.example:3001' +const REQUEST_URL = `http://${REQUEST_HOST}/api/trpc/sandbox.killTerminalPty` + +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 = [ + 'PUBLIC_E2B_DOMAIN', + 'PUBLIC_SANDBOX_URL', + '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 }) + +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' }) + ) + }) +}) + +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' }) + ) + }) + + 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 () => { + 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' }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + expect.objectContaining({ sandboxUrl: undefined }) + ) + }) +}) + +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) + }) +})