diff --git a/apps/docs/content/docs/admin-api.mdx b/apps/docs/content/docs/admin-api.mdx index a88731588..b31aabdb9 100644 --- a/apps/docs/content/docs/admin-api.mdx +++ b/apps/docs/content/docs/admin-api.mdx @@ -95,7 +95,11 @@ require the observed `expected_generation`, including zero at first setup. Reque Response `resources.allocations` and `resources.pending` are cleanup counts, not CPU, memory or disk settings. E2B accepts a write-only key and exact template build instead of a node Runtime release, and provisions without a node installation. E2B -may omit `resources` to adopt the validated build's CPU and memory; responses show +may omit `resources` to adopt the validated build's CPU and memory. An E2B-compatible +service may also supply paired `e2b.api_url` and `e2b.domain`; omitted selectors +use official E2B. Responses expose these addresses but never the key, and changing +them requires the same drained maintenance transition as changing the template. +Responses show the build as read at selection time in `e2b.template_build`. Microsandbox responses return its idle `suspension` policy; other providers return null. diff --git a/apps/docs/content/docs/api-reference/core/sandbox-manager.mdx b/apps/docs/content/docs/api-reference/core/sandbox-manager.mdx index b391590f8..de80e6d5a 100644 --- a/apps/docs/content/docs/api-reference/core/sandbox-manager.mdx +++ b/apps/docs/content/docs/api-reference/core/sandbox-manager.mdx @@ -46,6 +46,14 @@ _openapi: Restores admission but never restores Sessions already archived. With no reset running this is an idempotent read, provided the generation still matches. + - content: >- + Core key only. Uses a transient E2B credential and endpoint through + the pinned SDK helper; returns safe template metadata. Does not save + the credential or allocate compute. + - content: >- + Core key only. Reads one template through the pinned SDK helper with a + transient E2B credential. Returns ready builds only, without + allocating compute. - content: >- Core key only. Does not grant project resource access. Responses contain only explicit safe fields. @@ -68,4 +76,4 @@ _openapi: {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - \ No newline at end of file + \ No newline at end of file diff --git a/apps/docs/content/docs/configure.mdx b/apps/docs/content/docs/configure.mdx index b11799aca..40b88980e 100644 --- a/apps/docs/content/docs/configure.mdx +++ b/apps/docs/content/docs/configure.mdx @@ -160,7 +160,11 @@ file. The database owns the saved values. E2B takes no `runtime`, and Web sends no resources for it: Core adopts the CPU and memory of the ready template build `template-id:build-uuid`, and supplied values must -match it. Docker has no separate disk quota. +match it. For an E2B-compatible service, enter both its HTTPS API origin and +sandbox data-plane domain in the setup wizard. Leaving both blank uses official +E2B. The API host must be the data-plane domain or one of its subdomains. +Changing either address requires draining the deployment in maintenance. +Docker has no separate disk quota. Changing backend type or E2B team requires an explicit reset and a new setup. Same-team E2B template, resource and key changes apply online through the Core API: diff --git a/apps/docs/content/docs/install-options.mdx b/apps/docs/content/docs/install-options.mdx index 3a6239302..a59b387d9 100644 --- a/apps/docs/content/docs/install-options.mdx +++ b/apps/docs/content/docs/install-options.mdx @@ -61,6 +61,8 @@ create another process configuration file. | `--accept-docker-risks` | Accept Docker's weaker isolation without an interactive prompt | | `--e2b-api-key-file FILE` | With E2B: absolute path to a private key file, no group/other access and at most 4 KiB | | `--e2b-template ID:BUILD` | With E2B: ready template build as `template-id:build-uuid` | +| `--e2b-api-url URL` | With E2B: compatible service HTTPS API origin; use together with `--e2b-domain` | +| `--e2b-domain DOMAIN` | With E2B: sandbox data-plane DNS suffix; use together with `--e2b-api-url` | | `--core-key-file FILE` | With Web-only: absolute path to a private file containing the existing Core key, at least 32 characters | See [deployment modes](/install#modes), [native Core](/install#native-core) and diff --git a/apps/docs/content/docs/public-api.mdx b/apps/docs/content/docs/public-api.mdx index 22d07479a..9dfe73f02 100644 --- a/apps/docs/content/docs/public-api.mdx +++ b/apps/docs/content/docs/public-api.mdx @@ -56,7 +56,7 @@ with the Core key. | `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f45a4198aca79bf252412c55cad3647f5d704515/contracts/agents-api/environment-executor-credentials.md) | | `installation` | Public URL, API base URL, source commit, the installer's process settings and what is bound to the public URL; available before any deployment | [Installation](https://github.com/MiniMax-AI/parsar-core/blob/f45a4198aca79bf252412c55cad3647f5d704515/contracts/agents-api/installation.md) | | `metrics` | Core's own process metrics | [Core metrics](https://github.com/MiniMax-AI/parsar-core/blob/f45a4198aca79bf252412c55cad3647f5d704515/contracts/agents-api/core-metrics.md) | -| `sandbox/deployment[/reset]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/f45a4198aca79bf252412c55cad3647f5d704515/contracts/agents-api/sandbox-deployment.md), [node operations](https://github.com/MiniMax-AI/parsar-core/blob/f45a4198aca79bf252412c55cad3647f5d704515/services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/f45a4198aca79bf252412c55cad3647f5d704515/contracts/agents-api/node-host-history.md) | +| `sandbox/deployment[/reset]`, `sandbox/e2b/templates[/{template_id}/builds]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Core Web server or operator scripts with the Core key: sandbox deployment, read-only E2B template discovery, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/f45a4198aca79bf252412c55cad3647f5d704515/contracts/agents-api/sandbox-deployment.md), [node operations](https://github.com/MiniMax-AI/parsar-core/blob/f45a4198aca79bf252412c55cad3647f5d704515/services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/f45a4198aca79bf252412c55cad3647f5d704515/contracts/agents-api/node-host-history.md) | | `harnesses`, `harnesses/{harness}/model-configuration` | Supported harnesses and each harness's deployment default model configuration (write-only provider key) | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/f45a4198aca79bf252412c55cad3647f5d704515/contracts/agents-api/model-execution.md#deployment-defaults) | ## Machine connection API diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index 1c60f2909..e40a09c45 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -5,11 +5,11 @@ "docs/design-principles.md": "a33acd6e7bc80105a5c934d0be214c0cff59103e96c098f5b09179c86156c01d", "docs/web/architecture.md": "80bdb8053c06c2b2030b193aae17d5434545f9c72af50e9d3df3ade522fe25ed", "docs/getting-started/install.md": "a437b90c2cc4755e0fd760066837bfec96e9aa4a4a4bc5bc865b8ad02162c197", - "docs/getting-started/install-options.md": "aac219f367619036aa01573a295802fb8f0418e3d6db931efcfc88f17cf7734c", - "docs/configuration.md": "ca7aa789f6a2a63a493ecf6ebfc083adea94218215e781980cd35ae2aac42298", + "docs/getting-started/install-options.md": "c806f8f229edccc8bcc4a853976e330cfee6025c9d3d00f6960fe2ee97a8c1d6", + "docs/configuration.md": "77e24331a51f0053e968f8b18daee7d0517ec635842e0b01fca4d93c0a26d734", "docs/web/core-connection.md": "46f86520e70bf41079708e2c398655aa9087ad9488c8e661d9a73035d11098c4", "docs/getting-started/quickstart.md": "238b4de1137edb8c2998b38f4525fea345a19fd4de7a455aa9c4c9e4fc3c9b36", - "docs/api/README.md": "628deeb777b7203b42e77a253e91af0f05ca690cd10cb1e08b53b64d62cee923", + "docs/api/README.md": "e9af18bf54c0625ab63a01d2cba4337ece555d570bddc6471767e406501a5bc1", "contracts/agents-api/execution-tools.md": "e0b61f6c0c236c362186c5f6d0ad69a16dd8a8d9afabe1329a1fe22e5f4a71e9", "docs/api/public-agent-api.md": "5b04e5ec9311df851376f66fc1584dd48ca123cd8c26ca63277c10b9172e9955", "contracts/agents-api/environments.md": "4c4b9423b22919d6aa6bd98d96422e1a8d610d3442d4cf7731a575bd31fd43f0", @@ -18,7 +18,7 @@ "docs/self-hosted-native.md": "8fc6beccf28ace62e126faa03a923c669298b35213159c5d67b9ac3c7272741b", "apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", "docs/web/README.md": "9fce295f6f6ff04acefe338679c1e54723e123dd0027a778202adfa9ab95b6c4", - "docs/api/web-management.md": "aaae99269518e1fffe02f572d04fdbeb8da5342c309b5016ac31c10a6604dd8e", + "docs/api/web-management.md": "ec9a583c0a94845082ec31c7220ddcfb9aff7147c63fb36fa4c87463570f1b8c", "contracts/agents-api/runtime-observability-api.md": "cd46e777fe716a7e76374f4655c96dfbad9d8be1bfca6203ed6118ab87874c2b", "docs/getting-started/operations.md": "2cba15fe56936cbc51e2ec8b8a888686273b21f729711e45c14ca6e32758663d", "docs/user-guide.md": "a601c7f9df02c3bfe9c4ca3a43b91ab5721f3f8e904fccd116418dbdc1665a53", @@ -33,11 +33,11 @@ "content/docs/concepts.mdx": "ab0508c9d7c601695148dda6d3f4b709f9366bd4758ad92220f5839c84fcb64e", "content/docs/execution-model.mdx": "6f60226d4f90bc92defb14c5b419d707bb3e419baea781f76eb35c68d3c8ef64", "content/docs/install.mdx": "82f6b7015c2ddfeba16f53004eb6bbf9e5a5499fe97cbeee9b9244ac72ef694d", - "content/docs/install-options.mdx": "f21fc20bde39b80f4c6633c65891c73893e5b088b30dbdaed5611ff09656dc97", - "content/docs/configure.mdx": "09adaff6510efdd3e24930f00ffef3e39438a858a91bd1e4fb17b344ce709ce2", + "content/docs/install-options.mdx": "3bbfa77af2c34f2b9f203079778b51ee9425624ca516c6392c859ee2add43591", + "content/docs/configure.mdx": "a4f96cf774494b104e3b1efe20d24cfef64d9a71284e547e36d93179472eeba7", "content/docs/bootstrap-projects-keys.mdx": "477d76731eb09c14b2c1b09582d595258c1a74cc5b193e92644a340db859e6bd", "content/docs/quickstart.mdx": "0ae6bbd5c488d39489aa2d83bd100e219b9ebb24b86d8dc1489089c197baa775", - "content/docs/public-api.mdx": "b2d7cbf9be5d17e9a1a05ddf2f984adad5c14e5703e16e0165619850d6d82131", + "content/docs/public-api.mdx": "7902c5b44fdec1c097b4809634a70c554392cb58cc71fd68d6825b707888281e", "content/docs/agents-and-tools.mdx": "9ffa5a5883847f2e4d3641bfd5975baf448fd371542797fd93204f69ea6df5e2", "content/docs/sessions.mdx": "b0e127550c76b26d7649503dcf956044f4236eb798430b1000e356b103b02f1c", "content/docs/environments-and-files.mdx": "2b8a0e0783032ac7e187361cab2225e9fed524f0ae9bacd3edf43804356cd389", @@ -46,7 +46,7 @@ "content/docs/self-hosted-native.mdx": "7697ea6535e56a4efbe31cf77bb7df5c0739dc0216eab53e83b5babf90ac1902", "public/images/source/apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", "content/docs/console.mdx": "356769e6017dbb78a9f42ee04d2d50c4bea6fa366a1bf69a062c04e549d63e0f", - "content/docs/admin-api.mdx": "d4da5f91b4df53f1ad57e7b71a986a2efc794756a92b76402aff96f480b8a47e", + "content/docs/admin-api.mdx": "d553f7712b22e2eb46bf7c84c2b7a1d67df51996e1533aca2451d24c2caf7628", "content/docs/observability.mdx": "595ab9f9918d04c058b64fa3a31d81913bbcbbc4d03138dca63a590979dde112", "content/docs/troubleshooting.mdx": "6447708d4d73f9b7793b00215ddcd000ba411c41606db8987c4d848a64b1cd87", "content/docs/user-guide.mdx": "97194cf016c237f604b7e626297790dc0ea8b570d4e1a866f7395f2813e0e607", diff --git a/apps/docs/openapi/core-api.yaml b/apps/docs/openapi/core-api.yaml index b14ea33a8..06ba7f788 100644 --- a/apps/docs/openapi/core-api.yaml +++ b/apps/docs/openapi/core-api.yaml @@ -3629,6 +3629,93 @@ paths: $ref: '#/components/schemas/store.SandboxResetRequest' description: Reset mode and current deployment generation required: true + /core/v1/sandbox/e2b/templates: + post: + description: Core key only. Uses a transient E2B credential and endpoint through the pinned SDK helper; returns safe template metadata. Does not save the credential or allocate compute. + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/api.SandboxE2BDiscoveryResult' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + '503': + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + security: + - DeploymentAdminAuth: [] + summary: List templates visible to an E2B credential + tags: + - Sandbox Manager + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/api.SandboxE2BDiscoveryInput' + description: Transient E2B connection + required: true + /core/v1/sandbox/e2b/templates/{template_id}/builds: + post: + description: Core key only. Reads one template through the pinned SDK helper with a transient E2B credential. Returns ready builds only, without allocating compute. + parameters: + - schema: + type: string + description: Template ID + in: path + name: template_id + required: true + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/api.SandboxE2BDiscoveryResult' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + '503': + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + security: + - DeploymentAdminAuth: [] + summary: List ready builds for an E2B template + tags: + - Sandbox Manager + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/api.SandboxE2BDiscoveryInput' + description: Transient E2B connection + required: true /core/v1/sandbox/enrollment-tokens: post: description: Core key only. Does not grant project resource access. Responses contain only explicit safe fields. @@ -4718,10 +4805,34 @@ components: required: - expected_generation type: object + api.SandboxE2BDiscoveryInput: + properties: + api_key: + type: string + api_url: + type: string + domain: + type: string + type: object + api.SandboxE2BDiscoveryResult: + properties: + builds: + items: + $ref: '#/components/schemas/e2b.ReadyBuild' + type: array + templates: + items: + $ref: '#/components/schemas/e2b.TemplateSummary' + type: array + type: object api.SandboxE2BInput: properties: api_key: type: string + api_url: + type: string + domain: + type: string template: type: string type: object @@ -5073,6 +5184,24 @@ components: service: $ref: '#/components/schemas/coremetrics.ServiceState' type: object + e2b.ReadyBuild: + properties: + cpus: + type: integer + id: + type: string + memory_mib: + type: integer + type: object + e2b.TemplateSummary: + properties: + id: + type: string + names: + items: + type: string + type: array + type: object sandbox.DeploymentSpec: properties: resources: @@ -5568,8 +5697,12 @@ components: type: object store.SandboxE2BView: properties: + api_url: + type: string credential_configured: type: boolean + domain: + type: string template: type: string template_build: diff --git a/apps/docs/openapi/sources.json b/apps/docs/openapi/sources.json index b095dd873..a88071dbf 100644 --- a/apps/docs/openapi/sources.json +++ b/apps/docs/openapi/sources.json @@ -1,7 +1,7 @@ { "sources": { "contracts/agents-api/openapi.yaml": "b706abf259542b7b9a833b566f20048412d3cbc27c4c4284ed6baf5edc605489", - "contracts/agents-api/core.openapi.yaml": "3074ec8de0e313ec116487e96f6b27ac1aa1597f95db4bb162c73ce4d1c184e2", + "contracts/agents-api/core.openapi.yaml": "03275522b804b9ac467e0703b5f45c128f4f5cbf13d766387fe85fa1e0592223", "contracts/agents-api/runtime.openapi.yaml": "505286d5eacf94a14f9527fcf4e7beb4fba4f792681be26ba966254cbf49b4aa" }, "outputs": { @@ -40,8 +40,8 @@ "content/docs/api-reference/core/skills.mdx": "ed899ade7b032b1732b3097d81419a8fdbb9ead0594fca9f2107da644b97031f", "content/docs/api-reference/core/vaults.mdx": "12d0aac96cd4a3472ef4eaa287f7945f78862017956e79bfc3ed0ef8e772e78a", "content/docs/api-reference/core/credentials.mdx": "27d586b9a8fd738b9947802f32c80458195370252886bc26abedccf2125ee981", - "content/docs/api-reference/core/sandbox-manager.mdx": "6be7c52e057365949f0247c18c710561d680847a955f78f1bbe57825d4d5f549", - "openapi/core-api.yaml": "a3eb0ad9a86144576ed625a9dc1abcba924c00c7b081bf193cfdf1ea5b58f68a", + "content/docs/api-reference/core/sandbox-manager.mdx": "45e65afffb25e5b6ee2ab0853d7a84dee164e0d44ea384675fbd7ffcbd6c1a58", + "openapi/core-api.yaml": "7f8e51e6b8373ae33e3069a78745ec9cfeb6dfa415e02a26db6a9feab99b668e", "content/docs/api-reference/core/meta.json": "d0aef1f54c4cfc30b4e1d974119ae1cdce1c87d5ecba723afbeb4599119482a7", "content/docs/api-reference/core/index.mdx": "57cf9ea947ef45e79575c25a0a3d4e5e856dde6d14698ccc2c51b86201c249e9", "content/docs/api-reference/machine/native-installation.mdx": "421a858685b79fdcf8d7eff308c5768d1b62385c3988c6089f380afe80aaed69", diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index 44b6af91c..4673fcfaa 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -33,6 +33,13 @@ export async function openConsole(page: Page, request: APIRequestContext, hash = await page.goto(`/#${hash}`); } +/** Selects the ready build returned by the E2B discovery fixture. */ +export async function selectFixtureE2BBuild(page: Page) { + await page.getByLabel("E2B API key").fill("fixture-private-key"); + await page.getByLabel("Template", { exact: true }).selectOption("template"); + await page.getByLabel("Template build").selectOption("template:94be54a1-138c-4f30-bc87-b13686272dbe"); +} + /** Makes the next matching write fail once with the given status. */ export async function failNext(request: APIRequestContext, failure: { method: string; path: string; status: number; code?: string; message?: string }) { await request.post(`${fixture}/__fixture/fail-next`, { data: failure }); diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 074d214b0..321d539e0 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -89,7 +89,7 @@ const templateBuild = { status: "ready", resources: { cpus: 2, memory_mib: 2048, // E2B runs sandboxes in its cloud: no nodes, only what Core holds there. function e2bDeployment() { - return { ...configuredDeployment(), provider: "e2b", mode: "direct", rollout: noNodeRollout(), resources: { allocations: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, e2b: { template: "oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", credential_configured: true, template_build: templateBuild } }; + return { ...configuredDeployment(), provider: "e2b", mode: "direct", rollout: noNodeRollout(), resources: { allocations: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, e2b: { template: "oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", api_url: "https://api.e2b.app", domain: "e2b.app", credential_configured: true, template_build: templateBuild } }; } function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "demo", address = "public", credentials = "configured", installers = true, artifacts = "docker,microsandbox") { @@ -324,6 +324,18 @@ function nodeDetail(node) { } async function sandboxRoute(request, response, path, url) { + const e2bTemplates = ["/e2b/templates", "/e2b/templates/template/builds"]; + if (e2bTemplates.includes(path)) { + if (request.method !== "POST") return error(response, 405, "Method not allowed."); + const input = await body(request); + const knownEndpoint = [ + ["https://sandbox.sandbase.ai", "sandbox.sandbase.ai"], + ["https://api.e2b.app", "e2b.app"], + ].some(([apiURL, domain]) => input.api_url === apiURL && input.domain === domain); + if (input.api_key !== "fixture-private-key" || !knownEndpoint) return error(response, 400, "Invalid E2B connection."); + if (path === "/e2b/templates") return send(response, 200, { templates: [{ id: "template", names: ["fixture-runtime"] }] }); + return send(response, 200, { builds: [{ id: "94be54a1-138c-4f30-bc87-b13686272dbe", cpus: 2, memory_mib: 2048 }] }); + } // Retired even for authenticated callers; never reinterpret maintenance as reset. if (path === "/deployment/maintenance") return error(response, 404, "Not found."); if (path === "/deployment/reset" && (request.method === "POST" || request.method === "DELETE")) { @@ -397,7 +409,7 @@ async function sandboxRoute(request, response, path, url) { resources: held, rollout: e2b ? { ...noNodeRollout(), previous_generation_sandboxes: held.allocations + held.pending } : nodeRollout(held.allocations + held.pending), specification, - ...(e2b ? { e2b: { template: input.e2b?.template ?? "", credential_configured: true, template_build: templateBuild } } : {}), + ...(e2b ? { e2b: { template: input.e2b?.template ?? "", api_url: input.e2b?.api_url ?? state.deployment.e2b?.api_url ?? "https://api.e2b.app", domain: input.e2b?.domain ?? state.deployment.e2b?.domain ?? "e2b.app", credential_configured: true, template_build: templateBuild } } : {}), suspension: input.provider === "microsandbox" ? { idle_seconds: 300, retention_seconds: 86400 } : null, }; return send(response, 200, state.deployment); diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 67f9a2115..a130e7120 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; -import { expectManagementBoundary, failNext, openConsole, resetFixture, setNode, writes } from "./console"; +import { expectManagementBoundary, failNext, openConsole, resetFixture, selectFixtureE2BBuild, setNode, writes } from "./console"; test.afterEach(async ({ request }) => expectManagementBoundary(request)); @@ -241,14 +241,67 @@ test("preselects microsandbox and asks once before switching to Docker", async ( }); test("saves E2B without opening Add node, as it has no machines", async ({ page, request }) => { + let submitted: Record | null = null; + page.on("request", (sent) => { + if (sent.method() === "POST" && sent.url().endsWith("/core/v1/sandbox/deployment")) submitted = sent.postDataJSON() as Record; + }); await openConsole(page, request, "system?id=sandbox", { sandbox: "none" }); await page.getByRole("button", { name: "E2B cloud" }).click(); - await page.getByLabel("E2B API key").fill("fixture-private-key"); - await page.getByLabel("Template build").fill("template:94be54a1-138c-4f30-bc87-b13686272dbe"); + await expect(page.getByLabel("E2B provider")).toHaveValue("sandbase"); + await expect(page.getByLabel("Sandbox API URL")).toHaveValue("https://sandbox.sandbase.ai"); + await expect(page.getByLabel("Sandbox data-plane domain")).toHaveValue("sandbox.sandbase.ai"); + const keyConsole = page.getByRole("link", { name: "Console → API Keys" }); + await expect(keyConsole.locator("..")).toHaveClass(/field-label-row/); + await expect(keyConsole).toHaveCSS("font-size", "11px"); + await expect(keyConsole).toHaveAttribute("href", "https://www.sandbase.ai/console/keys"); + await expect(keyConsole).toHaveAttribute("target", "_blank"); + await expect(keyConsole).toHaveAttribute("rel", "noopener noreferrer"); + await selectFixtureE2BBuild(page); await page.getByRole("button", { name: "Next" }).click(); await page.getByRole("button", { name: "Save configuration" }).click(); await expect(page.getByRole("heading", { name: "Sandbox configuration", level: 1 })).toBeVisible(); await expect(page.getByRole("dialog")).toHaveCount(0); + expect(submitted).toMatchObject({ provider: "e2b", e2b: { + api_key: "fixture-private-key", template: "template:94be54a1-138c-4f30-bc87-b13686272dbe", + api_url: "https://sandbox.sandbase.ai", domain: "sandbox.sandbase.ai", + } }); + expect(await writes(request)).toEqual([ + "POST /core/v1/sandbox/e2b/templates", + "POST /core/v1/sandbox/e2b/templates/template/builds", + "POST /core/v1/sandbox/deployment", + ]); +}); + +test("uses the official E2B preset and clears a selected build when the key changes", async ({ page, request }) => { + await openConsole(page, request, "system?id=sandbox", { sandbox: "none" }); + await page.getByRole("button", { name: "E2B cloud" }).click(); + await page.getByLabel("E2B provider").selectOption("official"); + await expect(page.getByLabel("Sandbox API URL")).toHaveValue("https://api.e2b.app"); + await expect(page.getByLabel("Sandbox data-plane domain")).toHaveValue("e2b.app"); + const keyConsole = page.getByRole("link", { name: "Console → API Keys" }); + await expect(keyConsole).toHaveAttribute("href", "https://e2b.dev/dashboard?tab=api-keys"); + await page.getByLabel("Sandbox API URL").fill("https://custom.e2b.app"); + await expect(keyConsole).toHaveCount(0); + await page.getByLabel("E2B provider").selectOption("custom"); + await expect(keyConsole).toHaveCount(0); + await page.getByLabel("E2B provider").selectOption("official"); + await selectFixtureE2BBuild(page); + await expect(page.getByRole("button", { name: "Next" })).toBeEnabled(); + await page.getByLabel("E2B API key").fill("changed-fixture-key"); + await expect(page.getByLabel("Template", { exact: true })).toHaveValue(""); + await expect(page.getByLabel("Template build")).toHaveValue(""); + await expect(page.getByRole("button", { name: "Next" })).toBeDisabled(); +}); + +test("shows the retained E2B build while a replacement key is checked", async ({ page, request }) => { + await openConsole(page, request, "system?id=sandbox", { sandbox: "e2b" }); + await page.getByRole("button", { name: "Change resources" }).click(); + const edit = page.getByRole("dialog", { name: "Change resources" }); + const saved = "oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b"; + await expect(edit.getByLabel("Template build")).toHaveValue(saved); + await edit.getByLabel("E2B API key").fill("replacement-fixture-key"); + await expect(edit.getByLabel("Template build")).toHaveValue(saved); + await expect(edit.getByRole("button", { name: "Next" })).toBeEnabled(); }); test("edits only the saved backend, preserving a custom size and Runtime", async ({ page, request }) => { diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts index 1875a1458..84570ed0f 100644 --- a/apps/web/e2e/public-url.spec.ts +++ b/apps/web/e2e/public-url.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; -import { expectManagementBoundary, openConsole, writes } from "./console"; +import { expectManagementBoundary, openConsole, selectFixtureE2BBuild, writes } from "./console"; test.afterEach(async ({ request }) => expectManagementBoundary(request)); @@ -25,8 +25,7 @@ test("sets up sandboxes with Core's address read-only, never sending it", async test("explains an E2B rejection in the wizard, with the file to edit and the command to apply it", async ({ page, request }) => { await openConsole(page, request, "system?id=sandbox", { sandbox: "none", installation: "local" }); await page.getByRole("button", { name: "E2B cloud" }).click(); - await page.getByLabel("E2B API key").fill("fixture-private-key"); - await page.getByLabel("Template build").fill("template:94be54a1-138c-4f30-bc87-b13686272dbe"); + await selectFixtureE2BBuild(page); await page.getByRole("button", { name: "Next" }).click(); const address = page.getByRole("definition").filter({ hasText: "http://127.0.0.1:8091" }); await expect(address).toContainText("Only the Core machine can reach this address"); diff --git a/apps/web/e2e/sandbox-generation.spec.ts b/apps/web/e2e/sandbox-generation.spec.ts index eafd8554b..7b6548de8 100644 --- a/apps/web/e2e/sandbox-generation.spec.ts +++ b/apps/web/e2e/sandbox-generation.spec.ts @@ -4,6 +4,7 @@ import type { SandboxAllocation, SandboxDeployment, SandboxNode } from "@agents- import { expectManagementBoundary, failNext, openConsole, setDeployment, setNode, writes } from "./console"; const deploymentPath = "/core/v1/sandbox/deployment"; +const templateDiscoveryPath = "/core/v1/sandbox/e2b/templates"; const rollout = (page: Page) => page.getByRole("region", { name: "Configuration rollout", exact: true }); const fact = (scope: Locator, label: string) => scope.locator("dt").filter({ hasText: new RegExp(`^${label}`) }).locator("..").locator("dd"); async function inspectRollout(page: Page, values: Record) { @@ -197,7 +198,13 @@ test("E2B omitted-key updates keep the saved key while explicit same-key replace const storage = await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } })); expect(storage).not.toContain(key); expect(await page.content()).not.toContain(key); - expect(await writes(request)).toEqual(Array(3).fill(`PUT ${deploymentPath}`)); + expect(await writes(request)).toEqual([ + `PUT ${deploymentPath}`, + `POST ${templateDiscoveryPath}`, + `PUT ${deploymentPath}`, + `POST ${templateDiscoveryPath}`, + `PUT ${deploymentPath}`, + ]); }); test("a different E2B team leaves the committed configuration intact and requires a deliberate reset", async ({ page, request }) => { @@ -214,15 +221,15 @@ test("a different E2B team leaves the committed configuration intact and require await expect(page.getByRole("button", { name: "Keep saved key", exact: true })).toBeVisible(); await expect(page.getByRole("dialog", { name: "Change resources", exact: true })).toBeVisible(); await expect(page.getByRole("dialog", { name: "Couldn't confirm the sandbox change" })).toHaveCount(0); - expect(await writes(request)).toEqual([`PUT ${deploymentPath}`]); + expect(await writes(request)).toEqual([`POST ${templateDiscoveryPath}`, `PUT ${deploymentPath}`]); await page.getByRole("button", { name: "Cancel editing", exact: true }).click(); await page.getByRole("button", { name: "Reset deployment", exact: true }).click(); const reset = page.getByRole("dialog", { name: "Reset sandbox deployment?", exact: true }); await expect(reset).toContainText("Hosted Sessions will be archived permanently."); - expect(await writes(request)).toEqual([`PUT ${deploymentPath}`]); + expect(await writes(request)).toEqual([`POST ${templateDiscoveryPath}`, `PUT ${deploymentPath}`]); await reset.getByRole("button", { name: "Back", exact: true }).click(); await editE2B(page); - expect(await writes(request)).toEqual([`PUT ${deploymentPath}`]); + expect(await writes(request)).toEqual([`POST ${templateDiscoveryPath}`, `PUT ${deploymentPath}`]); }); diff --git a/apps/web/src/features/overview/getting-started.test.ts b/apps/web/src/features/overview/getting-started.test.ts index ba292316b..ac3a2f58d 100644 --- a/apps/web/src/features/overview/getting-started.test.ts +++ b/apps/web/src/features/overview/getting-started.test.ts @@ -63,7 +63,7 @@ describe("Getting started steps", () => { expect(sandboxes(fleet(deployment(), []))).toMatchObject({ state: "todo", action: "add-node" }); expect(sandboxes(fleet(deployment(), [node("n1", { provider_ready: false }), node("n2", { online: false })]))).toMatchObject({ state: "todo", action: "nodes" }); expect(sandboxes(fleet(deployment()))).toMatchObject({ state: "done" }); - const e2b = (status: string | null) => deployment({ provider: "e2b", mode: "direct", e2b: { template: "t", credential_configured: true, template_build: { status, resources: { cpus: 2, memory_mib: 2048, root_disk_mib: null } } } }); + const e2b = (status: string | null) => deployment({ provider: "e2b", mode: "direct", e2b: { template: "t", api_url: "https://api.e2b.app", domain: "e2b.app", credential_configured: true, template_build: { status, resources: { cpus: 2, memory_mib: 2048, root_disk_mib: null } } } }); expect(sandboxes(fleet(e2b("building"), []))).toMatchObject({ state: "todo", cloud: true }); expect(sandboxes(fleet(e2b("ready"), []))).toMatchObject({ state: "done", cloud: true }); // Saved before Core recorded the build: Core admitted it, so it counts as ready. diff --git a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx index 3bfb8f463..e7e78e873 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx @@ -58,6 +58,8 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset {deployment.provider === "e2b" ?
+
{t("Sandbox API URL")}
{deployment.e2b?.api_url}
+
{t("Sandbox data-plane domain")}
{deployment.e2b?.domain}
{t("Template build")}{deployment.e2b?.template || t("Unknown state")}
{[ @@ -81,7 +83,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset key={editKey} coreUrl={deployment.core_url} expectedGeneration={deployment.generation} - current={deployment.provider ? { provider: deployment.provider, specification: deployment.specification, e2bTemplate: deployment.e2b?.template } : undefined} + current={deployment.provider ? { provider: deployment.provider, specification: deployment.specification, e2bTemplate: deployment.e2b?.template, e2bAPIURL: deployment.e2b?.api_url, e2bDomain: deployment.e2b?.domain } : undefined} disabled={disabled || !canEdit} editing onSubmit={async (input) => { if (await onUpdate(input)) setChanging(false); }} diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.test.ts b/apps/web/src/features/sandbox/SandboxSetupWizard.test.ts new file mode 100644 index 000000000..3d2b6a7d6 --- /dev/null +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { validEndpoint } from "./SandboxSetupWizard"; + +describe("E2B endpoint input", () => { + it("accepts the official default and a paired compatible service", () => { + expect(validEndpoint("", "")).toBe(true); + expect(validEndpoint("https://sandbox-test.sandbase.ai", "sandbox-test.sandbase.ai")).toBe(true); + }); + + it("rejects incomplete, local and malformed selectors before submission", () => { + expect(validEndpoint("https://sandbox-test.sandbase.ai", "")).toBe(false); + expect(validEndpoint("https://localhost", "sandbox-test.sandbase.ai")).toBe(false); + expect(validEndpoint("https://127.0.0.1", "sandbox-test.sandbase.ai")).toBe(false); + expect(validEndpoint("https://bad-.example", "good.example")).toBe(false); + expect(validEndpoint("https://good.example", "-bad.example")).toBe(false); + expect(validEndpoint("https://unrelated.example", "sandbox-test.sandbase.ai")).toBe(false); + }); +}); diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index 0a35f2977..147d8e29d 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -1,9 +1,9 @@ -import { AgentCoreError, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxProvider, type SandboxResources, type SandboxRuntimeRelease, type SandboxSpecification } from "@agents-core-web/agents-client"; +import { AgentCoreError, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxE2BReadyBuild, type SandboxE2BTemplate, type SandboxProvider, type SandboxResources, type SandboxRuntimeRelease, type SandboxSpecification } from "@agents-core-web/agents-client"; import { useQuery } from "@tanstack/react-query"; import { AnimatePresence } from "motion/react"; import * as m from "motion/react-m"; -import { ArrowLeft, ArrowRight, Box, Cloud, Cpu, Server, SlidersHorizontal, type LucideIcon } from "lucide-react"; -import { useId, useRef, useState, type FormEvent, type ReactNode } from "react"; +import { ArrowLeft, ArrowRight, Box, Cloud, Cpu, ExternalLink, Server, SlidersHorizontal, type LucideIcon } from "lucide-react"; +import { useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; @@ -18,12 +18,27 @@ import { sandboxConfigurationRejection } from "../../lib/sandbox-labels"; import { defaultSandboxResources, distributionRuntime, savedSpecification, validSandboxResources } from "./deployment-specification"; import { e2bKeyReady, e2bUpdateSelection } from "./sandbox-update"; import { isRuntimeRelease, isRuntimeReleaseField, RUNTIME_RELEASE_FIELDS } from "./runtime-release"; +import { sandboxAdmin } from "./sandbox-queries"; import "./sandbox-wizard.css"; type Where = "nodes" | "direct"; type Step = "where" | "backend" | "e2b" | "size" | "review" | "advanced"; type Preset = "small" | "standard" | "large"; type Size = Preset | "current" | "custom"; +type E2BService = "sandbase" | "official" | "custom"; +const E2B_PRESETS = { + sandbase: { apiURL: "https://sandbox.sandbase.ai", domain: "sandbox.sandbase.ai" }, + official: { apiURL: "https://api.e2b.app", domain: "e2b.app" }, +} as const; +const E2B_KEY_CONSOLES = { + sandbase: "https://www.sandbase.ai/console/keys", + official: "https://e2b.dev/dashboard?tab=api-keys", +} as const; +function e2bService(apiURL?: string): E2BService { + if (!apiURL) return "sandbase"; + if (apiURL === E2B_PRESETS.official.apiURL) return "official"; + return apiURL === E2B_PRESETS.sandbase.apiURL ? "sandbase" : "custom"; +} const MIB = 2 ** 20; const EASE = [0.16, 1, 0.3, 1] as const; @@ -34,6 +49,21 @@ function validTemplate(value: string): boolean { return build !== undefined && /[^0-]/.test(build); } +export function validEndpoint(apiURL: string, domain: string): boolean { + const publicName = (host: string) => host.length <= 253 && host.includes(".") && !/^[0-9.]+$/.test(host) && + !host.endsWith(".local") && !host.endsWith(".localhost") && + host.split(".").every((label) => label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)); + if (!apiURL && !domain) return true; + if (!apiURL || !domain || apiURL.length > 512 || !publicName(domain)) return false; + try { + const url = new URL(apiURL); + return url.protocol === "https:" && url.origin === apiURL && !url.username && !url.password && + !url.port && publicName(url.hostname) && (url.hostname === domain || url.hostname.endsWith(`.${domain}`)); + } catch { + return false; + } +} + /** * Per-sandbox presets around the deployment default: half and double of it. * Disks apply to microsandbox only, the one provider that enforces them. @@ -81,7 +111,7 @@ function presetOf(provider: SandboxProvider, resources: SandboxResources): Prese type WizardProps = { coreUrl: string; expectedGeneration: number; - current?: { provider: SandboxProvider; specification?: SandboxSpecification; e2bTemplate?: string }; + current?: { provider: SandboxProvider; specification?: SandboxSpecification; e2bTemplate?: string; e2bAPIURL?: string; e2bDomain?: string }; disabled: boolean; } & ({ editing: true; onSubmit: (input: UpdateSandboxDeployment) => Promise } | { editing?: false; onSubmit: (input: InitializeSandboxDeployment) => Promise }); @@ -99,6 +129,17 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const [apiKey, setApiKey] = useState(""); const [replacementRequested, setReplacementRequested] = useState(false); const [template, setTemplate] = useState(current?.e2bTemplate ?? ""); + const [service, setService] = useState(e2bService(current?.e2bAPIURL)); + const [apiURL, setAPIURL] = useState(current?.e2bAPIURL ?? E2B_PRESETS[e2bService(current?.e2bAPIURL) as keyof typeof E2B_PRESETS]?.apiURL ?? ""); + const [domain, setDomain] = useState(current?.e2bDomain ?? E2B_PRESETS[e2bService(current?.e2bAPIURL) as keyof typeof E2B_PRESETS]?.domain ?? ""); + const keyConsoleURL = service !== "custom" && apiURL === E2B_PRESETS[service].apiURL && domain === E2B_PRESETS[service].domain + ? E2B_KEY_CONSOLES[service] : null; + const [templates, setTemplates] = useState([]); + const [builds, setBuilds] = useState([]); + const [selectedTemplate, setSelectedTemplate] = useState(current?.e2bTemplate?.split(":")[0] ?? ""); + const [discovery, setDiscovery] = useState<"idle" | "loading" | "ready" | "error">("idle"); + const [buildDiscovery, setBuildDiscovery] = useState<"idle" | "loading" | "ready" | "error">("idle"); + const [discoveryRetry, setDiscoveryRetry] = useState(0); const [runtime, setRuntime] = useState>({}); const [busy, setBusy] = useState(false); const [dockerConfirmed, setDockerConfirmed] = useState(current?.provider === "docker"); @@ -114,6 +155,35 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const address = coreUrl || installation.data?.public_url || null; const configuration = installation.data?.configuration ?? null; + useEffect(() => { + setTemplates([]); setBuilds([]); setDiscovery("idle"); setBuildDiscovery("idle"); + if (provider !== "e2b" || !apiKey.trim() || !validEndpoint(apiURL.trim(), domain.trim())) return; + const controller = new AbortController(); + const timer = window.setTimeout(() => { + setDiscovery("loading"); + void sandboxAdmin.listE2BTemplates({ api_key: apiKey.trim(), api_url: apiURL.trim(), domain: domain.trim() }, { signal: controller.signal }) + .then((items) => { if (!controller.signal.aborted) { setTemplates(items); setDiscovery("ready"); } }) + .catch(() => { if (!controller.signal.aborted) setDiscovery("error"); }); + }, 500); + return () => { window.clearTimeout(timer); controller.abort(); }; + }, [provider, apiKey, apiURL, domain, discoveryRetry]); + + useEffect(() => { + setBuilds([]); setBuildDiscovery("idle"); + if (discovery !== "ready" || !selectedTemplate || !templates.some((item) => item.id === selectedTemplate)) return; + const controller = new AbortController(); + setBuildDiscovery("loading"); + void sandboxAdmin.listE2BReadyBuilds(selectedTemplate, { api_key: apiKey.trim(), api_url: apiURL.trim(), domain: domain.trim() }, { signal: controller.signal }) + .then((items) => { if (!controller.signal.aborted) { setBuilds(items); setBuildDiscovery("ready"); } }) + .catch(() => { if (!controller.signal.aborted) setBuildDiscovery("error"); }); + return () => controller.abort(); + }, [discovery, selectedTemplate, templates, apiKey, apiURL, domain, discoveryRetry]); + + function changeConnection(key: string, url: string, dataDomain: string) { + setApiKey(key); setAPIURL(url); setDomain(dataDomain); + setTemplate(""); setSelectedTemplate(""); setTemplates([]); setBuilds([]); + } + // A release the administrator entered comes first, then the saved one of the same backend, // then the one this console distributes (the release its node installer verifies). const matched = useQuery({ queryKey: ["sandbox-runtime-release"], queryFn: ({ signal }) => distributionRuntime(signal).catch(() => null), staleTime: Infinity, retry: false }); @@ -123,7 +193,8 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const runtimeReady = !needsRuntime || isRuntimeRelease(release); // Initial setup requires a key; an update may retain the committed key. const keyReady = e2bKeyReady(Boolean(editing), replacementRequested, apiKey); - const e2bReady = provider !== "e2b" || (keyReady && validTemplate(template.trim())); + const connectionChanged = Boolean(editing && (apiURL.trim() !== (current?.e2bAPIURL || E2B_PRESETS.official.apiURL) || domain.trim() !== (current?.e2bDomain || E2B_PRESETS.official.domain))); + const e2bReady = provider !== "e2b" || (keyReady && validTemplate(template.trim()) && validEndpoint(apiURL.trim(), domain.trim()) && (!editing || !connectionChanged || apiKey.trim().length > 0)); // Core sizes E2B sandboxes from the template build, so E2B sends no resources. const sized = provider !== null && provider !== "e2b"; const sizeReady = provider !== null && (!sized || validSandboxResources(provider, resources)); @@ -162,8 +233,8 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab ...(sized ? { resources } : {}), ...(needsRuntime ? { runtime: release as SandboxRuntimeRelease } : {}), }; - if (editing) await onSubmit({ ...selection, ...(provider === "e2b" ? { e2b: e2bUpdateSelection(template, apiKey) } : {}) }); - else await onSubmit({ ...selection, ...(provider === "e2b" ? { e2b: { api_key: apiKey.trim(), template: template.trim() } } : {}) }); + if (editing) await onSubmit({ ...selection, ...(provider === "e2b" ? { e2b: { ...e2bUpdateSelection(template, apiKey), api_url: apiURL.trim(), domain: domain.trim() } } : {}) }); + else await onSubmit({ ...selection, ...(provider === "e2b" ? { e2b: { api_key: apiKey.trim(), template: template.trim(), api_url: apiURL.trim(), domain: domain.trim() } } : {}) }); } catch (error) { // A configuration Core rejected is explained here; the page reports every other failure. const reason = sandboxConfigurationRejection(error, i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"); @@ -171,7 +242,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab setRejection(reason); setFieldRejection(error); if (error instanceof AgentCoreError && error.param) { - if (["e2b.api_key", "e2b.template"].includes(error.param) && error.code !== "e2b_team_mismatch") setStep("e2b"); + if (["e2b.api_key", "e2b.template", "e2b.api_url", "e2b.domain"].includes(error.param) && error.code !== "e2b_team_mismatch") setStep("e2b"); else if (error.param === "runtime" || error.param.startsWith("resources.")) setStep("advanced"); } setResetRequired(error instanceof AgentCoreError && ["e2b_team_mismatch", "sandbox_reset_required"].includes(error.code ?? "")); @@ -209,11 +280,52 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab page = (
- - { setApiKey(event.target.value); setFieldRejection(null); setReplacementRequested(Boolean(event.target.value.trim())); }} /> + + + + + {t("Console → API Keys")} + {discovery === "loading" ?

{t("Loading templates…")}

: null} + {discovery === "error" ?

{t("Could not load templates. Check the key and provider connection.")}

: null} + {discovery === "ready" && templates.length === 0 ?

{t("No templates are visible to this key.")}

: null} + {editing && !apiKey.trim() ? setTemplate(event.target.value)} /> : <> + + + + {buildDiscovery === "loading" ?

{t("Loading ready builds…")}

: null} + {buildDiscovery === "error" ?

{t("Could not load builds.")}

: null} + {buildDiscovery === "ready" && builds.length === 0 ?

{t("This template has no ready builds.")}

: null} + + + + } + + changeConnection(apiKey, event.target.value, domain)} placeholder="https://sandbox.example.com" autoComplete="off" spellCheck={false} /> - - { setTemplate(event.target.value); setFieldRejection(null); }} placeholder="oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b" autoComplete="off" spellCheck={false} aria-invalid={Boolean(fieldError("e2b.template") || (template && !validTemplate(template.trim())))} aria-describedby={`${id}-template-error`} /> + + changeConnection(apiKey, apiURL, event.target.value)} placeholder="sandbox.example.com" autoComplete="off" spellCheck={false} />