feat(container): self-hostable container image with runtime configuration - #528
feat(container): self-hostable container image with runtime configuration#528svalleru wants to merge 7 commits into
Conversation
…config trade-offs
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.
|
Thank you for your pull request and welcome to our community. We could not parse the GitHub identity of the following contributors: Independence Check.
|
| */ | ||
| 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 | ||
| } | ||
|
|
||
| /** |
There was a problem hiding this comment.
🟡 (optional) resolveSandboxUrl()/resolveServerSandboxUrl() validate E2B_SANDBOX_URL/NEXT_PUBLIC_E2B_SANDBOX_URL with assertHttpUrl, but unlike resolveInfraApiUrl/resolveDashboardApiUrl (called eagerly at module scope in api.ts), this resolver is only invoked lazily inside sandbox.ts's openTerminal/killTerminalPty/resume/pause and /api/config. A malformed value passes container startup and container-smoke.sh's checks, then throws on every terminal action a user actually takes, contradicting the PR's own "fails at startup by design" claim. Fix: validate E2B_SANDBOX_URL/NEXT_PUBLIC_E2B_SANDBOX_URL eagerly too (e.g. call resolveSandboxUrl() at module scope alongside the other two resolvers) so misconfiguration is caught before serving traffic.
Extended reasoning...
sandbox.ts builds connectionOpts (with sandboxUrl: resolveServerSandboxUrl(...)) before the surrounding try block at 4 call sites (openTerminal ~238, killTerminalPty ~421, resume ~326, pause ~382). If E2B_SANDBOX_URL (or its NEXT_PUBLIC_ fallback) is set to a scheme-less or otherwise malformed value, assertHttpUrl throws inside resolveSandboxUrl, uncaught by the local try/catch, and is only ever exercised once a user opens/resumes/pauses/kills a terminal — not at container start, and not by scripts/container-smoke.sh, which never opens a terminal. Every such action then fails with an opaque tRPC INTERNAL_SERVER_ERROR in production, well after the operator believed the fail-fast env check had validated the deployment.
Verification: nit. The facts hold. resolveSandboxUrl()/resolveServerSandboxUrl() are invoked only lazily — in sandbox.ts procedures (openTerminal:238, resume:326, pause:382, killTerminalPty:421) and /api/config (route.ts:9) — never at module scope. api.ts:11,13 calls only resolveInfraApiUrl/resolveDashboardApiUrl eagerly, so only those two fail-fast on server import; the module comment… | nit. Mechanism is…
| function isSecureCookie(): boolean { | ||
| const configured: string | undefined = | ||
| process.env.DASHBOARD_COOKIE_SECURE?.trim().toLowerCase() | ||
|
|
||
| if (configured !== undefined && configured !== '') { | ||
| return configured !== 'false' |
There was a problem hiding this comment.
🟡 (optional) isSecureCookie() only treats the exact string 'false' as disabling Secure; any other DASHBOARD_COOKIE_SECURE value (e.g. '0', 'no', 'off', a typo) silently falls through to secure=true, silently reproducing the plain-http login loop this env var exists to fix, with no error since the container skips the build-time env schema check. Fix: parse the value against a small explicit set of falsy tokens (or reuse the same enum the build-time schema uses) and fail loudly (throw, like assertHttpUrl does for the URL vars) on any unrecognized value instead of defaulting to secure=true.
Extended reasoning...
A self-hoster running the prebuilt container sets DASHBOARD_COOKIE_SECURE=0 (a very common boolean convention) on a plain-http LAN install. src/lib/env.ts's z.enum(['true','false']) would reject '0' in dev/build, but per the module's own comment 'a prebuilt image starts without the env check', so nothing validates it at runtime. isSecureCookie() lowercases/trims to '0', which is not '===' 'false', so it returns true. BASE_COOKIE_OPTIONS.secure becomes true, the browser drops the httpOnly e2b_api_key cookie on the http origin, and the key form redirect-loops exactly as before this feature was added, with no log or error pointing at the misconfigured variable.
Verification: nit. Factually real and reachable: at src/configs/cookies.ts:35 return configured !== 'false' treats only the exact (trimmed, lowercased) token 'false' as disabling Secure, so DASHBOARD_COOKIE_SECURE='0' (or 'no'/'off') falls through to secure=true. The runtime value is unvalidated in the prebuilt container — src/lib/env.ts:17 (z.enum(['true','false'])) runs only at build time, confirmed by…
Summary
Dockerfilewithoutput: 'standalone'runsnode server.jsas a non-root user onPORT=3001; the server readsE2B_INFRA_API_URL,E2B_DASHBOARD_API_URL,E2B_SANDBOX_URLandDASHBOARD_COOKIE_SECUREat runtime; a newGET /api/confighands the browser{ infraApiUrl, sandboxUrl }per request, and the terminal and filesystem inspector read it instead of the build-inlined value.NEXT_PUBLIC_*at build time, so a prebuilt image could not be pointed at an install, and a plain-http LAN address needs the api key cookie withoutSecure. See the E2B Embed package.NEXT_PUBLIC_override, then the domain-derived value; with none set,/api/configreturnssandboxUrl: nulland the SDK derives the host as before.next startand platform builds are untouched.bun run buildfails inside the Linux image in Next's forked page-data workers, so the Dockerfile has three stages: Bun resolves the lockfile and runs the prebuild env check, Node builds, Node serves.src/core/shared/clients/api.tsnow imports a server-only module (all importers are server-side; the build confirms nothing reaches a client chunk).E2B_SANDBOX_URLdeliberately shares the name the E2B SDK reads for the same setting and is served to the browser verbatim, so it must be browser-reachable.Verification
305 unit and 7 integration tests pass (resolvers, the browser config fetch and its cache, forwarded-host handling, the cookie flag,
GET /api/config); lint 0 errors; knip andnext buildclean.The built server was probed both ways: with no
E2B_*set it returns{"infraApiUrl":"https://api.e2b.dev","sandboxUrl":null}; withE2B_INFRA_API_URLset it returns the request host on port 3002, trackingHostandX-Forwarded-Host.scripts/container-smoke.shpasses against the built image:GET /200,GET /sandboxes307 to the key form,GET /api/health503 with no dashboard-api reachable. The runtime stage carries no dev dependencies and runs asnode.A path-filtered
Containerworkflow runs the smoke script on PRs touching the files that can break the build.End-to-end on a fresh VM against an E2B Embed stack: key-form login, sandbox list, a terminal command and a filesystem read from a headless browser, all through the sandbox proxy's header routing. It found that the server-side terminal-kill call resolved no sandbox URL when
E2B_SANDBOX_URLis unset; the last commit makes the server resolve it the same way the browser does.Notes
/api/configis unauthenticated and carries no secret. Behind a reverse proxy, the proxy must setX-Forwarded-Host/X-Forwarded-Protoitself. A malformed explicit URL fails at startup by design, naming the variable.Containera required check; it is path-filtered.