From 8714b2c1f63e439fba079c0490f9be7865e5e9bf Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 18:33:08 +0000 Subject: [PATCH 01/19] Derive every Core address from one public URL and report installation facts Core now takes the installation's address from one setting, AGENTS_API_PUBLIC_URL. The daemon WebSocket URL, the self-hosted remote_url, hosted Runtime bootstrap, node configuration and the sandbox deployment's core_url all derive from it. AGENTS_API_DAEMON_WS_URL and AGENTS_API_CONFIG_FILE are retired: setting either fails startup and names the replacement. The deployment no longer stores a Core address. POST and PUT /core/v1/sandbox/deployment reject a core_url member with 400 (param core_url); responses keep core_url as a read-only field, present before configuration. E2B selections return 409 sandbox_configuration_error while the public URL is loopback. A migration moves the saved address onto each enrolled node. Nodes record the address they enrolled with and report it as core_url; a node bound to another address receives no new sandboxes, while its retained sandboxes can still resume. GET /core/v1/installation (Core key) reports the public URL, API base URL, source commit, installation ID, the installer's settings snapshot (AGENTS_API_SETTINGS_FILE, validated at startup so a sensitive setting never carries a value) and counts of what is bound to the public URL. It is available before any sandbox deployment exists. AGENTS_API_DATABASE_PASSWORD_FILE keeps the database password out of the URL for the server, migrations and maintenance commands. Core and the console now apply PARSAR_LOG_LEVEL, PARSAR_LOG_FORMAT and PARSAR_LOG_ADD_SOURCE, which were documented but never read. The installer writes the new names only: AGENTS_API_PUBLIC_URL (or the loopback Core origin without --public-url), the password file and a URL without a password. The local node setup no longer sends core_url. The client drops core_url from deployment inputs, marks the deployment and node core_url read-only, adds AdminClient.retrieveInstallation with strict projection, and passes Core's public-URL rejection through for E2B writes unless it reflects the submitted key. Docs and the Core OpenAPI follow; application examples use OPENAI_BASE_URL and OPENAI_API_KEY. --- .gitignore | 2 + CONTRIBUTING.md | 27 ++-- contracts/agents-api/core.openapi.yaml | 125 ++++++++++++++++- contracts/agents-api/installation.md | 42 ++++++ contracts/agents-api/sandbox-deployment.md | 32 +++-- contracts/agents-api/startup-configuration.md | 6 +- deploy/install/configuration.py | 25 ++-- deploy/install/install.py | 13 +- deploy/install/local_node.py | 3 +- deploy/install/test_install.py | 49 ++++--- deploy/install/test_local_node.py | 1 + deploy/install/test_native_service.py | 3 +- docs/api/README.md | 3 +- docs/api/public-agent-api.md | 10 +- docs/configuration.md | 16 ++- docs/getting-started/quickstart.md | 24 ++-- .../agents-client/src/admin-client.test.ts | 27 ++++ packages/agents-client/src/admin-client.ts | 8 +- .../agents-client/src/admin-projection.ts | 36 ++++- packages/agents-client/src/admin-types.ts | 52 ++++++++ packages/agents-client/src/index.ts | 2 +- .../agents-client/src/sandbox-client.test.ts | 33 +++-- packages/agents-client/src/sandbox-client.ts | 13 +- services/agents-api/CONTAINER.md | 5 +- services/agents-api/HOSTED-RELEASE.md | 6 +- services/agents-api/HOSTED-SANDBOX-MANAGER.md | 22 ++- services/agents-api/README.md | 6 +- services/agents-api/RELEASE.md | 4 +- services/agents-api/cmd/device/main.go | 6 +- .../agents-api/cmd/environment-key/main.go | 6 +- services/agents-api/cmd/migrate/main.go | 6 +- .../agents-api/cmd/server/daemon_bootstrap.go | 12 +- .../cmd/server/daemon_bootstrap_test.go | 14 +- .../agents-api/cmd/server/installation.go | 46 +++++++ services/agents-api/cmd/server/main.go | 29 +++- .../agents-api/cmd/server/managed_nodes.go | 8 +- .../agents-api/cmd/server/managed_setup.go | 23 ++-- .../cmd/server/managed_setup_test.go | 46 +++---- .../cmd/server/process_configuration.go | 31 ++++- .../cmd/server/process_configuration_test.go | 27 ++++ .../agents-api/internal/api/core_routes.go | 3 + services/agents-api/internal/api/errors.go | 2 + services/agents-api/internal/api/handler.go | 2 + .../agents-api/internal/api/installation.go | 126 ++++++++++++++++++ .../internal/api/installation_test.go | 98 ++++++++++++++ .../api/sandbox_deployment_changes_test.go | 4 +- .../internal/api/sandbox_deployment_setup.go | 32 ++++- .../api/sandbox_deployment_setup_test.go | 16 +-- .../internal/databaseurl/databaseurl.go | 58 ++++++++ .../internal/databaseurl/databaseurl_test.go | 34 +++++ .../db/queries/runtime_deployment.sql | 8 ++ .../internal/db/queries/runtime_nodes.sql | 4 +- .../db/queries/sandbox_deployment_setup.sql | 4 +- .../agents-api/internal/db/sqlc/models.go | 2 +- .../db/sqlc/runtime_deployment.sql.go | 25 +++- .../internal/db/sqlc/runtime_nodes.sql.go | 20 ++- .../db/sqlc/sandbox_deployment_setup.sql.go | 10 +- .../execution/sandbox_deployment_setup.go | 2 +- .../sandbox_deployment_setup_test.go | 4 +- .../sandbox_deployment_switch_test.go | 4 +- .../execution/sandbox_template_build_test.go | 2 +- .../store/admin_session_archive_race_test.go | 2 +- .../admin_session_archive_worker_http_test.go | 4 +- .../store/credential_matrix_http_test.go | 6 +- .../agents-api/internal/store/public_url.go | 39 ++++++ .../store/public_url_migration_test.go | 77 +++++++++++ .../store/runtime_node_configuration.go | 2 +- .../internal/store/runtime_node_types.go | 6 +- .../internal/store/runtime_nodes.go | 12 +- .../internal/store/runtime_placements.go | 7 +- .../store/sandbox_deployment_mutations.go | 15 +-- .../store/sandbox_deployment_setup.go | 35 +++-- .../store/sandbox_deployment_setup_test.go | 13 +- .../store/sandbox_deployment_switch_test.go | 14 +- .../sandbox_deployment_switch_worker_test.go | 12 +- .../store/sandbox_deployment_view_test.go | 2 +- .../store/sandbox_deployment_worker_test.go | 6 +- .../internal/store/sandbox_specification.go | 2 +- .../store/sandbox_specification_store_test.go | 26 +++- .../internal/store/session_initial_input.go | 2 +- .../agents-api/internal/store/sessions.go | 3 + .../migrations/000075_public_url_binding.sql | 42 ++++++ services/agents-api/tests/container_server.py | 2 +- services/agents-api/tests/official_client.py | 2 +- services/core-console/main.go | 1 + 85 files changed, 1313 insertions(+), 328 deletions(-) create mode 100644 contracts/agents-api/installation.md create mode 100644 services/agents-api/cmd/server/installation.go create mode 100644 services/agents-api/internal/api/installation.go create mode 100644 services/agents-api/internal/api/installation_test.go create mode 100644 services/agents-api/internal/databaseurl/databaseurl.go create mode 100644 services/agents-api/internal/databaseurl/databaseurl_test.go create mode 100644 services/agents-api/internal/store/public_url.go create mode 100644 services/agents-api/internal/store/public_url_migration_test.go create mode 100644 services/agents-api/migrations/000075_public_url_binding.sql diff --git a/.gitignore b/.gitignore index 4b69c8cd3..9012af483 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ __pycache__/ /state/ /cache/ /target/ +# `go build ./services/core-console` in the repository root writes this binary. +/core-console /playwright-report/ /test-results/ /.agents/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ff4c0452..519c25105 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -227,7 +227,7 @@ intervals and restart gaps. Do not substitute host usage for process usage. Administrator node detail adds host observations and history as documented in [node-host-history.md](contracts/agents-api/node-host-history.md). Keep the node -list unchanged. Reuse authenticated heartbeat ownership, the Runtime sampling +list unchanged apart from the address each node enrolled with (`core_url`). Reuse authenticated heartbeat ownership, the Runtime sampling sweep and PostgreSQL retention cleanup; node observations have their own table because they do not belong to a Project, Session or Environment. History is best-effort telemetry, never scheduling truth. No read-triggered sampling or @@ -1652,8 +1652,8 @@ execution concurrency bounds simultaneous work, not attempt frequency. Managed-p polling retains its separate five-second interval. Unknown promotion results or errors after admission retain the existing no-replay settlement rules. -`AGENTS_API_DAEMON_WS_URL` enables the private gateway and supplies the unchanged -public `remote_url`. `AGENTS_API_HARNESSES` explicitly adds deployment-supported +`AGENTS_API_PUBLIC_URL` enables the private gateway; Core derives the public +`remote_url` from it. `AGENTS_API_HARNESSES` explicitly adds deployment-supported engines to the default engine and configured managed profiles; advertising a heartbeat alone does not enable an engine. The three native profiles share enrollment at `/workspace`. Their new user-managed public chain requires fixed-client/raw HTTP, @@ -1791,14 +1791,15 @@ Environment/key binding and current authenticated connection; it never enrolls, allocates, wakes a sandbox or grants project resource access. It is an `/api/v1` machine route that reaches Core directly, never through the console. Bounded polling and reruns retain the original container and history; timeout is a -diagnostic failure, not permission to relaunch. An explicit installer -`--public-url` supplies both the console origin and the advertised daemon `wss` -origin. Keep local managed Provider routing separate; do not return an internal -Compose hostname to a user-managed Runtime when an external origin was supplied. Bootstrap routing uses the -node bound to the authenticated device's persisted allocation, never request Host -or caller-supplied placement fields. Local and remote managed nodes use the saved -public Core origin. Self-hosted devices retain the deployment's advertised public -address. This does +diagnostic failure, not permission to relaunch. The installation public URL +(`AGENTS_API_PUBLIC_URL`, from the installer's `--public-url`) is the one origin for +applications, nodes, sandbox guests and self-hosted executors, and also the console +origin. Core derives the daemon `wss` URL, the self-hosted `remote_url`, hosted +Runtime bootstrap and the deployment's read-only `core_url` from it; the deployment +API does not accept a Core address, and no deployment row stores one. Bootstrap +never uses request Host or caller-supplied placement fields. Each node records the +address it enrolled with; after the public URL changes, it receives no new +sandboxes until re-added. This does not widen sandbox network policies or change credential admission. The distribution build sets umask 022 for non-root-readable payloads; installation @@ -2404,7 +2405,7 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti tenant ownership and a credential digest. Their internal daemon gateway uses `/api/v1/agent-daemon/*`, separately from the official `/v1/agents/*` surface; device credentials grant no Session API or product permissions. The optional - `AGENTS_API_DAEMON_WS_URL` enables that gateway. It is a single-process registry, + `AGENTS_API_PUBLIC_URL` enables that gateway. It is a single-process registry, not a claim of multi-pod execution or stock `exec-server` interoperability. Self-hosted enrollment uses this gateway with an exact Environment binding. Session/device bindings are tenant-scoped and immutable. Revocation denies new @@ -2775,7 +2776,7 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti including after terminal or later Turns. Omitted/null input is permitted only for non-streaming hosted creation and self-hosted creation. Creation streaming uses the shared live path above; non-text messages remain a gap. -- Enabling `AGENTS_API_DAEMON_WS_URL` also starts a bounded execution worker. Select +- Enabling daemon transport with `AGENTS_API_PUBLIC_URL` also starts a bounded execution worker. Select only connected, capable devices owned by the authenticated tenant; bind once and preserve native continuity. Metadata cannot select a device. Offline work stays queued and can be cancelled. An engine host is not a self-hosted environment. diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index 4a5e157fa..d5ff98856 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -195,6 +195,78 @@ definitions: $ref: '#/definitions/store.ExecutorCredential' type: array type: object + api.Installation: + properties: + address_bindings: + $ref: '#/definitions/store.AddressBindings' + api_base_url: + description: public_url followed by /v1; null when public_url is null. + type: string + x-nullable: true + configuration: + allOf: + - $ref: '#/definitions/api.InstallationConfiguration' + description: The installer's settings snapshot (AGENTS_API_SETTINGS_FILE); null when the installer did not start Core. + x-nullable: true + installation_id: + description: AGENTS_API_SANDBOX_INSTALLATION_ID; null when Core runs without the sandbox manager. + type: string + x-nullable: true + local_only: + description: True when public_url names a loopback host, reachable only from the Core host. + type: boolean + object: + enum: + - core.installation + type: string + public_url: + description: 'AGENTS_API_PUBLIC_URL: the origin applications, nodes, sandboxes and self-hosted executors use. Null when unset.' + type: string + x-nullable: true + source_commit: + description: Full source commit Core was built from; null for development builds. + type: string + x-nullable: true + type: object + api.InstallationConfiguration: + properties: + applied_at: + type: string + apply_command: + description: Command that applies config.json changes. + type: string + path: + description: Absolute host path of the installation's config.json. + type: string + settings: + items: + $ref: '#/definitions/api.InstallationSetting' + type: array + type: object + api.InstallationSetting: + properties: + changeable: + description: False for settings fixed at installation. + type: boolean + configured: + description: 'Present only for a sensitive setting: whether it has a value.' + type: boolean + default: + x-nullable: true + key: + description: Dotted config.json key, such as ports.core. + type: string + restarts: + description: Services that restart when the setting changes. + items: + type: string + type: array + sensitive: + type: boolean + value: + description: Applied value; always null for a sensitive setting. + x-nullable: true + type: object api.ProjectAPIKeyRequest: properties: name: @@ -235,8 +307,6 @@ definitions: type: object api.SandboxDeploymentChangeInput: properties: - core_url: - type: string e2b: $ref: '#/definitions/api.SandboxE2BInput' expected_generation: @@ -254,8 +324,6 @@ definitions: type: object api.SandboxDeploymentInput: properties: - core_url: - type: string e2b: $ref: '#/definitions/api.SandboxE2BInput' provider: @@ -530,6 +598,17 @@ definitions: source_commit: type: string type: object + store.AddressBindings: + properties: + hosted_sandboxes: + type: integer + nodes: + type: integer + nodes_on_other_address: + type: integer + self_hosted_executors: + type: integer + type: object store.AdminAssetCounts: properties: agents: @@ -725,6 +804,7 @@ definitions: store.RuntimeDeploymentView: properties: core_url: + description: 'Read-only: the installation public URL (AGENTS_API_PUBLIC_URL), which nodes and sandboxes use to reach Core. The deployment API does not accept it.' type: string e2b: $ref: '#/definitions/store.SandboxE2BView' @@ -762,6 +842,11 @@ definitions: type: integer cleanup_pending: type: integer + core_url: + description: |- + The Core address this node enrolled with. A node whose address differs + from the installation public URL receives no new sandboxes; re-add it. + type: string cpu_count: type: integer created_at: @@ -839,6 +924,11 @@ definitions: type: integer cleanup_pending: type: integer + core_url: + description: |- + The Core address this node enrolled with. A node whose address differs + from the installation public URL receives no new sandboxes; re-add it. + type: string cpu_count: type: integer created_at: @@ -2879,6 +2969,29 @@ paths: summary: Query committed administrator mutations tags: - Core Administration + /core/v1/installation: + get: + description: Core key only; available before any sandbox deployment exists. Reports the public URL that applications, nodes, sandboxes and self-hosted executors use, the API base URL, Core's source commit and installation ID, the installer's settings snapshot with where to change it, and what is bound to the current public URL. Sensitive settings report only whether they are configured. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.Installation' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - DeploymentAdminAuth: [] + summary: Retrieve installation facts and process settings + tags: + - Core Administration /core/v1/metrics: get: description: Core key only. Complete UTC buckets; unknown measurements are null. Samples are process-local and are not backfilled after a restart. @@ -5323,7 +5436,7 @@ paths: post: consumes: - application/json - description: Selects a provider, enforced resource limits, pinned Runtime release and public Core origin. E2B credentials are write-only. E2B may omit resources to adopt the validated template build's CPU and memory, returned in specification.resources. Exact retries return the existing selection; differing selections and file-managed deployments reject. This does not create compute or execute work. + description: Selects a provider, enforced resource limits and pinned Runtime release. Core derives the deployment's core_url from the installation public URL and rejects a core_url member with 400. E2B returns 409 sandbox_configuration_error while the public URL is loopback. E2B credentials are write-only. E2B may omit resources to adopt the validated template build's CPU and memory, returned in specification.resources. Exact retries return the existing selection; differing selections and file-managed deployments reject. This does not create compute or execute work. parameters: - description: Deployment selection in: body @@ -5366,7 +5479,7 @@ paths: put: consumes: - application/json - description: Requires maintenance, the current generation and verified cleanup of all old resources. Credentials are write-only. E2B may omit resources to adopt the validated template build's CPU and memory. The public Core origin stays unchanged. Historical records are retained; old node credentials and enrollments are retired. Explicitly resume after success. Never automatically retry an uncertain write. + description: Requires maintenance, the current generation and verified cleanup of all old resources. Credentials are write-only. E2B may omit resources to adopt the validated template build's CPU and memory. A core_url member is rejected with 400; the address comes from the installation public URL. Historical records are retained; old node credentials and enrollments are retired. Explicitly resume after success. Never automatically retry an uncertain write. parameters: - description: Replacement deployment selection in: body diff --git a/contracts/agents-api/installation.md b/contracts/agents-api/installation.md new file mode 100644 index 000000000..96b64029c --- /dev/null +++ b/contracts/agents-api/installation.md @@ -0,0 +1,42 @@ +# Installation facts + +`GET /core/v1/installation` reports what an administrator needs to call and +change this installation. Core key only, like every `/core/v1` route; Web forwards +it after sign-in. It is available before any sandbox deployment exists and makes no +provider or model call. See the [Core OpenAPI](core.openapi.yaml) for the schema. + +| Field | Source | +| --- | --- | +| `object` | Always `core.installation` | +| `installation_id` | `AGENTS_API_SANDBOX_INSTALLATION_ID`; null when Core runs without the sandbox manager | +| `public_url` | `AGENTS_API_PUBLIC_URL`: the origin applications, nodes, sandbox guests and self-hosted executors use. Null when unset | +| `api_base_url` | `public_url` followed by `/v1`: the base URL for Project API keys (`OPENAI_BASE_URL`). Null when `public_url` is null | +| `local_only` | True when `public_url` names a loopback host, which only the Core host reaches | +| `source_commit` | The full source commit Core was built from; null for development builds | +| `configuration` | The installer's settings snapshot (`AGENTS_API_SETTINGS_FILE`); null when the installer did not start Core | +| `address_bindings` | What a change of `public_url` affects, counted on each read | + +`configuration` has: + +- `path`: the absolute host path of the installation's `config.json`, where every + process setting is changed; +- `apply_command`: the command that applies `config.json` changes; +- `applied_at`: when the snapshot was last applied; +- `settings`: one item per `config.json` setting, with its dotted `key`, applied + `value`, `default`, whether it is `changeable` after installation, whether it is + `sensitive`, and the services it `restarts` (`core`, `web`, `database`). + +A sensitive setting always has a null `value` and `default`, and a boolean +`configured` instead; only sensitive settings have `configured`. Core refuses to +start when the snapshot breaks this rule, has a duplicate key or has an unknown +member. Core reports the snapshot and never acts on it; runtime settings, such as +the sandbox deployment, have their own routes. + +`address_bindings` has: + +| Field | Meaning | +| --- | --- | +| `nodes` | Enrolled nodes that are not removed | +| `nodes_on_other_address` | Nodes enrolled with an address other than `public_url`. They receive no new sandboxes; remove and add them again. At most `nodes` | +| `hosted_sandboxes` | Retained and pending hosted sandboxes, started with the address current at the time | +| `self_hosted_executors` | Unrevoked executor credentials; their executors were installed with the advertised `remote_url` | diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 3b138d52d..d83ae9eb7 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -20,7 +20,7 @@ for the operator workflow. Generated schemas cover the | Method and route | Authority | Effect | | --- | --- | --- | | `GET /core/v1/sandbox/deployment` | Core key | Read the safe active configuration and retained-resource counts | -| `POST /core/v1/sandbox/deployment` | Core key | Select the initial provider, resources, Runtime and Core origin | +| `POST /core/v1/sandbox/deployment` | Core key | Select the initial provider, resources and Runtime | | `PUT /core/v1/sandbox/deployment` | Core key | Replace a fully drained selection while maintenance is enabled | | `PATCH /core/v1/sandbox/deployment/maintenance` | Core key | Pause or resume fresh hosted admission at the expected generation | | `GET /api/v1/sandbox-node/configuration` | Enrollment token or retained node credential | Read the active node installation configuration without consuming enrollment | @@ -43,6 +43,11 @@ expose approved limits for that credential; node updates remain administrator operations. Local host capacity checks may reject a deployment that cannot run safely, but never raise its limits. +Each node in `GET /core/v1/sandbox/nodes` and its detail reports `core_url`: the +installation public URL when the node enrolled. A node whose `core_url` differs from +the current public URL receives no new sandboxes; its retained sandboxes can still +resume while the old address reaches Core. Remove it and add it again. + `GET /core/v1/sandbox/nodes/{node_id}/allocations` lists the node's unreleased allocations. Each item's `compute_phase_changed_at` is the time the allocation entered its current `compute_phase`, or null when unknown; an allocation that @@ -58,15 +63,21 @@ POST and PUT take the same complete selection. PUT also requires a nonzero | Field | Meaning | | --- | --- | | `provider` | Exactly one of `docker`, `microsandbox`, `e2b` | -| `core_url` | Canonical Core origin, without path, credentials, query or fragment | | `resources` | Per-sandbox resource limits described below; required for Docker/microsandbox, optional for E2B | | `runtime` | Required immutable distribution identity for Docker/microsandbox; absent for E2B | | `e2b` | Required only for E2B: write-only `api_key` and immutable `template` build selector | -Use an HTTPS origin reachable from nodes and sandbox guests. The API accepts HTTP -loopback only for explicit local development. A guest's loopback address does not -reach its host. PUT preserves the selected `core_url`; changing the public origin -is outside this operation. +The request has no Core address. Core derives the deployment's `core_url` from the +installation public URL (`public_url` in `config.json`, `AGENTS_API_PUBLIC_URL` for +Core): the HTTPS origin nodes and sandbox guests use to reach Core. A request that +contains `core_url` is rejected with 400 `invalid_request_error` and +`param: "core_url"`. E2B guests reach Core from E2B's cloud, so an E2B selection is +rejected with 409 `sandbox_configuration_error` while the public URL is loopback. +Docker and microsandbox selections do not depend on the address; a loopback public +URL serves local development only, because a guest's loopback address does not +reach its host. Changing the public URL is an installation change, not this +operation: nodes enrolled with the old address receive no new sandboxes and must +be removed and added again. ### Resources @@ -133,8 +144,9 @@ build uses the same drained maintenance transition as changing resources. ## Safe response -GET and successful mutations return `installation_id`, `provider`, `core_url`, -`mode`, `generation`, `owner_epoch`, `maintenance`, `suspension` and resource +GET and successful mutations return `installation_id`, `provider`, `core_url` +(read-only: the installation public URL, present before configuration), `mode`, +`generation`, `owner_epoch`, `maintenance`, `suspension` and resource accounting. A configured deployment also returns `specification` and `specification_digest`. E2B returns only `e2b.template`, `e2b.credential_configured` and `e2b.template_build`; the `e2b` object is absent @@ -231,8 +243,8 @@ digest must match the active deployment. This read remains available in maintenance so the retained node can recover its exact configuration. The old enrollment token cannot replace a registered node's credential. -The response contains `installation_id`, `provider`, `core_url`, `generation`, -`specification` and `specification_digest`. It contains no administrator, Project +The response contains `installation_id`, `provider`, `core_url` (the installation +public URL), `generation`, `specification` and `specification_digest`. It contains no administrator, Project or E2B credential. It is available only for node-backed providers. The installer reads this configuration before preparing local assets. Its provider diff --git a/contracts/agents-api/startup-configuration.md b/contracts/agents-api/startup-configuration.md index 2259310f7..6e3d4a959 100644 --- a/contracts/agents-api/startup-configuration.md +++ b/contracts/agents-api/startup-configuration.md @@ -1,8 +1,10 @@ # Core startup configuration (removed) -The startup configuration read is removed, with no replacement route. Neither +The startup configuration read is removed. The separate +[installation read](installation.md), `GET /core/v1/installation`, reports the public +URL and the installer's process settings. Neither `GET /core/v1/admin/startup-configuration` nor the earlier `GET /v1/agents/core/startup-configuration` exists; `/v1` serves only the pinned -upstream routes and `/core/v1` has no equivalent. A Session's committed harness +upstream routes and `/core/v1` has no startup-configuration route. A Session's committed harness and model selection remain available through the administrator [execution configuration](execution-configuration.md) read. diff --git a/deploy/install/configuration.py b/deploy/install/configuration.py index aa09530ff..27618aba3 100644 --- a/deploy/install/configuration.py +++ b/deploy/install/configuration.py @@ -3,7 +3,6 @@ import re import stat from pathlib import Path -from urllib.parse import urlsplit def environment_text(values): @@ -45,11 +44,8 @@ def read_core_environment(root, state=None): if not match or match[1] in result: raise RuntimeError("Core configuration requires unique names and double-quoted literal values") result[match[1]] = re.sub(r'\\([\\"$])', r'\1', match[2]) - if state is not None: - expected = str(path) if state["native_core"] else "/config/core.env" - if (result.get("AGENTS_API_SANDBOX_INSTALLATION_ID") != state["installation_id"] - or result.get("AGENTS_API_CONFIG_FILE") != expected): - raise RuntimeError("Core configuration installation identity or file path differs") + if state is not None and result.get("AGENTS_API_SANDBOX_INSTALLATION_ID") != state["installation_id"]: + raise RuntimeError("Core configuration installation identity differs") return result @@ -57,22 +53,20 @@ def bind(source, target, readonly=True): return {"type": "bind", "source": str(source).replace("$", "$$"), "target": target, "read_only": readonly} -def core_environment(root, state, database_password): +def core_environment(root, state): native = state["native_core"] config = str(Path(root) / "config") if native else "/config" database = f'127.0.0.1:{state["database_port"]}' if native else "database:5432" - daemon_host = f'127.0.0.1:{state["core_port"]}' if native else "core:8091" - daemon_url = f"ws://{daemon_host}/api/v1/agent-daemon/ws" - if state.get("public_url"): - origin = urlsplit(state["public_url"]) - daemon_url = origin._replace(scheme="wss" if origin.scheme == "https" else "ws", - path="/api/v1/agent-daemon/ws").geturl() result = { - "AGENTS_API_DATABASE_URL": f"postgres://agents_api:{database_password}@{database}/agents_api?sslmode=disable", + # The password stays in its own file; the URL never carries it. + "AGENTS_API_DATABASE_URL": f"postgres://agents_api@{database}/agents_api?sslmode=disable", + "AGENTS_API_DATABASE_PASSWORD_FILE": config + "/database.password", "AGENTS_API_CREDENTIAL_KEY_FILE": config + "/credential.key", "AGENTS_API_ADDR": f'127.0.0.1:{state["core_port"]}' if native else ":8091", "AGENTS_API_ENGINE": "codex", "AGENTS_API_HARNESSES": "codex,claude_sdk,mcode", - "AGENTS_API_DAEMON_WS_URL": daemon_url, + # The one origin applications, nodes, sandboxes and self-hosted executors + # use. Without a public URL, only this host reaches Core's loopback port. + "AGENTS_API_PUBLIC_URL": state.get("public_url") or f'http://127.0.0.1:{state["core_port"]}', "AGENTS_API_E2B_PROVIDER_BIN": (str(Path(root) / "native/e2b/agents-api-e2b-provider") if native else "/opt/parsar/e2b/agents-api-e2b-provider"), "AGENTS_API_E2B_STATE_DIR": str(Path(root) / "state/e2b") if native else "/state/e2b", @@ -80,7 +74,6 @@ def core_environment(root, state, database_password): result["AGENTS_API_CORE_KEY_DIGESTS_FILE"] = ( str(Path(root) / "admin/core-key-digests.json") if native else "/admin/core-key-digests.json") result["AGENTS_API_SANDBOX_INSTALLATION_ID"] = state["installation_id"] - result["AGENTS_API_CONFIG_FILE"] = config + "/core.env" return result diff --git a/deploy/install/install.py b/deploy/install/install.py index 2b2b28f19..27167e4ea 100644 --- a/deploy/install/install.py +++ b/deploy/install/install.py @@ -121,6 +121,15 @@ def origin_port(value): return parsed.port or (443 if parsed.scheme == "https" else 80) +def public_origin(value): + # Core accepts only a canonical origin: lowercase scheme and host, no IPv6 literal. + value = core_target(value) + parsed = urlsplit(value) + if "[" in parsed.netloc: + raise argparse.ArgumentTypeError("Public URL must use a host name or IPv4 address") + return parsed._replace(scheme=parsed.scheme.lower(), netloc=parsed.netloc.lower()).geturl() + + def arguments(argv=None): parser = argparse.ArgumentParser(description=__doc__) modes = parser.add_mutually_exclusive_group() @@ -135,7 +144,7 @@ def arguments(argv=None): parser.add_argument("--core-port", type=int, default=8091) parser.add_argument("--web-port", type=int, default=8080) parser.add_argument("--core-url", type=core_target) - parser.add_argument("--public-url", type=core_target, help="Public HTTPS Core/Web origin behind your TLS reverse proxy") + parser.add_argument("--public-url", type=public_origin, help="Public HTTPS Core/Web origin behind your TLS reverse proxy") parser.add_argument("--core-key-file", type=Path, help="Web-only: private file containing the existing Core's Core key") parser.add_argument("--admin-token-file", type=Path, help=argparse.SUPPRESS) parser.add_argument("--status", action="store_true", help="Read installation health; never invoke a model") @@ -292,7 +301,7 @@ def initialize(root, args, manifest): private_write(root / "admin/core.key", token) password = (config / "database.password").read_text() if mode != "web-only" else "" if mode != "web-only": - private_write(config / "core.env", environment_text(core_environment(root, state, password))) + private_write(config / "core.env", environment_text(core_environment(root, state))) write_json(root / "compose.json", compose_config(root, state, manifest, password)) write_json(root / "installation.json", state) return state diff --git a/deploy/install/local_node.py b/deploy/install/local_node.py index 655c27390..8fd64b31b 100644 --- a/deploy/install/local_node.py +++ b/deploy/install/local_node.py @@ -48,8 +48,7 @@ def install(root, state, manifest, bundle, run): if state["provider"] == "microsandbox": resources.update(memory_mib=4096, root_disk_mib=8192, environment_disk_mib=8192) current = request(core, admin, "POST", "deployment", { - "provider": state["provider"], "core_url": state["public_url"], - "resources": resources, "runtime": node_spec.release(manifest)}) + "provider": state["provider"], "resources": resources, "runtime": node_spec.release(manifest)}) if current.get("provider") != state["provider"] or current.get("core_url") != state["public_url"]: raise LocalNodeError("Existing deployment selection differs; change it through maintenance instead of reinstalling") node_root = Path.home() / ".parsar/nodes" / state["installation_id"] diff --git a/deploy/install/test_install.py b/deploy/install/test_install.py index 070a781cc..248a5ddd4 100644 --- a/deploy/install/test_install.py +++ b/deploy/install/test_install.py @@ -139,13 +139,15 @@ def test_core_and_migration_share_one_private_environment_file(self): environment = install.read_core_environment(self.root, state) path = self.root / "config/core.env" self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) - self.assertEqual(environment["AGENTS_API_CONFIG_FILE"], "/config/core.env") + self.assertNotIn("AGENTS_API_CONFIG_FILE", environment) + self.assertEqual(environment["AGENTS_API_DATABASE_URL"], "postgres://agents_api@database:5432/agents_api?sslmode=disable") + self.assertEqual(environment["AGENTS_API_DATABASE_PASSWORD_FILE"], "/config/database.password") services = self.document("compose.json")["services"] for name in ("core", "migrate"): self.assertEqual(services[name]["env_file"], [str(path)]) self.assertNotIn("environment", services[name]) environment["AGENTS_API_ENGINE"] = "claude_sdk" - environment["AGENTS_API_DAEMON_WS_URL"] = "wss://edited.example/api/v1/agent-daemon/ws" + environment["AGENTS_API_PUBLIC_URL"] = "https://edited.example" path.write_text(install.environment_text(environment)) before = self.snapshot() self.initialize() @@ -181,11 +183,10 @@ def test_retained_core_environment_refuses_missing_or_unsafe_file(self): def test_retained_core_environment_cannot_replace_installation_identity(self): state = self.initialize() values = install.read_core_environment(self.root) - for key in ("AGENTS_API_SANDBOX_INSTALLATION_ID", "AGENTS_API_CONFIG_FILE"): - changed = dict(values, **{key: "changed"}) - (self.root / "config/core.env").write_text(install.environment_text(changed)) - with self.assertRaisesRegex(RuntimeError, "identity or file path"): - self.initialize() + changed = dict(values, AGENTS_API_SANDBOX_INSTALLATION_ID="changed") + (self.root / "config/core.env").write_text(install.environment_text(changed)) + with self.assertRaisesRegex(RuntimeError, "identity differs"): + self.initialize() (self.root / "config/core.env").write_text(install.environment_text(values)) self.assertEqual(self.initialize(), state) @@ -393,7 +394,7 @@ def test_administrator_credential_is_private_and_only_web_holds_plaintext(self): before = self.snapshot() self.initialize(*flags) self.assertEqual(self.snapshot(), before) - environment = install.core_environment(self.root, state, "synthetic database password") + environment = install.core_environment(self.root, state) expected = str(admin / "core-key-digests.json") if native else "/admin/core-key-digests.json" self.assertEqual(environment["AGENTS_API_CORE_KEY_DIGESTS_FILE"], expected) self.assertNotIn("AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE", environment) @@ -535,34 +536,30 @@ def test_public_origin_is_explicit_and_preserved(self): self.assertEqual(web["environment"]["CORE_CONSOLE_ORIGIN"], "https://core.example") self.assertEqual(web["ports"], ["127.0.0.1:8080:8080"]) self.assertEqual(state["public_url"], "https://core.example") - self.assertEqual(install.read_core_environment(self.root)["AGENTS_API_DAEMON_WS_URL"], - "wss://core.example/api/v1/agent-daemon/ws") + self.assertEqual(install.read_core_environment(self.root)["AGENTS_API_PUBLIC_URL"], "https://core.example") with self.assertRaises(install.InstallError): self.initialize("--public-url", "https://other.example") - def test_public_daemon_address_is_shared_across_placement_modes(self): + def test_public_url_is_shared_across_packaging_modes(self): state = self.initialize("--public-url", "https://core.example:8443") for native in (False, True): with self.subTest(native=native): configured = dict(state, native_core=native, database_port=15432) - env = install.core_environment(self.root, configured, "fixture-password") - self.assertEqual(env["AGENTS_API_DAEMON_WS_URL"], - "wss://core.example:8443/api/v1/agent-daemon/ws") + env = install.core_environment(self.root, configured) + self.assertEqual(env["AGENTS_API_PUBLIC_URL"], "https://core.example:8443") + self.assertNotIn("AGENTS_API_DAEMON_WS_URL", env) configured["public_url"] = None - local = install.core_environment(self.root, configured, "fixture-password") - expected_host = "127.0.0.1:8091" if native else "core:8091" - self.assertEqual(local["AGENTS_API_DAEMON_WS_URL"], "ws://" + expected_host + "/api/v1/agent-daemon/ws") + # Without a public URL, only this host reaches Core. + self.assertEqual(install.core_environment(self.root, configured)["AGENTS_API_PUBLIC_URL"], "http://127.0.0.1:8091") - def test_accepted_public_origin_schemes_generate_websocket_urls(self): - state = self.initialize() - for origin, expected in (("HTTPS://core.example", "wss://core.example"), - ("http://localhost:8080", "ws://localhost:8080"), - ("http://127.0.0.1:8080", "ws://127.0.0.1:8080")): + def test_public_url_is_canonical_for_core(self): + for origin, expected in (("HTTPS://Core.Example", "https://core.example"), + ("http://localhost:8080/", "http://localhost:8080"), + ("http://127.0.0.1:8080", "http://127.0.0.1:8080")): with self.subTest(origin=origin): - args = self.args("--public-url", origin) - configured = dict(state, public_url=args.public_url) - env = install.core_environment(self.root, configured, "fixture-password") - self.assertEqual(env["AGENTS_API_DAEMON_WS_URL"], expected + "/api/v1/agent-daemon/ws") + self.assertEqual(self.args("--public-url", origin).public_url, expected) + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + install.arguments(["--public-url", "https://[2001:db8::1]"]) def test_local_opt_in_requires_non_loopback_https_origin_before_installation(self): for provider in ("docker", "microsandbox"): diff --git a/deploy/install/test_local_node.py b/deploy/install/test_local_node.py index 47ffa7330..37a0783db 100644 --- a/deploy/install/test_local_node.py +++ b/deploy/install/test_local_node.py @@ -44,6 +44,7 @@ def test_first_install_saves_spec_and_launches_ordinary_node_without_secret_argu setup = request.call_args_list[1].args[4] self.assertEqual(setup["resources"], resources) self.assertEqual(setup["runtime"]["image_id"], self.manifest["images"]["runtime"]) + self.assertNotIn("core_url", setup) # Core derives it from its public URL. self.assertNotIn("one-time", str(self.run.call_args.args)) self.assertEqual(self.run.call_args.kwargs["env"]["PARSAR_NODE_ENROLLMENT_TOKEN"], "one-time") self.assertIn("--bundle", self.run.call_args.args[0]) diff --git a/deploy/install/test_native_service.py b/deploy/install/test_native_service.py index 4316c3453..747bbce5e 100644 --- a/deploy/install/test_native_service.py +++ b/deploy/install/test_native_service.py @@ -27,8 +27,7 @@ def setUp(self): path = self.bundle / "native" / name path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(b"\x7fELFfixture-" + name.encode()) - self.environment.update(AGENTS_API_SANDBOX_INSTALLATION_ID=self.state["installation_id"], - AGENTS_API_CONFIG_FILE=str(self.root / "config/core.env")) + self.environment.update(AGENTS_API_SANDBOX_INSTALLATION_ID=self.state["installation_id"]) (self.root / "config").mkdir(parents=True, mode=0o700) (self.root / "config/core.env").write_text(environment_text(self.environment)) (self.root / "config/core.env").chmod(0o600) diff --git a/docs/api/README.md b/docs/api/README.md index 6e31401eb..4317660d5 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -6,7 +6,7 @@ no credential works in another namespace. | Namespace | Caller | Credential | Contents | Reference | | --- | --- | --- | --- | --- | | `/v1` | Applications (business systems, SDKs) | Project API key | Exactly the pinned official Agents API routes. Core-only fields live only in `x_agents_core` (`harness`, `model_provider`) | [Public API](public-agent-api.md) | -| `/core/v1` | Core Web's server and operator scripts | [Core key](../getting-started/operations.md#core-key) | Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes | [Core API](#core-api), [Web API](web-management.md), [Core OpenAPI](../../contracts/agents-api/core.openapi.yaml) | +| `/core/v1` | Core Web's server and operator scripts | [Core key](../getting-started/operations.md#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes | [Core API](#core-api), [Web API](web-management.md), [Core OpenAPI](../../contracts/agents-api/core.openapi.yaml) | | `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: node enrollment tokens and executor credentials issued through `/core/v1`, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine connections only: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](../../contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](../../contracts/agents-api/runtime.openapi.yaml) | A Project API key gets 401 on `/core/v1` and `/api/v1`; the Core key gets 401 on @@ -49,6 +49,7 @@ with the Core key. | `projects/{project_id}/sessions/{session_id}/runtime-history` | Stored Runtime history | [Runtime history](../../contracts/agents-api/runtime-history-api.md) | | `projects/{project_id}/{resource-owners,write-operations}`, `audit-log`, `summary` | Provenance, write history, administrator audit and usage summary | [Write audit](../../contracts/agents-api/write-audit.md), [administrator contract](../../contracts/agents-api/admin-api.md) | | `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](../../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](../../contracts/agents-api/installation.md) | | `metrics` | Core's own process metrics | [Core metrics](../../contracts/agents-api/core-metrics.md) | | `sandbox/deployment[/maintenance]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, node enrollment tokens and nodes | [Sandbox deployment](../../contracts/agents-api/sandbox-deployment.md), [node operations](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](../../contracts/agents-api/node-host-history.md) | diff --git a/docs/api/public-agent-api.md b/docs/api/public-agent-api.md index 40e6c521f..d97b62a88 100644 --- a/docs/api/public-agent-api.md +++ b/docs/api/public-agent-api.md @@ -6,19 +6,17 @@ They never use the Core key or the console's browser session cookie. ## Authentication and base URL -Set the OpenAI client's `base_url` to the Core origin followed by `/v1`. Send +Set `OPENAI_BASE_URL` to the installation's API base URL (`api_base_url` in +`GET /core/v1/installation`: the public URL followed by `/v1`) and `OPENAI_API_KEY` +to a Project API key; the official SDK reads both. Raw HTTP callers send `Authorization: Bearer `. The fixed SDK supplies `OpenAI-Beta: agents=v1` for the Beta Agents resources; raw HTTP callers must add it. General Files and Skills endpoints do not require that Beta header. ```python -import os from openai import OpenAI -client = OpenAI( - api_key=os.environ["PARSAR_PROJECT_API_KEY"], - base_url=os.environ["PARSAR_CORE_URL"].rstrip("/") + "/v1", -) +client = OpenAI() # Reads OPENAI_API_KEY and OPENAI_BASE_URL. page = client.beta.agents.list() ``` diff --git a/docs/configuration.md b/docs/configuration.md index 4444383f9..6850f5873 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -50,18 +50,19 @@ flags. Changing `core.env` does not require editing those launcher files. | Parameter | Default / unit | Restrictions and effect | | --- | --- | --- | -| `AGENTS_API_DATABASE_URL` | Required PostgreSQL connection string; generated for the dedicated database | Includes pool settings; keep credentials private | +| `AGENTS_API_DATABASE_URL` | Required PostgreSQL connection string; generated for the dedicated database without a password | Includes pool settings; with `AGENTS_API_DATABASE_PASSWORD_FILE`, the password comes only from that file | | Pool options in the URL | Existing pgx defaults: `pool_max_conns=max(4, CPU count)`, `pool_min_conns=0`, `pool_max_conn_lifetime=1h`, `pool_max_conn_idle_time=30m`, `pool_health_check_period=1m` | For example append `&pool_max_conns=16`; counts are integers and times are Go durations; no separate pool env layer | | `AGENTS_API_ADDR` | Binary: `127.0.0.1:8091`; installer: container `:8091` or native loopback installation port | Listen address; container port publishing and reverse-proxy routing are deployment wiring and must match | | `AGENTS_API_EXECUTION_CONCURRENCY` | `4` concurrent execution work units | Integer `1..1024`; explicit empty/invalid values fail startup; unrelated to node sandbox capacity | -| `PARSAR_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` | +| `PARSAR_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error`; Web reads the same three logging settings | | `PARSAR_LOG_FORMAT` | Automatic for the output destination | `text` or `json` | | `PARSAR_LOG_ADD_SOURCE` | Disabled | `1` enables source locations; `0` disables | | `AGENTS_API_WRITE_AUDIT_RETENTION` | `2160h` (90 days) | Go duration, minimum `1h`; permanent creation ownership is retained | | `AGENTS_API_ENGINE` | `codex` | Default harness; accepted Session choices remain frozen | | `AGENTS_API_HARNESSES` | Binary: default harness; installer: `codex,claude_sdk,mcode` | Comma-separated supported harness IDs; unknown IDs fail startup | -| `AGENTS_API_DAEMON_WS_URL` | Installer-generated reachable WebSocket URL | Enables daemon transport and advertises self-hosted connectivity; hosted bootstrap uses the saved deployment origin | -| `AGENTS_API_CONFIG_FILE` | Installer-generated absolute loaded-file path | Diagnostic marker only, not a loader; preserve it | +| `AGENTS_API_PUBLIC_URL` | Installer: `--public-url`, or `http://127.0.0.1:` without one | The one origin applications, nodes, sandbox guests and self-hosted executors use: canonical HTTPS origin without path, or HTTP only for a loopback host. Enables daemon transport; Core derives the daemon WebSocket URL, self-hosted `remote_url`, hosted bootstrap and the deployment's read-only `core_url` from it. Required with `AGENTS_API_SANDBOX_INSTALLATION_ID`. The retired `AGENTS_API_DAEMON_WS_URL` fails startup | +| `AGENTS_API_DATABASE_PASSWORD_FILE` | Installer: private `database.password` path | The database password, which `AGENTS_API_DATABASE_URL` then must not contain; also read by migrations and the maintenance commands | +| `AGENTS_API_SETTINGS_FILE` | Unset | The installer's non-secret settings snapshot, reported by `GET /core/v1/installation`; Core does not act on it. The retired `AGENTS_API_CONFIG_FILE` fails startup | | `AGENTS_API_SANDBOX_INSTALLATION_ID` | Installer-generated canonical UUID | Stable identity pinned to the database, not provider selection; preserve it | | `AGENTS_API_CORE_KEY_DIGESTS_FILE` | Generated private `admin/core-key-digests.json` path | JSON array of [Core key](getting-started/operations.md#core-key) SHA-256 digests that authorize `/core/v1`; the key itself stays in Web's `CORE_CONSOLE_CORE_KEY_FILE`. The old name `AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE` fails startup | | `AGENTS_API_CREDENTIAL_KEY_FILE` | Generated private encryption-key path | Preserve with the database; never regenerate to repair credentials | @@ -137,7 +138,8 @@ Microsandbox uses both limits. Docker never suspends, so its `max_retained` alwa equals `max_active`; Core replaces any submitted value. The generated private `provider.json` includes the provider, installation identity, -Core address, generation, approved specification copy and one adapter object: +Core address (the installation public URL when the node enrolled), generation, +approved specification copy and one adapter object: - Docker: explicit Unix `host`, locally imported `image`, `network`, `extra_hosts` and absolute `seccomp_file`, with the existing nested-sandbox security settings. @@ -156,7 +158,9 @@ Keep the private identity/state directory and backend storage together. Do not copy node identities, replace a backend directory, or delete snapshots to fix a connection problem. Reinstall retains local configuration and identity; changes in provider, installation, Core address or release are refused. This is not an -upgrade or cross-provider migration tool. +upgrade or cross-provider migration tool. Core records the address each node +enrolled with. After the installation public URL changes, such a node receives no +new sandboxes: remove it in Web and add it again with a fresh state directory. ## Daemon and identity diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 477370dad..357480f7f 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -17,23 +17,21 @@ on Web's **Projects and keys** page or with the [Core key](operations.md#core-ke as Bearer credential: `POST /core/v1/projects` with `{"name":"Default"}`, then `POST /core/v1/projects/{project_id}/keys` with a descriptive `{"name":"..."}`. -Obtain that key through a private channel and supply it as `PARSAR_API_KEY` in your -application configuration. Its plaintext appears only at issuance; Core stores a -digest in its database. All keys in the Project share assets, permissions and the -same execution principal, while write provenance records the actual key. Other -Projects remain isolated. The installer creates neither a Project nor an API key. -For a remote installation, use its HTTPS API endpoint routed directly to Core; the -console does not proxy `/v1`. +Obtain that key through a private channel and set it as `OPENAI_API_KEY` in your +application's environment. Set `OPENAI_BASE_URL` to the installation's API base +URL: `api_base_url` in `GET /core/v1/installation`, which is the public URL followed +by `/v1`, such as `https://core.example/v1`. The official SDK reads both variables. +The key's plaintext appears only at issuance; Core stores a digest in its database. +All keys in the Project share assets, permissions and the same execution principal, +while write provenance records the actual key. Other Projects remain isolated. The +installer creates neither a Project nor an API key. The API base URL reaches Core +directly; the console does not proxy `/v1`. ```python -import os from openai import OpenAI -client = OpenAI( - base_url="http://127.0.0.1:8091/v1", - api_key=os.environ["PARSAR_API_KEY"], - default_headers={"OpenAI-Beta": "agents=v1"}, -) +# Reads OPENAI_API_KEY and OPENAI_BASE_URL. +client = OpenAI(default_headers={"OpenAI-Beta": "agents=v1"}) print(client.beta.agents.list().data) ``` diff --git a/packages/agents-client/src/admin-client.test.ts b/packages/agents-client/src/admin-client.test.ts index 3f531086e..f86389702 100644 --- a/packages/agents-client/src/admin-client.test.ts +++ b/packages/agents-client/src/admin-client.test.ts @@ -24,6 +24,7 @@ function clientWith(value: unknown) { const page = (data: Array<{ id: string }>) => ({ object: "list", data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null }); const routeCases: Array<[string, string, (client: AdminClient) => Promise]> = [ + ["GET", "/installation", (client) => client.retrieveInstallation()], ["GET", "/projects", (client) => client.listProjects()], ["POST", "/projects", (client) => client.createProject({ name: "Research" })], ["POST", `/projects/${projectId}`, (client) => client.renameProject(projectId, { name: "Renamed" })], @@ -281,6 +282,32 @@ describe("AdminClient deployment read models", () => { }); }); +describe("AdminClient installation", () => { + const port = { key: "ports.core", value: 8091, default: 8091, changeable: true, sensitive: false, restarts: ["core"] }; + const headers = { key: "core.runtime_history.headers", value: null, default: null, configured: true, changeable: true, sensitive: true, restarts: ["core"] }; + const installation = { + object: "core.installation", installation_id: resourceId, public_url: "https://core.example", api_base_url: "https://core.example/v1", + local_only: false, source_commit: "a".repeat(40), + configuration: { path: "/home/alice/.parsar/core/config.json", apply_command: "/home/alice/.parsar/core/parsar apply", applied_at: "2026-09-25T09:30:00Z", settings: [port, headers] }, + address_bindings: { nodes: 2, nodes_on_other_address: 1, hosted_sandboxes: 3, self_hosted_executors: 1 }, + }; + it("reads installation facts before any deployment and rejects inconsistent snapshots", async () => { + expect(await clientWith(installation).client.retrieveInstallation()).toEqual(installation); + expect(await clientWith({ ...installation, installation_id: null, public_url: null, api_base_url: null, source_commit: null, configuration: null }).client.retrieveInstallation()).toMatchObject({ public_url: null }); + const configuration = (settings: unknown[]) => ({ ...installation, configuration: { ...installation.configuration, settings } }); + for (const invalid of [ + configuration([port, { ...headers, value: { authorization: "leak" } }]), + configuration([port, { key: headers.key, value: null, default: null, changeable: true, sensitive: true, restarts: ["core"] }]), + configuration([port, port]), + { ...installation, address_bindings: { ...installation.address_bindings, nodes_on_other_address: 3 } }, + { ...installation, token: "leak" }, + configuration([{ ...port, configured: true }]), + ]) { + await expect(clientWith(invalid).client.retrieveInstallation()).rejects.toMatchObject({ code: "invalid_admin_response" }); + } + }); +}); + describe("AdminClient project lifecycle", () => { it("creates projects with server-owned IDs and lists their metadata", async () => { const { client, fetch } = clientWith(project); diff --git a/packages/agents-client/src/admin-client.ts b/packages/agents-client/src/admin-client.ts index 2d81b63d4..215c3bcfb 100644 --- a/packages/agents-client/src/admin-client.ts +++ b/packages/agents-client/src/admin-client.ts @@ -11,13 +11,13 @@ import { CoreRequester } from "./core-request"; import { invalidAdminResponse, projectAdminProject, projectAdminKey, projectIssuedAdminKey, projectAdminPage, projectAdminDeleted, projectAdminSessionArchive, projectResourcePage, projectSavedAgent, projectSkill, projectSkillVersion, projectArtifact, projectSummary, projectAdminRuntimePage, projectResourceOwners, projectWriteOperations, projectAdminAudit, - projectExecutorCredentials, projectIssuedExecutorCredential, + projectExecutorCredentials, projectIssuedExecutorCredential, projectInstallation, } from "./admin-projection"; import type { PageOptions, ReadOptions, RuntimeHistoryQuery, SkillList, SkillVersionDeleted, SkillVersionList, SourceFileList, VaultListOptions } from "./types"; import type { AdminClientOptions, AdminAuditOptions, AdminContent, ArchiveAdminSessionInput, CreateAdminProjectInput, RenameAdminProjectInput, IssueAdminAPIKeyInput, AdminSummaryOptions, AdminResourceType, AdminResourceOwner, AdminWriteOperationOptions, AdminWriteOperationPage, - ExecutorCredential, IssueExecutorCredentialInput, IssuedExecutorCredential, + ExecutorCredential, IssueExecutorCredentialInput, IssuedExecutorCredential, CoreInstallation, } from "./admin-types"; function segment(value: string): string { @@ -67,6 +67,10 @@ export class AdminClient { return { blob: await response.blob(), contentType: response.headers.get("Content-Type"), contentDisposition: response.headers.get("Content-Disposition") }; } + /** Installation facts and process settings; readable before any sandbox deployment exists. */ + async retrieveInstallation(options?: ReadOptions): Promise { + return projectInstallation(await this.#json("/installation", options)); + } async listProjects(options?: PageOptions) { return projectAdminPage(await this.#json(pageQuery("/projects", options), options), (value) => projectAdminProject(value)); } diff --git a/packages/agents-client/src/admin-projection.ts b/packages/agents-client/src/admin-projection.ts index 3d2494ece..6c67ff5c3 100644 --- a/packages/agents-client/src/admin-projection.ts +++ b/packages/agents-client/src/admin-projection.ts @@ -2,7 +2,7 @@ import { AgentCoreError, projectRuntimeObservation, projectSavedAgentConfigurati import { projectTokenUsage } from "./usage-projection"; import { canonicalUuid, exactFields, isNonnegativeInteger, isRecord, sameResourceId } from "./response-projection"; import type { ListPage, SavedAgent } from "./types"; -import type { AdminAPIKey, AdminProject, AdminAuditPage, AdminSummary, AdminRuntimeObservation, RuntimeDiskObservation, AdminKeyProvenance, AdminResourceOwner, AdminWriteOperationPage, AdminAuditResultID, AdminDeleted, AdminIssuedAPIKey, AdminPage, AdminSessionArchive, SessionArtifact, Skill, SkillVersion, ExecutorCredential, IssuedExecutorCredential } from "./admin-types"; +import type { AdminAPIKey, AdminProject, AdminAuditPage, AdminSummary, AdminRuntimeObservation, RuntimeDiskObservation, AdminKeyProvenance, AdminResourceOwner, AdminWriteOperationPage, AdminAuditResultID, AdminDeleted, AdminIssuedAPIKey, AdminPage, AdminSessionArchive, SessionArtifact, Skill, SkillVersion, ExecutorCredential, IssuedExecutorCredential, CoreInstallation, CoreInstallationSetting } from "./admin-types"; export function invalidAdminResponse(): never { throw new AgentCoreError("Core returned an invalid administration response.", 502, "invalid_admin_response"); @@ -214,3 +214,37 @@ export function projectIssuedExecutorCredential(value: unknown, keyId: string, e typeof issued.executor_token !== "string" || !issued.executor_token) return invalidAdminResponse(); return { key_id: issued.key_id, environment_id: issued.environment_id, executor_token: issued.executor_token }; } + +const settingKey = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/; +const settingServices = new Set(["core", "web", "database"]); +function projectInstallationSetting(value: unknown): CoreInstallationSetting { + const base = ["key", "value", "default", "changeable", "sensitive", "restarts"]; + if (!isRecord(value) || typeof value.sensitive !== "boolean") return invalidAdminResponse(); + const setting = record(value, value.sensitive ? [...base, "configured"] : base); + if (typeof setting.key !== "string" || !settingKey.test(setting.key) || typeof setting.changeable !== "boolean" || + !Array.isArray(setting.restarts) || !setting.restarts.every((service) => typeof service === "string" && settingServices.has(service)) || + (setting.sensitive && (setting.value !== null || setting.default !== null || typeof setting.configured !== "boolean"))) return invalidAdminResponse(); + return { ...setting } as unknown as CoreInstallationSetting; +} +/** Sensitive settings carry no value, keys are unique and binding counts are consistent. */ +export function projectInstallation(value: unknown): CoreInstallation { + const installation = record(value, ["object", "installation_id", "public_url", "api_base_url", "local_only", "source_commit", "configuration", "address_bindings"]); + const origin = installation.public_url; + if (installation.object !== "core.installation" || (installation.installation_id !== null && canonicalUuid(installation.installation_id) === null) || + (origin !== null && typeof origin !== "string") || installation.api_base_url !== (typeof origin === "string" ? `${origin}/v1` : null) || + typeof installation.local_only !== "boolean" || + (installation.source_commit !== null && (typeof installation.source_commit !== "string" || !/^[0-9a-f]{40}$/.test(installation.source_commit)))) return invalidAdminResponse(); + const bindings = record(installation.address_bindings, ["nodes", "nodes_on_other_address", "hosted_sandboxes", "self_hosted_executors"]); + if (![bindings.nodes, bindings.nodes_on_other_address, bindings.hosted_sandboxes, bindings.self_hosted_executors].every(isNonnegativeInteger) || + (bindings.nodes_on_other_address as number) > (bindings.nodes as number)) return invalidAdminResponse(); + let configuration: CoreInstallation["configuration"] = null; + if (installation.configuration !== null) { + const applied = record(installation.configuration, ["path", "apply_command", "applied_at", "settings"]); + if (typeof applied.path !== "string" || !applied.path.startsWith("/") || typeof applied.apply_command !== "string" || !applied.apply_command || + typeof applied.applied_at !== "string" || !date(applied.applied_at) || !Array.isArray(applied.settings)) return invalidAdminResponse(); + const settings = applied.settings.map(projectInstallationSetting); + if (new Set(settings.map((setting) => setting.key)).size !== settings.length) return invalidAdminResponse(); + configuration = { path: applied.path, apply_command: applied.apply_command, applied_at: applied.applied_at, settings }; + } + return { ...installation, address_bindings: { ...bindings }, configuration } as unknown as CoreInstallation; +} diff --git a/packages/agents-client/src/admin-types.ts b/packages/agents-client/src/admin-types.ts index 1f1a5efae..f9ea9023b 100644 --- a/packages/agents-client/src/admin-types.ts +++ b/packages/agents-client/src/admin-types.ts @@ -164,6 +164,58 @@ export interface IssueExecutorCredentialInput { key_id: string; rotate?: boolean /** Returned once, on issuance or rotation. */ export interface IssuedExecutorCredential { key_id: string; environment_id: string; executor_token: string } +/** What is bound to the installation public URL; a change of that URL affects all of it. */ +export interface CoreAddressBindings { + /** Enrolled nodes that are not removed. */ + nodes: number; + /** Nodes enrolled with another address; they receive no new sandboxes until re-added. At most `nodes`. */ + nodes_on_other_address: number; + /** Retained and pending hosted sandboxes, which were started with the address current at the time. */ + hosted_sandboxes: number; + /** Unrevoked self-hosted executor credentials, whose executors were installed with an advertised remote_url. */ + self_hosted_executors: number; +} +/** One process setting from the installation's config.json, as last applied. */ +export interface CoreInstallationSetting { + /** Dotted config.json key, such as `ports.core`. Unique within the snapshot. */ + key: string; + /** Applied JSON value; always null for a sensitive setting. */ + value: unknown; + default: unknown; + /** Present exactly for a sensitive setting: whether it has a value. */ + configured?: boolean; + /** False for settings fixed at installation. */ + changeable: boolean; + sensitive: boolean; + /** Services that restart when the setting changes. */ + restarts: ("core" | "web" | "database")[]; +} +/** Where process settings change, and their last applied values. */ +export interface CoreInstallationConfiguration { + /** Absolute host path of config.json. */ + path: string; + /** Command that applies config.json changes. */ + apply_command: string; + applied_at: string; + settings: CoreInstallationSetting[]; +} +/** `GET /core/v1/installation`: available before any sandbox deployment exists. */ +export interface CoreInstallation { + object: "core.installation"; + installation_id: string | null; + /** The origin applications, nodes, sandboxes and self-hosted executors use; null when Core runs without one. */ + public_url: string | null; + /** `public_url` followed by `/v1`; the base URL for application API keys. */ + api_base_url: string | null; + /** True when `public_url` is a loopback origin that only the Core host reaches. */ + local_only: boolean; + /** Full source commit Core was built from; null for development builds. */ + source_commit: string | null; + /** Null when the installer did not start Core. */ + configuration: CoreInstallationConfiguration | null; + address_bindings: CoreAddressBindings; +} + /** * The project-bound reads and deletions the console makes through Core, in * the public projections' shapes: each method is a Core client method with diff --git a/packages/agents-client/src/index.ts b/packages/agents-client/src/index.ts index 3f2c90970..0cb31e6b2 100644 --- a/packages/agents-client/src/index.ts +++ b/packages/agents-client/src/index.ts @@ -11,4 +11,4 @@ export { isOpenAIHostedSessionEnvironment } from "./session-environment-projecti export { compareSkillVersionNumbers, isSkillId, isSkillUploadPath, isSkillVersionId, isSkillVersionNumber, maxSkillUploadFiles } from "./skill-projection"; export { AdminClient } from "./admin-client"; // Skill and SkillVersion come from ./types; the admin projections use the same shapes. -export type { AdminClientOptions, AdminProject, CreateAdminProjectInput, RenameAdminProjectInput, AdminAPIKey, AdminIssuedAPIKey, IssueAdminAPIKeyInput, AdminPage, AdminDeleted, SessionArtifact, AdminContent, AdminResourceType, AdminKeyProvenance, AdminResourceOwner, AdminWriteOperation, AdminWriteOperationOptions, AdminWriteOperationPage, AdminSummaryOptions, AdminSummaryEntry, AdminSummary, AdminRuntimeObservation, AdminAuditOptions, AdminAuditResultID, AdminAuditEntry, AdminAuditPage, ExecutorCredential, IssueExecutorCredentialInput, IssuedExecutorCredential, CoreProjectReader } from "./admin-types"; +export type { AdminClientOptions, AdminProject, CreateAdminProjectInput, RenameAdminProjectInput, AdminAPIKey, AdminIssuedAPIKey, IssueAdminAPIKeyInput, AdminPage, AdminDeleted, SessionArtifact, AdminContent, AdminResourceType, AdminKeyProvenance, AdminResourceOwner, AdminWriteOperation, AdminWriteOperationOptions, AdminWriteOperationPage, AdminSummaryOptions, AdminSummaryEntry, AdminSummary, AdminRuntimeObservation, AdminAuditOptions, AdminAuditResultID, AdminAuditEntry, AdminAuditPage, ExecutorCredential, IssueExecutorCredentialInput, IssuedExecutorCredential, CoreProjectReader, CoreInstallation, CoreInstallationConfiguration, CoreInstallationSetting, CoreAddressBindings } from "./admin-types"; diff --git a/packages/agents-client/src/sandbox-client.test.ts b/packages/agents-client/src/sandbox-client.test.ts index 5e6fa48b0..e47460981 100644 --- a/packages/agents-client/src/sandbox-client.test.ts +++ b/packages/agents-client/src/sandbox-client.test.ts @@ -37,16 +37,16 @@ describe("Core sandbox credential boundaries", () => { expect(fetch).toHaveBeenCalledTimes(1); }); - it("initializes using only the explicit provider and origin with cancellation and admin credentials", async () => { + it("initializes using only the explicit provider with cancellation and admin credentials", async () => { const deployment = { installation_id: "installation", provider: "docker", core_url: "https://core.example", maintenance: false, owner_epoch: 1 }; const fetch = vi.fn().mockResolvedValue(response(deployment)); const admin = new SandboxAdminClient({ baseUrl: "/core/v1/sandbox", token: "admin-only", fetch }); const controller = new AbortController(); - expect(await admin.initializeDeployment({ provider: "docker", core_url: "https://core.example" }, { signal: controller.signal })).toEqual(deployment); + expect(await admin.initializeDeployment({ provider: "docker" }, { signal: controller.signal })).toEqual(deployment); const [url, init] = fetch.mock.calls[0]!; expect(url).toBe("/core/v1/sandbox/deployment"); expect(init?.method).toBe("POST"); - expect(JSON.parse(String(init?.body))).toEqual({ provider: "docker", core_url: "https://core.example" }); + expect(JSON.parse(String(init?.body))).toEqual({ provider: "docker" }); expect(init?.signal).toBe(controller.signal); expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer admin-only"); expect(new Headers(init?.headers).has("OpenAI-Beta")).toBe(false); @@ -54,7 +54,7 @@ describe("Core sandbox credential boundaries", () => { it("forwards one specification with generation and preserves backend conflict details", async () => { const fetch = vi.fn().mockResolvedValue(response({ error: { code: "sandbox_specification_mismatch", message: "Node specification differs" } }, 409)); const admin = new SandboxAdminClient({ token: "admin-only", fetch }); - const input = { provider: "docker" as const, core_url: "https://core.example", resources: { cpus: 2, memory_mib: 2048 }, runtime: { source_commit: "a".repeat(40), image_id: "sha256:" + "b".repeat(64), image_manifest_digest: "sha256:" + "c".repeat(64), microsandbox_ref: "parsar-core-runtime@sha256:" + "d".repeat(64), runtime_sha256: "e".repeat(64), firmware_sha256: "f".repeat(64) }, expected_generation: 3 }; + const input = { provider: "docker" as const, resources: { cpus: 2, memory_mib: 2048 }, runtime: { source_commit: "a".repeat(40), image_id: "sha256:" + "b".repeat(64), image_manifest_digest: "sha256:" + "c".repeat(64), microsandbox_ref: "parsar-core-runtime@sha256:" + "d".repeat(64), runtime_sha256: "e".repeat(64), firmware_sha256: "f".repeat(64) }, expected_generation: 3 }; await expect(admin.updateDeployment(input)).rejects.toMatchObject({ status: 409, code: "sandbox_specification_mismatch" }); expect(fetch).toHaveBeenCalledTimes(1); expect(JSON.parse(String(fetch.mock.calls[0]?.[1]?.body))).toEqual(input); @@ -62,13 +62,13 @@ describe("Core sandbox credential boundaries", () => { it.each([409, 503])("does not retry initialization after HTTP %s", async (status) => { const fetch = vi.fn().mockResolvedValue(response({ error: { message: "Setup failed", code: "sandbox_deployment_conflict" } }, status)); const admin = new SandboxAdminClient({ baseUrl: "/core/v1/sandbox", token: "admin", fetch }); - await expect(admin.initializeDeployment({ provider: "microsandbox", core_url: "https://core.example" })).rejects.toThrow("Setup failed"); + await expect(admin.initializeDeployment({ provider: "microsandbox" })).rejects.toThrow("Setup failed"); expect(fetch).toHaveBeenCalledTimes(1); }); it("does not retry an uncertain initialization transport failure", async () => { const fetch = vi.fn().mockRejectedValue(new TypeError("Connection lost")); const admin = new SandboxAdminClient({ baseUrl: "/core/v1/sandbox", token: "admin", fetch }); - await expect(admin.initializeDeployment({ provider: "docker", core_url: "https://core.example" })).rejects.toThrow("Connection lost"); + await expect(admin.initializeDeployment({ provider: "docker" })).rejects.toThrow("Connection lost"); expect(fetch).toHaveBeenCalledTimes(1); }); it("uses the explicit admin credential and admin routes without a project beta header", async () => { @@ -107,13 +107,13 @@ describe("hosted provider configuration", () => { const client = new SandboxAdminClient({ baseUrl: "/core/v1/sandbox", fetch }); const controller = new AbortController(); // Omitted E2B resources are filled from the validated template build. - expect(await client.initializeDeployment({ provider: "e2b", core_url: "https://core.example", e2b })).toEqual(deployment); - await client.updateDeployment({ provider: "e2b", core_url: "https://core.example", e2b, expected_generation: 1 }, { signal: controller.signal }); + expect(await client.initializeDeployment({ provider: "e2b", e2b })).toEqual(deployment); + await client.updateDeployment({ provider: "e2b", e2b, expected_generation: 1 }, { signal: controller.signal }); await client.setMaintenance({ maintenance: false, expected_generation: 2 }); expect(fetch.mock.calls.map(([url, init]) => [url, init?.method])).toEqual([ ["/core/v1/sandbox/deployment", "POST"], ["/core/v1/sandbox/deployment", "PUT"], ["/core/v1/sandbox/deployment/maintenance", "PATCH"], ]); - expect(JSON.parse(String(fetch.mock.calls[1]?.[1]?.body))).toEqual({ provider: "e2b", core_url: "https://core.example", e2b, expected_generation: 1 }); + expect(JSON.parse(String(fetch.mock.calls[1]?.[1]?.body))).toEqual({ provider: "e2b", e2b, expected_generation: 1 }); expect(fetch.mock.calls[1]?.[1]?.signal).toBe(controller.signal); expect(JSON.parse(String(fetch.mock.calls[2]?.[1]?.body))).toEqual({ maintenance: false, expected_generation: 2 }); for (const [, init] of fetch.mock.calls) { @@ -124,17 +124,26 @@ describe("hosted provider configuration", () => { it.each([409, 503])("does not expose reflected E2B keys or retry configuration after HTTP %s", async (status) => { const fetch = vi.fn().mockResolvedValue(response({ error: { message: e2b.api_key, code: e2b.api_key, param: e2b.api_key } }, status)); const client = new SandboxAdminClient({ fetch }); - await expect(client.updateDeployment({ provider: "e2b", core_url: "https://core.example", e2b, expected_generation: 1 })).rejects.toMatchObject({ code: "sandbox_configuration_unconfirmed", status }); - await client.initializeDeployment({ provider: "e2b", core_url: "https://core.example", e2b }).catch((error) => { + await expect(client.updateDeployment({ provider: "e2b", e2b, expected_generation: 1 })).rejects.toMatchObject({ code: "sandbox_configuration_unconfirmed", status }); + await client.initializeDeployment({ provider: "e2b", e2b }).catch((error) => { expect(JSON.stringify(error)).not.toContain(e2b.api_key); expect(error.message).not.toContain(e2b.api_key); }); expect(fetch).toHaveBeenCalledTimes(2); }); + it("shows Core's public-URL rejection unless it reflects the E2B key", async () => { + const rejection = { message: "E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback.", code: "sandbox_configuration_error", param: null }; + const fetch = vi.fn() + .mockResolvedValueOnce(response({ error: rejection }, 409)) + .mockResolvedValueOnce(response({ error: { ...rejection, message: rejection.message + e2b.api_key } }, 409)); + const client = new SandboxAdminClient({ fetch }); + await expect(client.initializeDeployment({ provider: "e2b", e2b })).rejects.toMatchObject({ status: 409, code: "sandbox_configuration_error", message: rejection.message, param: null }); + await expect(client.initializeDeployment({ provider: "e2b", e2b })).rejects.toMatchObject({ status: 409, code: "sandbox_configuration_unconfirmed" }); + }); it("never retries uncertain switch or maintenance writes", async () => { const fetch = vi.fn().mockRejectedValue(new TypeError("Connection lost")); const client = new SandboxAdminClient({ fetch }); - await expect(client.updateDeployment({ provider: "docker", core_url: "https://core.example", expected_generation: 1 })).rejects.toThrow(); + await expect(client.updateDeployment({ provider: "docker", expected_generation: 1 })).rejects.toThrow(); await expect(client.setMaintenance({ maintenance: true, expected_generation: 1 })).rejects.toThrow(); expect(fetch).toHaveBeenCalledTimes(2); }); diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts index 5900293f0..5626615a1 100644 --- a/packages/agents-client/src/sandbox-client.ts +++ b/packages/agents-client/src/sandbox-client.ts @@ -19,9 +19,9 @@ export type SandboxProvider = "docker" | "microsandbox" | "e2b"; export interface SandboxResources { cpus: number; memory_mib: number; root_disk_mib?: number; environment_disk_mib?: number } export interface SandboxRuntimeRelease { source_commit: string; image_id: string; image_manifest_digest: string; microsandbox_ref: string; runtime_sha256: string; firmware_sha256: string } export interface SandboxSpecification { resources: SandboxResources; runtime?: SandboxRuntimeRelease } +/** Core derives the deployment's address from the installation public URL; a `core_url` member is rejected. */ export interface InitializeSandboxDeployment { provider: SandboxProvider; - core_url: string; /** Required for Docker/microsandbox. E2B may omit it to adopt its validated template build's CPU and memory. */ resources?: SandboxResources; /** Required for Docker/microsandbox; E2B uses its fixed template build. */ @@ -36,7 +36,8 @@ export interface SandboxDeployment { specification_digest?: string; installation_id: string; provider: SandboxProvider | ""; - core_url: string; + /** Read-only: the installation public URL, which nodes and sandboxes use to reach Core. Present before configuration. */ + readonly core_url: string; maintenance: boolean; owner_epoch: number; generation: number; @@ -72,6 +73,8 @@ export interface SandboxNode { retained: number; cleanup_pending: number; created_at: string; + /** Read-only: the Core address this node enrolled with. When it differs from the installation public URL, the node receives no new sandboxes and must be re-added. */ + readonly core_url: string; } /** The node machine's last heartbeat observation; unavailable measurements are null. */ export interface SandboxNodeHost { @@ -147,7 +150,11 @@ export class SandboxAdminClient { return await this.#json("/deployment", options, method, input); } catch (error) { if (!input.e2b) throw error; - // A credential-bearing rejection may reflect the key in any error field. + // The public-URL rejection is safe to show unless it somehow reflects the key. + const key = input.e2b.api_key; + if (error instanceof AgentCoreError && error.status === 409 && error.code === "sandbox_configuration_error" + && !error.message.includes(key) && !(error.param ?? "").includes(key)) throw error; + // Any other credential-bearing rejection may reflect the key in any error field. throw new AgentCoreError("Sandbox configuration could not be confirmed. Refresh before submitting again.", error instanceof AgentCoreError ? error.status : 0, "sandbox_configuration_unconfirmed"); } } diff --git a/services/agents-api/CONTAINER.md b/services/agents-api/CONTAINER.md index 3ccd6d21e..f57992b3a 100644 --- a/services/agents-api/CONTAINER.md +++ b/services/agents-api/CONTAINER.md @@ -70,8 +70,9 @@ active service per execution database; container replicas do not add HA/recovery ## Connect execution -Set `AGENTS_API_DAEMON_WS_URL` in `api.env` to the API's externally reachable daemon -WebSocket URL, then start the container. Provision a device using this image with +Set `AGENTS_API_PUBLIC_URL` in `api.env` to the API's externally reachable origin, +such as `https://core.example`, then start the container. Core derives the daemon +WebSocket URL from it. Provision a device using this image with `/usr/local/bin/agents-api-device` as the command and the arguments documented in [Internal execution device connection](README.md#internal-execution-device-connection). Pass the same private environment file. The operator command emits a secret profile; diff --git a/services/agents-api/HOSTED-RELEASE.md b/services/agents-api/HOSTED-RELEASE.md index 0daa8ee2a..5d62f9898 100644 --- a/services/agents-api/HOSTED-RELEASE.md +++ b/services/agents-api/HOSTED-RELEASE.md @@ -60,7 +60,7 @@ nodes and sandbox guests reach through your TLS proxy: ```sh export AGENTS_API_ADDR=0.0.0.0:8091 export AGENTS_API_SANDBOX_INSTALLATION_ID="" -export AGENTS_API_DAEMON_WS_URL="wss://core.example/api/v1/agent-daemon/ws" +export AGENTS_API_PUBLIC_URL="https://core.example" export AGENTS_API_EXECUTION_OPTIONS_FILE="$PARSAR_HOME/execution-options.json" ``` @@ -91,8 +91,8 @@ chmod 0600 "$AGENTS_API_CORE_KEY_DIGESTS_FILE" "$AGENTS_API_EXECUTION_OPTIONS_FI "$AGENTS_API_BIN_DIR/agents-api" ``` -Then select `docker` with `https://core.example` as `core_url`, the per-sandbox -resources and the complete Runtime release, and register a node on a host where +Then select `docker`, the per-sandbox resources and the complete Runtime release +(the deployment's `core_url` comes from `AGENTS_API_PUBLIC_URL`), and register a node on a host where this image is loaded. Hosted admission starts when a ready node has capacity. Use your existing service supervisor for long-running operation. Migrations are diff --git a/services/agents-api/HOSTED-SANDBOX-MANAGER.md b/services/agents-api/HOSTED-SANDBOX-MANAGER.md index 1b4caafc6..9d13d85de 100644 --- a/services/agents-api/HOSTED-SANDBOX-MANAGER.md +++ b/services/agents-api/HOSTED-SANDBOX-MANAGER.md @@ -43,13 +43,11 @@ ready before the selection can be saved. E2B CPU/memory limits are optional: Web sends none, and Core adopts the build's size; supplied limits must match the build. The key is write-only, encrypted by Core and never returned to the browser. E2B needs no node installation. -The saved Core origin defaults to the paired console's public origin, where the -reverse proxy sends `/api/v1`, including WebSocket upgrades, directly to Core; Web -does not forward it. Use advanced network settings only when nodes and guests need -a different public HTTPS origin. When the inferred address is loopback or is not -HTTPS, setup opens the network field and requires a non-loopback HTTPS origin before saving. -The API still accepts HTTP loopback for explicit local development; a guest's -loopback address cannot reach its Core host. +Setup takes no Core address. Nodes and guests use the installation public URL, +where the reverse proxy sends `/api/v1`, including WebSocket upgrades, directly to +Core; Web does not forward it. The deployment reports it as read-only `core_url`. +E2B requires a public URL that is not loopback; a loopback public URL serves only +local development, because a guest's loopback address cannot reach its Core host. **Save configuration** validates and initializes the deployment without creating compute. A failed candidate leaves the previous selection intact. Refresh after an uncertain response before @@ -99,9 +97,9 @@ fails until setup is complete and a ready node has capacity. E2B allocates direc For manual zero-node deployments, set `AGENTS_API_SANDBOX_INSTALLATION_ID` to a stable UUID, configure `AGENTS_API_CORE_KEY_DIGESTS_FILE`, and enable the daemon -gateway using `AGENTS_API_DAEMON_WS_URL`. Do not also set -`AGENTS_API_MANAGED_RUNTIMES_FILE`. The Web-selected origin supplies hosted Runtime -bootstrap and its public daemon WebSocket address; it never uses request Host or +gateway with `AGENTS_API_PUBLIC_URL`. Do not also set +`AGENTS_API_MANAGED_RUNTIMES_FILE`. Core derives hosted Runtime bootstrap and the +public daemon WebSocket address from that URL; it never uses request Host or forwarded headers. Preserve the installation UUID and database together. Use the live sandbox deployment response (`GET /core/v1/sandbox/deployment`) for @@ -186,7 +184,7 @@ Manual registration remains available for operator-managed payloads: Build/install `parsar-sandbox-node` from the same Core release. On the host, first read `GET /api/v1/sandbox-node/configuration` with the enrollment Bearer token. Build the private provider JSON from its `provider`, `installation_id`, -`core_url`, `generation` and `specification`, using the existing local backend +`core_url` (the installation public URL), `generation` and `specification`, using the existing local backend schema for the host's socket, runtime paths and network policy. The Runtime image and resource limits must match the server specification. Docker needs access to its local Unix socket and pinned image. Microsandbox needs its qualified runtime, helper, @@ -304,7 +302,7 @@ Maintenance and configuration changes alone perform no cleanup. `maintenance` and `expected_generation`; `PUT /core/v1/sandbox/deployment` takes the provider, complete `resources`/`runtime` selection, any E2B input and `expected_generation`. Both require deployment admin -authority. The public Core origin cannot change in this operation. E2B is not +authority. Neither request takes a Core address. E2B is not combined with own-machine nodes, and existing Sessions never migrate providers. This release has one Core execution owner. It does not add Core multi-active, diff --git a/services/agents-api/README.md b/services/agents-api/README.md index b4f371cfc..a1da7d8ef 100644 --- a/services/agents-api/README.md +++ b/services/agents-api/README.md @@ -295,9 +295,9 @@ other exact hosted failure and expiry semantics remain unverified. The standalone service can accept existing daemon connections without a Parsar workspace or product database. Enable its internal gateway by setting -`AGENTS_API_DAEMON_WS_URL=wss://your-service/api/v1/agent-daemon/ws` (use `ws` -for local development). This configured URL is returned unchanged as -`self_hosted.remote_url`. It names +`AGENTS_API_PUBLIC_URL=https://your-service` (HTTP only for a loopback host during +local development). Core returns the derived +`wss://your-service/api/v1/agent-daemon/ws` as `self_hosted.remote_url`. It names our private daemon transport, not stock OpenAI `exec-server` interoperability. After migrations, an operator can provision a device for an execution tenant: diff --git a/services/agents-api/RELEASE.md b/services/agents-api/RELEASE.md index bdb0ba416..33a55b7df 100644 --- a/services/agents-api/RELEASE.md +++ b/services/agents-api/RELEASE.md @@ -54,10 +54,10 @@ its scope and principal; rotation uses issuance and revocation without a restart For the Docker variant, continue in `HOSTED.md` now to configure the Runtime's outward connection and provider before starting Core. For the basic archive, -set the reachable daemon endpoint: +set the reachable public origin; Core derives the daemon endpoint from it: ```sh -export AGENTS_API_DAEMON_WS_URL=ws://127.0.0.1:8091/api/v1/agent-daemon/ws +export AGENTS_API_PUBLIC_URL=http://127.0.0.1:8091 ``` Keep the configuration and key files mode 0600. Run migrations explicitly, then diff --git a/services/agents-api/cmd/device/main.go b/services/agents-api/cmd/device/main.go index c7bba8edd..a6f645b3a 100644 --- a/services/agents-api/cmd/device/main.go +++ b/services/agents-api/cmd/device/main.go @@ -17,6 +17,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/databaseurl" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -33,7 +34,10 @@ func run() error { serverURL := flag.String("url", "", "Agents API HTTP base URL") revoke := flag.String("revoke", "", "revoke this device UUID instead of provisioning") flag.Parse() - dsn := os.Getenv("AGENTS_API_DATABASE_URL") + dsn, err := databaseurl.FromEnvironment() + if err != nil { + return err + } if dsn == "" || *tenant == "" || flag.NArg() != 0 { return errors.New("AGENTS_API_DATABASE_URL and --tenant are required") } diff --git a/services/agents-api/cmd/environment-key/main.go b/services/agents-api/cmd/environment-key/main.go index 95235b69e..e247cd742 100644 --- a/services/agents-api/cmd/environment-key/main.go +++ b/services/agents-api/cmd/environment-key/main.go @@ -11,6 +11,7 @@ import ( "time" "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/databaseurl" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "github.com/google/uuid" @@ -92,7 +93,10 @@ func run() error { if err != nil { return err } - dsn := os.Getenv("AGENTS_API_DATABASE_URL") + dsn, err := databaseurl.FromEnvironment() + if err != nil { + return err + } if dsn == "" { return errors.New("AGENTS_API_DATABASE_URL must point to a dedicated execution database") } diff --git a/services/agents-api/cmd/migrate/main.go b/services/agents-api/cmd/migrate/main.go index 1df04bf72..6edc9a921 100644 --- a/services/agents-api/cmd/migrate/main.go +++ b/services/agents-api/cmd/migrate/main.go @@ -7,6 +7,7 @@ import ( "time" "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/databaseurl" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/migrations" ) @@ -18,7 +19,10 @@ func main() { } func run() error { - databaseURL := os.Getenv("AGENTS_API_DATABASE_URL") + databaseURL, err := databaseurl.FromEnvironment() + if err != nil { + return err + } if databaseURL == "" { return errors.New("AGENTS_API_DATABASE_URL must point to a dedicated execution database") } diff --git a/services/agents-api/cmd/server/daemon_bootstrap.go b/services/agents-api/cmd/server/daemon_bootstrap.go index 71f2f6025..261dce61d 100644 --- a/services/agents-api/cmd/server/daemon_bootstrap.go +++ b/services/agents-api/cmd/server/daemon_bootstrap.go @@ -8,21 +8,19 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" ) +// webSocketURL answers daemon bootstrap. Every daemon uses the one URL derived +// from AGENTS_API_PUBLIC_URL, except a legacy embedded node's own Runtime. func (m *managedNodes) webSocketURL(publicURL string) func(context.Context, gateway.AuthenticatedRuntime) (string, error) { return func(ctx context.Context, auth gateway.AuthenticatedRuntime) (string, error) { - if m == nil || (auth.RuntimeNodeID == "" && auth.RuntimeAllocationID == "") { - return publicURL, nil - } - if m.runtime != nil && auth.RuntimeNodeID != "" && auth.RuntimeNodeID == m.runtime.LocalNodeID { + if m != nil && m.runtime != nil && auth.RuntimeNodeID != "" && auth.RuntimeNodeID == m.runtime.LocalNodeID { return runtimeWebSocketURL(m.runtime.CoreURL) } - if m.setup != nil { - return m.setup.webSocketURL(publicURL)(ctx) - } return publicURL, nil } } +// runtimeWebSocketURL derives the daemon WebSocket URL from a Core origin or +// its /api/v1 base. func runtimeWebSocketURL(coreURL string) (string, error) { u, err := url.Parse(coreURL) if err != nil || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { diff --git a/services/agents-api/cmd/server/daemon_bootstrap_test.go b/services/agents-api/cmd/server/daemon_bootstrap_test.go index 83fa75d18..04636a69e 100644 --- a/services/agents-api/cmd/server/daemon_bootstrap_test.go +++ b/services/agents-api/cmd/server/daemon_bootstrap_test.go @@ -26,7 +26,6 @@ func (s bootstrapCredentialStore) GetDeviceCredential(context.Context, string) ( func TestBootstrapAddressFollowsAuthenticatedAllocation(t *testing.T) { const publicURL = "wss://private-proxy.example/api/v1/agent-daemon/ws" - const selectedURL = "wss://selected-node-entry.example/api/v1/agent-daemon/ws" local := &managedNodes{runtime: &execution.RuntimeProvider{LocalNodeID: "local-node", CoreURL: "http://host.microsandbox.internal:8091/api/v1"}} remote := &managedNodes{setup: &managedSetup{store: &setupStore{value: store.SandboxSetup{Provider: "docker", Generation: 1}}}} remote.setup.selected.Store(&execution.RuntimeProvider{CoreURL: "https://selected-node-entry.example/api/v1", Generation: 1}) @@ -38,7 +37,8 @@ func TestBootstrapAddressFollowsAuthenticatedAllocation(t *testing.T) { {"embedded managed", "local-node", "ws://host.microsandbox.internal:8091/api/v1/agent-daemon/ws", local}, {"remote managed beside embedded node", "remote-node", publicURL, local}, {"self-hosted beside embedded node", "", publicURL, local}, - {"remote managed after Web setup", "remote-node", selectedURL, remote}, + // Every placed daemon uses the one address derived from the public URL. + {"remote managed after Web setup", "remote-node", publicURL, remote}, {"self-hosted after Web setup", "", publicURL, remote}, {"self-hosted before Web setup", "", publicURL, zero}, {"standalone self-hosted", "", publicURL, nil}, @@ -77,13 +77,3 @@ func TestEmbeddedBootstrapDoesNotFallbackWhenInternalAddressIsInvalid(t *testing t.Fatalf("bootstrap fell back after route failure: %d %s", response.Code, response.Body.String()) } } - -func TestCloudBootstrapUsesAuthenticatedAllocationWithoutNode(t *testing.T) { - s := &managedSetup{store: &setupStore{value: store.SandboxSetup{Provider: "e2b", Generation: 1}}} - s.selected.Store(&execution.RuntimeProvider{ProviderKind: "e2b", Generation: 1, CoreURL: "https://cloud-entry.example/api/v1"}) - m := &managedNodes{setup: s} - got, err := m.webSocketURL("wss://original.example/api/v1/agent-daemon/ws")(t.Context(), gateway.AuthenticatedRuntime{DeviceID: "device", RuntimeAllocationID: "allocation"}) - if err != nil || got != "wss://cloud-entry.example/api/v1/agent-daemon/ws" { - t.Fatal("cloud allocation lost configured bootstrap address", err) - } -} diff --git a/services/agents-api/cmd/server/installation.go b/services/agents-api/cmd/server/installation.go new file mode 100644 index 000000000..a6b2f504e --- /dev/null +++ b/services/agents-api/cmd/server/installation.go @@ -0,0 +1,46 @@ +package main + +import ( + "errors" + "io" + "os" + "regexp" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +var sourceCommit = regexp.MustCompile(`^[0-9a-f]{40}$`) + +// installationFacts reports what GET /core/v1/installation serves: Core's own +// environment and build, plus the installer's settings snapshot. Core never +// acts on the snapshot; it only reports it. +func installationFacts(publicURL string) (api.Installation, error) { + var facts api.Installation + if id := os.Getenv("AGENTS_API_SANDBOX_INSTALLATION_ID"); id != "" { + facts.InstallationID = &id + } + if publicURL != "" { + base := publicURL + "/v1" + facts.PublicURL, facts.APIBaseURL, facts.LocalOnly = &publicURL, &base, store.LoopbackOrigin(publicURL) + } + if sourceCommit.MatchString(buildRevision) { + revision := buildRevision + facts.SourceCommit = &revision + } + path := os.Getenv("AGENTS_API_SETTINGS_FILE") + if path == "" { + return facts, nil + } + f, err := os.Open(path) + if err != nil { + return facts, errors.New("cannot read AGENTS_API_SETTINGS_FILE") + } + defer f.Close() + raw, err := io.ReadAll(io.LimitReader(f, 64<<10+1)) + if err != nil { + return facts, errors.New("cannot read AGENTS_API_SETTINGS_FILE") + } + facts.Configuration, err = api.ParseInstallationConfiguration(raw) + return facts, err +} diff --git a/services/agents-api/cmd/server/main.go b/services/agents-api/cmd/server/main.go index 0a0edf3ba..95ede68b5 100644 --- a/services/agents-api/cmd/server/main.go +++ b/services/agents-api/cmd/server/main.go @@ -34,6 +34,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/coremetrics" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/databaseurl" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtime" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeenrollment" @@ -53,15 +54,23 @@ func main() { } func run() error { + log.Init(log.ConfigFromEnv()) if err := validateProcessConfiguration(); err != nil { return err } + public, err := publicURL() + if err != nil { + return err + } concurrency, err := executionConcurrency() if err != nil { return err } logConfigurationSources() - databaseURL := os.Getenv("AGENTS_API_DATABASE_URL") + databaseURL, err := databaseurl.FromEnvironment() + if err != nil { + return err + } if databaseURL == "" { return errors.New("AGENTS_API_DATABASE_URL is required") } @@ -98,6 +107,11 @@ func run() error { return err } executionStore := store.NewWithCredentialCipherAndOAuthRefresh(pool, credentialKey, oauthClient) + executionStore.SetPublicURL(public) + installation, err := installationFacts(public) + if err != nil { + return err + } metricsSource := &coreMetricsSource{store: executionStore, pool: pool} metrics := coremetrics.New(processStartedAt, buildRevision, metricsSource) auth, err := api.NewDatabaseAuthenticator(executionStore) @@ -117,7 +131,7 @@ func run() error { defer func() { cancelAuditCleanup(); <-auditCleanupDone }() var workerDone chan error var worker *execution.Worker - managedNodes, err := configureManagedNodes(executionStore, func(ctx context.Context) error { + managedNodes, err := configureManagedNodes(executionStore, public, func(ctx context.Context) error { if worker == nil { return errors.New("sandbox execution owner is unavailable") } @@ -143,7 +157,7 @@ func run() error { if err != nil { return err } - history, err := runtimeHistory(ctx, executionStore, os.Getenv("AGENTS_API_DAEMON_WS_URL") != "") + history, err := runtimeHistory(ctx, executionStore, public != "") if err != nil { return err } @@ -187,7 +201,8 @@ func run() error { if err := api.ValidateCredentialSeparation(ctx, keyAdmin, executionStore); err != nil { return err } - options = append(options, api.WithProjectAPIKeys(executionStore, keyAdmin), api.WithWriteAudit(executionStore, keyAdmin), api.WithAdminManagement(executionStore)) + options = append(options, api.WithProjectAPIKeys(executionStore, keyAdmin), api.WithWriteAudit(executionStore, keyAdmin), api.WithAdminManagement(executionStore), + api.WithInstallation(installation, executionStore.AddressBindings)) if history.Reader != nil { historyResolver, resolverErr := historystoreresolver.NewResolver(executionStore) if resolverErr != nil { @@ -201,7 +216,11 @@ func run() error { } var daemonHandler http.Handler var registry *gateway.Registry - if wsURL := os.Getenv("AGENTS_API_DAEMON_WS_URL"); wsURL != "" { + if public != "" { + wsURL, err := runtimeWebSocketURL(public) + if err != nil { + return err + } daemonHandler, registry, err = runtime.NewGatewayWithURLResolver(executionStore, wsURL, managedNodes.webSocketURL(wsURL)) if err != nil { return err diff --git a/services/agents-api/cmd/server/managed_nodes.go b/services/agents-api/cmd/server/managed_nodes.go index ff9cf1af3..411e94f1b 100644 --- a/services/agents-api/cmd/server/managed_nodes.go +++ b/services/agents-api/cmd/server/managed_nodes.go @@ -21,7 +21,7 @@ type managedNodes struct { closeProvider func() } -func configureManagedNodes(s *store.Store, owner func(context.Context) error) (*managedNodes, error) { +func configureManagedNodes(s *store.Store, publicURL string, owner func(context.Context) error) (*managedNodes, error) { if os.Getenv("AGENTS_API_MANAGED_RUNTIMES_FILE") != "" { return nil, errors.New("file-managed sandbox configuration is no longer supported; retain existing resources, drain them with the previous release, this release does not automatically adopt file-managed deployment records") } @@ -33,8 +33,8 @@ func configureManagedNodes(s *store.Store, owner func(context.Context) error) (* if err != nil || id == uuid.Nil || id.String() != setupID { return nil, errors.New("sandbox installation ID must be a canonical UUID") } - if os.Getenv("AGENTS_API_DAEMON_WS_URL") == "" { - return nil, errors.New("sandbox setup requires daemon transport") + if publicURL == "" { + return nil, errors.New("AGENTS_API_SANDBOX_INSTALLATION_ID requires AGENTS_API_PUBLIC_URL, the origin nodes and sandboxes use to reach Core") } closeProvider := func() {} result := &managedNodes{closeProvider: closeProvider} @@ -81,7 +81,7 @@ func configureManagedNodes(s *store.Store, owner func(context.Context) error) (* return s.HeartbeatRuntimeNode(ctx, n.NodeID, connection, epoch, store.RuntimeNodeHealth{Host: &store.RuntimeNodeHost{EffectiveCPUCores: health.EffectiveCPUCores, CPUUtilization: health.CPUUtilization, TotalMemoryBytes: health.TotalMemoryBytes, AvailableMemoryBytes: health.AvailableMemoryBytes, AvailableDiskBytes: health.AvailableDiskBytes, ObservedAt: &health.ObservedAt}, ProviderReady: health.ProviderReady, Diagnostic: health.Diagnostic, CPUCount: health.CPUCount, AvailableMemoryBytes: health.AvailableMemoryBytes, AvailableDiskBytes: health.AvailableDiskBytes}) }, }) - result.setup = &managedSetup{store: s, hub: result.hub, installationID: setupID} + result.setup = &managedSetup{store: s, hub: result.hub, installationID: setupID, publicURL: publicURL} result.runtime = execution.NewDeferredRuntimeProvider(setupID, result.setup.load, result.setup.prepare) success = true return result, nil diff --git a/services/agents-api/cmd/server/managed_setup.go b/services/agents-api/cmd/server/managed_setup.go index afca8a148..fa5099138 100644 --- a/services/agents-api/cmd/server/managed_setup.go +++ b/services/agents-api/cmd/server/managed_setup.go @@ -27,7 +27,9 @@ type managedSetup struct { } hub *node.Hub installationID string - selected atomic.Pointer[execution.RuntimeProvider] + // publicURL is AGENTS_API_PUBLIC_URL; every sandbox reaches Core through it. + publicURL string + selected atomic.Pointer[execution.RuntimeProvider] } func (s *managedSetup) load(ctx context.Context) (*execution.RuntimeProvider, error) { @@ -63,6 +65,10 @@ func (s *managedSetup) load(ctx context.Context) (*execution.RuntimeProvider, er } func (s *managedSetup) prepare(ctx context.Context, setup store.SandboxSetup) (execution.PreparedRuntimeDeployment, error) { + // E2B guests reach Core from E2B's cloud, over the internet. + if setup.Provider == "e2b" && store.LoopbackOrigin(s.publicURL) { + return execution.PreparedRuntimeDeployment{}, store.ErrSandboxPublicURLUnreachable + } candidate, err := s.configuration(setup) if err != nil { return execution.PreparedRuntimeDeployment{}, err @@ -105,7 +111,7 @@ func (s *managedSetup) configuration(setup store.SandboxSetup) (execution.Prepar return execution.PreparedRuntimeDeployment{}, fmt.Errorf("%w: %v", execution.ErrExecutionUnavailable, err) } selected := &execution.RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, Generation: setup.Generation, Mode: setup.Mode, Maintenance: setup.Maintenance, - CoreURL: setup.CoreURL + "/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: provider} + CoreURL: s.publicURL + "/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: provider} if setup.Provider == "microsandbox" { selected.Suspension = &execution.RuntimeSuspensionPolicy{IdleTimeout: time.Duration(setup.IdleSeconds) * time.Second, Retention: time.Duration(setup.RetentionSeconds) * time.Second, MaxActive: 4, MaxRetained: 16} @@ -113,19 +119,6 @@ func (s *managedSetup) configuration(setup store.SandboxSetup) (execution.Prepar return execution.PreparedRuntimeDeployment{Config: selected, Publish: s.selected.Store}, nil } -func (s *managedSetup) webSocketURL(fallback string) func(context.Context) (string, error) { - return func(ctx context.Context) (string, error) { - selected, err := s.load(ctx) - if err != nil { - return "", err - } - if selected == nil { - return fallback, nil - } - return runtimeWebSocketURL(selected.CoreURL) - } -} - func (s *managedSetup) ObservationProviderType() string { if selected := s.selected.Load(); selected != nil { return selected.ProviderKind diff --git a/services/agents-api/cmd/server/managed_setup_test.go b/services/agents-api/cmd/server/managed_setup_test.go index cd6b81a15..2f42ab2a6 100644 --- a/services/agents-api/cmd/server/managed_setup_test.go +++ b/services/agents-api/cmd/server/managed_setup_test.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "os" "path/filepath" + "strings" "testing" "errors" @@ -18,14 +19,16 @@ import ( func TestWebSetupCreatesManagerWithoutLocalProvider(t *testing.T) { t.Setenv("AGENTS_API_MANAGED_RUNTIMES_FILE", "") t.Setenv("AGENTS_API_SANDBOX_INSTALLATION_ID", uuid.NewString()) - t.Setenv("AGENTS_API_DAEMON_WS_URL", "ws://core:8091/api/v1/agent-daemon/ws") digest := sha256.Sum256([]byte("synthetic-admin")) path := filepath.Join(t.TempDir(), "core-key-digests.json") if err := os.WriteFile(path, []byte(`["`+hex.EncodeToString(digest[:])+`"]`), 0600); err != nil { t.Fatal(err) } t.Setenv("AGENTS_API_CORE_KEY_DIGESTS_FILE", path) - m, err := configureManagedNodes(nil, func(context.Context) error { return nil }) + if _, err := configureManagedNodes(nil, "", nil); err == nil || !strings.Contains(err.Error(), "AGENTS_API_PUBLIC_URL") { + t.Fatal("sandbox manager started without a public URL", err) + } + m, err := configureManagedNodes(nil, "https://core.example", func(context.Context) error { return nil }) if err != nil { t.Fatal(err) } @@ -34,28 +37,11 @@ func TestWebSetupCreatesManagerWithoutLocalProvider(t *testing.T) { t.Fatal("zero-node setup unexpectedly instantiated local compute or omitted management") } t.Setenv("AGENTS_API_CORE_KEY_DIGESTS_FILE", "") - if _, err := configureManagedNodes(nil, nil); err == nil { + if _, err := configureManagedNodes(nil, "https://core.example", nil); err == nil { t.Fatal("setup accepted without admin authentication") } } -func TestSelectedSetupPublishesSameOriginForDaemonBootstrap(t *testing.T) { - for _, tc := range []struct{ origin, want string }{ - {"https://core.example:8443/api/v1", "wss://core.example:8443/api/v1/agent-daemon/ws"}, - {"http://127.0.0.1:8091/api/v1", "ws://127.0.0.1:8091/api/v1/agent-daemon/ws"}, - } { - s := &managedSetup{store: &setupStore{value: store.SandboxSetup{Provider: "docker", Generation: 1}}} - s.selected.Store(&execution.RuntimeProvider{CoreURL: tc.origin, ProviderKind: "docker", Generation: 1}) - got, err := s.webSocketURL("ws://private-core:8091/api/v1/agent-daemon/ws")(context.Background()) - if err != nil || got != tc.want { - t.Fatalf("bootstrap URL=%s error=%v", got, err) - } - if s.ObservationProviderType() != "docker" { - t.Fatal("observation lost selected provider") - } - } -} - type setupStore struct { value store.SandboxSetup err error @@ -113,14 +99,14 @@ func TestManagedSetupPreparesWithoutPublishing(t *testing.T) { id := uuid.NewString() hub := node.NewHub(node.HubOptions{}) defer hub.Close() - s := &managedSetup{installationID: id, hub: hub, store: &setupStore{}} + s := &managedSetup{installationID: id, hub: hub, store: &setupStore{}, publicURL: "https://core.example"} previous := &execution.RuntimeProvider{InstallationID: id, Generation: 1, ProviderKind: "docker"} s.selected.Store(previous) - candidate, err := s.prepare(t.Context(), store.SandboxSetup{InstallationID: id, Provider: "microsandbox", Mode: "nodes", CoreURL: "https://core.example", IdleSeconds: 300, RetentionSeconds: 86400}) + candidate, err := s.prepare(t.Context(), store.SandboxSetup{InstallationID: id, Provider: "microsandbox", Mode: "nodes", IdleSeconds: 300, RetentionSeconds: 86400}) if err != nil { t.Fatal(err) } - if s.selected.Load() != previous || candidate.Config.ProviderKind != "microsandbox" || candidate.Config.Suspension == nil { + if s.selected.Load() != previous || candidate.Config.ProviderKind != "microsandbox" || candidate.Config.Suspension == nil || candidate.Config.CoreURL != "https://core.example/api/v1" { t.Fatal("preparation published or lost candidate configuration") } committed := *candidate.Config @@ -138,16 +124,26 @@ func TestManagedSetupRejectedCandidateRetainsSelection(t *testing.T) { s := &managedSetup{installationID: id} previous := &execution.RuntimeProvider{InstallationID: id, Generation: 1, ProviderKind: "docker"} s.selected.Store(previous) - _, err := s.prepare(t.Context(), store.SandboxSetup{InstallationID: id, Provider: "e2b", Mode: "direct", CoreURL: "https://core.example", + _, err := s.prepare(t.Context(), store.SandboxSetup{InstallationID: id, Provider: "e2b", Mode: "direct", E2B: &store.SandboxE2BConfiguration{APIKey: "synthetic-key", Template: "runtime:" + uuid.NewString()}}) if !errors.Is(err, execution.ErrExecutionUnavailable) || s.selected.Load() != previous { t.Fatal("rejected candidate lost the previous selection", err) } } +func TestE2BRequiresAPublicURLOutsideTheHost(t *testing.T) { + id := uuid.NewString() + s := &managedSetup{installationID: id, publicURL: "http://127.0.0.1:8091"} + _, err := s.prepare(t.Context(), store.SandboxSetup{InstallationID: id, Provider: "e2b", Mode: "direct", + E2B: &store.SandboxE2BConfiguration{APIKey: "synthetic-key", Template: "runtime:" + uuid.NewString()}}) + if !errors.Is(err, store.ErrSandboxPublicURLUnreachable) { + t.Fatal("E2B accepted a loopback public URL", err) + } +} + func TestCoreRejectsFileManagedSandboxConfiguration(t *testing.T) { t.Setenv("AGENTS_API_MANAGED_RUNTIMES_FILE", "/retained/config.json") - if _, err := configureManagedNodes(nil, nil); err == nil { + if _, err := configureManagedNodes(nil, "https://core.example", nil); err == nil { t.Fatal("accepted a second configuration source") } } diff --git a/services/agents-api/cmd/server/process_configuration.go b/services/agents-api/cmd/server/process_configuration.go index 8e85a0a5e..5a9d63811 100644 --- a/services/agents-api/cmd/server/process_configuration.go +++ b/services/agents-api/cmd/server/process_configuration.go @@ -8,6 +8,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) func executionConcurrency() (int, error) { @@ -27,22 +28,42 @@ func validateProcessConfiguration() error { if _, present := os.LookupEnv("AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE"); present { return errors.New("AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE was renamed; set AGENTS_API_CORE_KEY_DIGESTS_FILE to the Core key digest file instead") } + if _, present := os.LookupEnv("AGENTS_API_DAEMON_WS_URL"); present { + return errors.New("AGENTS_API_DAEMON_WS_URL is retired; set AGENTS_API_PUBLIC_URL to the public Core origin, such as https://core.example, and Core derives the daemon WebSocket URL") + } + if _, present := os.LookupEnv("AGENTS_API_CONFIG_FILE"); present { + return errors.New("AGENTS_API_CONFIG_FILE is retired; the installer names its settings snapshot with AGENTS_API_SETTINGS_FILE") + } for _, retired := range []string{"AGENTS_API_MANAGED_RUNTIMES_FILE", "AGENTS_API_SANDBOX_NODE_STATE_DIR", "AGENTS_API_SANDBOX_NODE_CORE_URL"} { if _, present := os.LookupEnv(retired); present { return errors.New(retired + " is retired; configure deployment in Core and enroll a separate node") } } - if path := os.Getenv("AGENTS_API_CONFIG_FILE"); path != "" && !filepath.IsAbs(path) { - return errors.New("AGENTS_API_CONFIG_FILE must be an absolute diagnostic path") + if path := os.Getenv("AGENTS_API_SETTINGS_FILE"); path != "" && !filepath.IsAbs(path) { + return errors.New("AGENTS_API_SETTINGS_FILE must be an absolute path") } return nil } -// The launcher loads core.env. Core reports its source without parsing another +// publicURL reads AGENTS_API_PUBLIC_URL, the one origin applications, nodes, +// sandboxes and self-hosted executors use. An empty result disables daemon +// transport, as for a Core without execution. +func publicURL() (string, error) { + value := os.Getenv("AGENTS_API_PUBLIC_URL") + if value == "" { + return "", nil + } + if store.ValidateSandboxCoreURL(value) != nil { + return "", errors.New("AGENTS_API_PUBLIC_URL must be a canonical HTTPS origin without path, credentials, query or fragment, such as https://core.example; plain HTTP is accepted only for a loopback host") + } + return value, nil +} + +// The launcher loads core.env. Core reports its sources without parsing another // configuration layer or logging environment values. func logConfigurationSources() { - if path := os.Getenv("AGENTS_API_CONFIG_FILE"); path != "" { - log.Bg().Info("Core process configuration loaded", "path", path) + if path := os.Getenv("AGENTS_API_SETTINGS_FILE"); path != "" { + log.Bg().Info("Core process configuration from the installer", "settings", path) } else { log.Bg().Info("Core process configuration loaded from the process environment") } diff --git a/services/agents-api/cmd/server/process_configuration_test.go b/services/agents-api/cmd/server/process_configuration_test.go index 88b055df1..420716d08 100644 --- a/services/agents-api/cmd/server/process_configuration_test.go +++ b/services/agents-api/cmd/server/process_configuration_test.go @@ -40,6 +40,33 @@ func TestRetiredConfigurationRejectedWithoutReadingValues(t *testing.T) { } } +func TestRetiredAddressAndConfigSettingsNameTheirReplacement(t *testing.T) { + for key, replacement := range map[string]string{"AGENTS_API_DAEMON_WS_URL": "AGENTS_API_PUBLIC_URL", "AGENTS_API_CONFIG_FILE": "AGENTS_API_SETTINGS_FILE"} { + t.Run(key, func(t *testing.T) { + t.Setenv(key, "wss://private.example/api/v1/agent-daemon/ws") + err := validateProcessConfiguration() + if err == nil || !strings.Contains(err.Error(), replacement) || strings.Contains(err.Error(), "private.example") { + t.Fatal(err) + } + }) + } +} + +func TestPublicURLMustBeACanonicalOrigin(t *testing.T) { + for _, value := range []string{"https://core.example", "https://core.example:8443", "http://127.0.0.1:8091"} { + t.Setenv("AGENTS_API_PUBLIC_URL", value) + if got, err := publicURL(); err != nil || got != value { + t.Fatal(value, got, err) + } + } + for _, value := range []string{"https://core.example/", "https://Core.example", "http://core.example", "wss://core.example", "https://core.example/v1"} { + t.Setenv("AGENTS_API_PUBLIC_URL", value) + if _, err := publicURL(); err == nil { + t.Fatal("accepted", value) + } + } +} + func TestRenamedCoreKeyDigestSettingNamesItsReplacement(t *testing.T) { t.Setenv("AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE", "/private/admin/digests.json") err := validateProcessConfiguration() diff --git a/services/agents-api/internal/api/core_routes.go b/services/agents-api/internal/api/core_routes.go index 566517c2d..ae4aa4c73 100644 --- a/services/agents-api/internal/api/core_routes.go +++ b/services/agents-api/internal/api/core_routes.go @@ -18,6 +18,9 @@ func (h *Handler) registerCoreRoutes(router chi.Router) { r.NotFound(func(w http.ResponseWriter, _ *http.Request) { writeError(w, http.StatusNotFound, "not_found", "This Core operation does not exist.") }) + if h.installation != nil { + r.Get("/installation", h.getInstallation) + } h.registerProjectAPIKeyRoutes(r) h.registerAdminResourceRoutes(r) h.registerExecutorCredentialRoutes(r) diff --git a/services/agents-api/internal/api/errors.go b/services/agents-api/internal/api/errors.go index 22bd002ec..797101361 100644 --- a/services/agents-api/internal/api/errors.go +++ b/services/agents-api/internal/api/errors.go @@ -92,6 +92,8 @@ func writeStoreError(w http.ResponseWriter, r *http.Request, err error, notFound switch { case errors.As(err, &sandboxConfiguration): writeError(w, http.StatusBadRequest, "invalid_sandbox_configuration", sandboxConfiguration.Message) + case errors.Is(err, store.ErrSandboxPublicURLUnreachable): + writeError(w, http.StatusConflict, "sandbox_configuration_error", err.Error()) case errors.Is(err, store.ErrRuntimeSpecificationMismatch): writeError(w, http.StatusConflict, "sandbox_specification_mismatch", "The node resource limits or Runtime release do not match the active deployment. Restore its installed configuration or remove and enroll the node again after a drained deployment change.") case errors.Is(err, store.ErrProjectArchived): diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index 7542545e4..ee8212213 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -64,6 +64,8 @@ type Handler struct { subagents SubagentStore runtimeObservations RuntimeObservationService runtimeHistory RuntimeHistoryService + installation *Installation + installationBindings func(context.Context) (store.AddressBindings, error) } func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ...Option) (http.Handler, error) { diff --git a/services/agents-api/internal/api/installation.go b/services/agents-api/internal/api/installation.go new file mode 100644 index 000000000..5ae4c547f --- /dev/null +++ b/services/agents-api/internal/api/installation.go @@ -0,0 +1,126 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "path/filepath" + "regexp" + "slices" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// Installation reports Core's installation facts. Core reads them from its +// environment and build; configuration is the installer's settings snapshot. +type Installation struct { + Object string `json:"object" enums:"core.installation"` + // AGENTS_API_SANDBOX_INSTALLATION_ID; null when Core runs without the sandbox manager. + InstallationID *string `json:"installation_id" extensions:"x-nullable"` + // AGENTS_API_PUBLIC_URL: the origin applications, nodes, sandboxes and self-hosted executors use. Null when unset. + PublicURL *string `json:"public_url" extensions:"x-nullable"` + // public_url followed by /v1; null when public_url is null. + APIBaseURL *string `json:"api_base_url" extensions:"x-nullable"` + // True when public_url names a loopback host, reachable only from the Core host. + LocalOnly bool `json:"local_only"` + // Full source commit Core was built from; null for development builds. + SourceCommit *string `json:"source_commit" extensions:"x-nullable"` + // The installer's settings snapshot (AGENTS_API_SETTINGS_FILE); null when the installer did not start Core. + Configuration *InstallationConfiguration `json:"configuration" extensions:"x-nullable"` + AddressBindings store.AddressBindings `json:"address_bindings"` +} + +// InstallationConfiguration says where process settings are changed and what +// the last applied values were. Sensitive values are never included. +type InstallationConfiguration struct { + // Absolute host path of the installation's config.json. + Path string `json:"path"` + // Command that applies config.json changes. + ApplyCommand string `json:"apply_command"` + AppliedAt time.Time `json:"applied_at"` + Settings []InstallationSetting `json:"settings"` +} + +type InstallationSetting struct { + // Dotted config.json key, such as ports.core. + Key string `json:"key"` + // Applied value; always null for a sensitive setting. + Value any `json:"value" extensions:"x-nullable"` + Default any `json:"default" extensions:"x-nullable"` + // Present only for a sensitive setting: whether it has a value. + Configured *bool `json:"configured,omitempty"` + // False for settings fixed at installation. + Changeable bool `json:"changeable"` + Sensitive bool `json:"sensitive"` + // Services that restart when the setting changes. + Restarts []string `json:"restarts"` +} + +var installationSettingKey = regexp.MustCompile(`^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$`) + +const maxInstallationSettings = 64 << 10 + +// ParseInstallationConfiguration validates the installer's settings snapshot. +// A sensitive setting that carries a value is rejected, so the snapshot cannot +// leak a secret through this read. +func ParseInstallationConfiguration(raw []byte) (*InstallationConfiguration, error) { + invalid := errors.New("AGENTS_API_SETTINGS_FILE must contain the installer's settings snapshot") + if len(raw) > maxInstallationSettings { + return nil, invalid + } + var value InstallationConfiguration + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if decoder.Decode(&value) != nil || decoder.Decode(new(any)) != io.EOF { + return nil, invalid + } + if !filepath.IsAbs(value.Path) || value.ApplyCommand == "" || len(value.ApplyCommand) > 4096 || value.AppliedAt.IsZero() || value.Settings == nil { + return nil, invalid + } + seen := make(map[string]bool, len(value.Settings)) + for _, setting := range value.Settings { + if !installationSettingKey.MatchString(setting.Key) || seen[setting.Key] || setting.Restarts == nil || + setting.Sensitive != (setting.Configured != nil) || (setting.Sensitive && (setting.Value != nil || setting.Default != nil)) { + return nil, invalid + } + for _, service := range setting.Restarts { + if !slices.Contains([]string{"core", "web", "database"}, service) { + return nil, invalid + } + } + seen[setting.Key] = true + } + return &value, nil +} + +// WithInstallation serves GET /core/v1/installation. bindings counts what is +// bound to the current public URL. +func WithInstallation(value Installation, bindings func(context.Context) (store.AddressBindings, error)) Option { + return func(h *Handler) { + value.Object = "core.installation" + h.installation, h.installationBindings = &value, bindings + } +} + +// @Summary Retrieve installation facts and process settings +// @Description Core key only; available before any sandbox deployment exists. Reports the public URL that applications, nodes, sandboxes and self-hosted executors use, the API base URL, Core's source commit and installation ID, the installer's settings snapshot with where to change it, and what is bound to the current public URL. Sensitive settings report only whether they are configured. +// @Tags Core Administration +// @Produce json +// @Security DeploymentAdminAuth +// @Success 200 {object} api.Installation +// @Failure 401,500 {object} v1.ErrorResponse +// @Router /core/v1/installation [get] +func (h *Handler) getInstallation(w http.ResponseWriter, r *http.Request) { + bindings, err := h.installationBindings(r.Context()) + if err != nil { + writeStoreError(w, r, err) + return + } + value := *h.installation + value.AddressBindings = bindings + writeJSON(w, http.StatusOK, value) +} diff --git a/services/agents-api/internal/api/installation_test.go b/services/agents-api/internal/api/installation_test.go new file mode 100644 index 000000000..43e450dd1 --- /dev/null +++ b/services/agents-api/internal/api/installation_test.go @@ -0,0 +1,98 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestInstallationReadNeedsOnlyTheCoreKey(t *testing.T) { + project, _ := NewAuthenticator([]APIKey{callerBinding()}) + admin, _ := NewDeploymentAuthenticator([]string{device.HashCredential("administrator")}) + public, id := "https://core.example", "5b7c0f3e-0000-4000-8000-000000000001" + settings, err := ParseInstallationConfiguration([]byte(`{"path":"/home/alice/.parsar/core/config.json","apply_command":"/home/alice/.parsar/core/parsar apply", + "applied_at":"2026-09-25T09:30:00Z","settings":[{"key":"ports.core","value":8091,"default":8091,"changeable":true,"sensitive":false,"restarts":["core"]}, + {"key":"core.runtime_history.headers","value":null,"default":null,"configured":true,"changeable":true,"sensitive":true,"restarts":["core"]}]}`)) + if err != nil { + t.Fatal(err) + } + bindings := func(context.Context) (store.AddressBindings, error) { + return store.AddressBindings{Nodes: 2, NodesOnOtherAddress: 1}, nil + } + // No sandbox manager: the read is available before any deployment exists. + h, err := NewHandler(&recordingStore{}, project, "codex", WithProjectAPIKeys(nil, admin), + WithInstallation(Installation{InstallationID: &id, PublicURL: &public, Configuration: settings}, bindings)) + if err != nil { + t.Fatal(err) + } + get := func(token string) *httptest.ResponseRecorder { + request := httptest.NewRequest(http.MethodGet, "/core/v1/installation", nil) + request.Header.Set("Authorization", "Bearer "+token) + result := httptest.NewRecorder() + h.ServeHTTP(result, request) + return result + } + if result := get("caller"); result.Code != http.StatusUnauthorized { + t.Fatal("Project key read installation facts", result.Code) + } + result := get("administrator") + var body map[string]any + if result.Code != http.StatusOK || json.Unmarshal(result.Body.Bytes(), &body) != nil || body["object"] != "core.installation" || + body["public_url"] != public || body["address_bindings"].(map[string]any)["nodes_on_other_address"] != float64(1) || + body["configuration"].(map[string]any)["path"] != "/home/alice/.parsar/core/config.json" { + t.Fatal(result.Code, result.Body.String()) + } +} + +func TestInstallationSnapshotCannotCarryASensitiveValue(t *testing.T) { + for _, setting := range []string{ + `{"key":"core.runtime_history.headers","value":{"authorization":"secret"},"default":null,"configured":true,"changeable":true,"sensitive":true,"restarts":["core"]}`, + `{"key":"core.runtime_history.headers","value":null,"default":null,"changeable":true,"sensitive":true,"restarts":["core"]}`, + `{"key":"ports.core","value":8091,"default":8091,"changeable":true,"sensitive":false,"restarts":["core"],"unknown":true}`, + } { + raw := `{"path":"/c/config.json","apply_command":"/c/parsar apply","applied_at":"2026-09-25T09:30:00Z","settings":[` + setting + `]}` + if _, err := ParseInstallationConfiguration([]byte(raw)); err == nil || strings.Contains(err.Error(), "secret") { + t.Fatal("accepted", setting, err) + } + } +} + +func TestDeploymentAddressIsNotInput(t *testing.T) { + project, _ := NewAuthenticator([]APIKey{callerBinding()}) + admin, _ := NewDeploymentAuthenticator([]string{device.HashCredential("administrator")}) + initialize := func(_ context.Context, input store.SandboxDeploymentSetupRequest) (store.RuntimeDeploymentView, error) { + return store.RuntimeDeploymentView{}, store.ErrSandboxPublicURLUnreachable + } + update := func(context.Context, store.SandboxDeploymentUpdateRequest) (store.RuntimeDeploymentView, error) { + t.Fatal("core_url reached the update") + return store.RuntimeDeploymentView{}, nil + } + h, err := NewHandler(&recordingStore{}, project, "codex", WithSandboxManager(&store.Store{}, admin), + WithSandboxDeploymentSetup(initialize), WithSandboxDeploymentChanges(update, nil)) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + method, body, code string + status int + }{ + {http.MethodPost, `{"provider":"docker","core_url":"https://core.example"}`, `"param":"core_url"`, http.StatusBadRequest}, + {http.MethodPut, `{"provider":"docker","core_url":"https://core.example","expected_generation":1}`, `"param":"core_url"`, http.StatusBadRequest}, + {http.MethodPost, `{"provider":"e2b","e2b":{"api_key":"key","template":"runtime:build"}}`, `"code":"sandbox_configuration_error"`, http.StatusConflict}, + } { + request := httptest.NewRequest(test.method, "/core/v1/sandbox/deployment", strings.NewReader(test.body)) + request.Header.Set("Authorization", "Bearer administrator") + request.Header.Set("Content-Type", "application/json") + result := httptest.NewRecorder() + h.ServeHTTP(result, request) + if result.Code != test.status || !strings.Contains(result.Body.String(), test.code) { + t.Fatal(test.method, result.Code, result.Body.String()) + } + } +} diff --git a/services/agents-api/internal/api/sandbox_deployment_changes_test.go b/services/agents-api/internal/api/sandbox_deployment_changes_test.go index e7076a40b..ff8808312 100644 --- a/services/agents-api/internal/api/sandbox_deployment_changes_test.go +++ b/services/agents-api/internal/api/sandbox_deployment_changes_test.go @@ -33,7 +33,7 @@ func TestSandboxDeploymentChangesAuthenticateAndDecode(t *testing.T) { if err != nil { t.Fatal(err) } - const selection = `{"provider":"e2b","core_url":"https://core.example","expected_generation":2,"e2b":{"api_key":"synthetic-private-key","template":"qualified:build"}}` + const selection = `{"provider":"e2b","expected_generation":2,"e2b":{"api_key":"synthetic-private-key","template":"qualified:build"}}` for _, tc := range []struct { method, path, token, body string status int @@ -67,7 +67,7 @@ func TestSandboxDeploymentChangesUnavailableWithoutOwner(t *testing.T) { body string handler http.HandlerFunc }{ - {`{"provider":"docker","core_url":"https://core.example","expected_generation":1}`, h.updateSandboxDeployment}, + {`{"provider":"docker","expected_generation":1}`, h.updateSandboxDeployment}, {`{"maintenance":true,"expected_generation":1}`, h.setSandboxMaintenance}, } { w := httptest.NewRecorder() diff --git a/services/agents-api/internal/api/sandbox_deployment_setup.go b/services/agents-api/internal/api/sandbox_deployment_setup.go index 099c33818..187cd58dc 100644 --- a/services/agents-api/internal/api/sandbox_deployment_setup.go +++ b/services/agents-api/internal/api/sandbox_deployment_setup.go @@ -2,6 +2,7 @@ package api import ( "context" + "encoding/json" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "net/http" @@ -21,7 +22,6 @@ type SandboxDeploymentInput struct { Resources sandbox.Resources `json:"resources"` Runtime *sandbox.RuntimeRelease `json:"runtime,omitempty"` Provider string `json:"provider"` - CoreURL string `json:"core_url"` E2B *SandboxE2BInput `json:"e2b,omitempty"` } @@ -31,13 +31,27 @@ type SandboxDeploymentChangeInput struct { } func (v SandboxDeploymentInput) request() store.SandboxDeploymentSetupRequest { - input := store.SandboxDeploymentSetupRequest{Provider: v.Provider, CoreURL: v.CoreURL, DeploymentSpec: sandbox.DeploymentSpec{Resources: v.Resources, Runtime: v.Runtime}} + input := store.SandboxDeploymentSetupRequest{Provider: v.Provider, DeploymentSpec: sandbox.DeploymentSpec{Resources: v.Resources, Runtime: v.Runtime}} if v.E2B != nil { input.E2B = &store.SandboxE2BConfiguration{APIKey: v.E2B.APIKey, Template: v.E2B.Template} } return input } +// rejectCoreURL names the retired member instead of reporting a generic unknown +// member: Core derives core_url from the installation public URL. +func rejectCoreURL(w http.ResponseWriter, raw json.RawMessage) bool { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil { + return false + } + if _, present := fields["core_url"]; !present { + return false + } + writeError(w, http.StatusBadRequest, "invalid_request_error", "core_url is derived from the installation public URL (public_url in config.json, AGENTS_API_PUBLIC_URL for Core) and cannot be set here. Remove it.", "core_url") + return true +} + func WithSandboxDeploymentSetup(initialize func(context.Context, store.SandboxDeploymentSetupRequest) (store.RuntimeDeploymentView, error)) Option { return func(h *Handler) { h.sandboxSetup = initialize } } @@ -50,7 +64,7 @@ func WithSandboxDeploymentChanges( } // @Summary Initialize the deployment sandbox provider -// @Description Selects a provider, enforced resource limits, pinned Runtime release and public Core origin. E2B credentials are write-only. E2B may omit resources to adopt the validated template build's CPU and memory, returned in specification.resources. Exact retries return the existing selection; differing selections and file-managed deployments reject. This does not create compute or execute work. +// @Description Selects a provider, enforced resource limits and pinned Runtime release. Core derives the deployment's core_url from the installation public URL and rejects a core_url member with 400. E2B returns 409 sandbox_configuration_error while the public URL is loopback. E2B credentials are write-only. E2B may omit resources to adopt the validated template build's CPU and memory, returned in specification.resources. Exact retries return the existing selection; differing selections and file-managed deployments reject. This does not create compute or execute work. // @Tags Sandbox Manager // @Produce json // @Security DeploymentAdminAuth @@ -64,8 +78,11 @@ func (h *Handler) initializeSandboxDeployment(w http.ResponseWriter, r *http.Req if !ok { return } + if rejectCoreURL(w, raw) { + return + } var input SandboxDeploymentInput - if decodeInputObject(raw, &input, "provider", "core_url", "e2b", "resources", "runtime") != nil { + if decodeInputObject(raw, &input, "provider", "e2b", "resources", "runtime") != nil { writeStoreError(w, r, store.ErrInvalidInput) return } @@ -82,7 +99,7 @@ func (h *Handler) initializeSandboxDeployment(w http.ResponseWriter, r *http.Req } // @Summary Change a fully drained deployment's sandbox configuration -// @Description Requires maintenance, the current generation and verified cleanup of all old resources. Credentials are write-only. E2B may omit resources to adopt the validated template build's CPU and memory. The public Core origin stays unchanged. Historical records are retained; old node credentials and enrollments are retired. Explicitly resume after success. Never automatically retry an uncertain write. +// @Description Requires maintenance, the current generation and verified cleanup of all old resources. Credentials are write-only. E2B may omit resources to adopt the validated template build's CPU and memory. A core_url member is rejected with 400; the address comes from the installation public URL. Historical records are retained; old node credentials and enrollments are retired. Explicitly resume after success. Never automatically retry an uncertain write. // @Tags Sandbox Manager // @Produce json // @Security DeploymentAdminAuth @@ -96,8 +113,11 @@ func (h *Handler) updateSandboxDeployment(w http.ResponseWriter, r *http.Request if !ok { return } + if rejectCoreURL(w, raw) { + return + } var input SandboxDeploymentChangeInput - if decodeInputObject(raw, &input, "provider", "core_url", "e2b", "resources", "runtime", "expected_generation") != nil || input.ExpectedGeneration == 0 { + if decodeInputObject(raw, &input, "provider", "e2b", "resources", "runtime", "expected_generation") != nil || input.ExpectedGeneration == 0 { writeStoreError(w, r, store.ErrInvalidInput) return } diff --git a/services/agents-api/internal/api/sandbox_deployment_setup_test.go b/services/agents-api/internal/api/sandbox_deployment_setup_test.go index e8aa4e14e..a7fd195ef 100644 --- a/services/agents-api/internal/api/sandbox_deployment_setup_test.go +++ b/services/agents-api/internal/api/sandbox_deployment_setup_test.go @@ -20,7 +20,7 @@ func TestSandboxDeploymentSetupRequiresAdministratorAndStrictBody(t *testing.T) if input.Provider == "microsandbox" { return store.RuntimeDeploymentView{}, store.ErrSandboxDeploymentConflict } - return store.RuntimeDeploymentView{Provider: input.Provider, CoreURL: input.CoreURL}, nil + return store.RuntimeDeploymentView{Provider: input.Provider}, nil } h, err := NewHandler(&recordingStore{}, project, "codex", WithSandboxManager(&store.Store{}, admin), WithSandboxDeploymentSetup(initialize)) if err != nil { @@ -30,12 +30,12 @@ func TestSandboxDeploymentSetupRequiresAdministratorAndStrictBody(t *testing.T) token, body string status, calls int }{ - {"caller", `{"provider":"docker","core_url":"https://core.example"}`, 401, 0}, - {"node-credential", `{"provider":"docker","core_url":"https://core.example"}`, 401, 0}, - {"enrollment-token", `{"provider":"docker","core_url":"https://core.example"}`, 401, 0}, - {"administrator", `{"provider":"docker","core_url":"https://core.example","unexpected":true}`, 400, 0}, - {"administrator", `{"provider":"docker","core_url":"https://core.example"}`, 200, 1}, - {"administrator", `{"provider":"microsandbox","core_url":"https://core.example"}`, 409, 2}, + {"caller", `{"provider":"docker"}`, 401, 0}, + {"node-credential", `{"provider":"docker"}`, 401, 0}, + {"enrollment-token", `{"provider":"docker"}`, 401, 0}, + {"administrator", `{"provider":"docker","unexpected":true}`, 400, 0}, + {"administrator", `{"provider":"docker"}`, 200, 1}, + {"administrator", `{"provider":"microsandbox"}`, 409, 2}, } { request := httptest.NewRequest(http.MethodPost, "/core/v1/sandbox/deployment", strings.NewReader(test.body)) request.Header.Set("Authorization", "Bearer "+test.token) @@ -49,7 +49,7 @@ func TestSandboxDeploymentSetupRequiresAdministratorAndStrictBody(t *testing.T) func TestSandboxDeploymentSetupFileModeReturnsConflict(t *testing.T) { h := &Handler{} - request := httptest.NewRequest(http.MethodPost, "/core/v1/sandbox/deployment", strings.NewReader(`{"provider":"docker","core_url":"https://core.example"}`)) + request := httptest.NewRequest(http.MethodPost, "/core/v1/sandbox/deployment", strings.NewReader(`{"provider":"docker"}`)) result := httptest.NewRecorder() h.initializeSandboxDeployment(result, request) if result.Code != http.StatusConflict || !strings.Contains(result.Body.String(), "sandbox_deployment_conflict") { diff --git a/services/agents-api/internal/databaseurl/databaseurl.go b/services/agents-api/internal/databaseurl/databaseurl.go new file mode 100644 index 000000000..f1e9ba820 --- /dev/null +++ b/services/agents-api/internal/databaseurl/databaseurl.go @@ -0,0 +1,58 @@ +// Package databaseurl reads Core's PostgreSQL connection settings, shared by +// the server and its maintenance commands. +package databaseurl + +import ( + "errors" + "io" + "net/url" + "os" + "strings" +) + +// FromEnvironment returns AGENTS_API_DATABASE_URL. When +// AGENTS_API_DATABASE_PASSWORD_FILE is set, the password comes only from that +// file and the URL must not contain one. An empty result means the URL is +// unset; each command reports that in its own terms. +func FromEnvironment() (string, error) { + raw := os.Getenv("AGENTS_API_DATABASE_URL") + file := os.Getenv("AGENTS_API_DATABASE_PASSWORD_FILE") + if raw == "" || file == "" { + return raw, nil + } + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "postgres" && u.Scheme != "postgresql") || u.User == nil || u.User.Username() == "" { + return "", errors.New("AGENTS_API_DATABASE_PASSWORD_FILE requires AGENTS_API_DATABASE_URL in postgres:// form with a user name") + } + if _, set := u.User.Password(); set { + return "", errors.New("set the database password only in AGENTS_API_DATABASE_PASSWORD_FILE, not also in AGENTS_API_DATABASE_URL") + } + password, err := readPassword(file) + if err != nil { + return "", err + } + u.User = url.UserPassword(u.User.Username(), password) + return u.String(), nil +} + +func readPassword(path string) (string, error) { + failure := errors.New("AGENTS_API_DATABASE_PASSWORD_FILE must name a readable regular file containing one password line") + f, err := os.Open(path) + if err != nil { + return "", failure + } + defer f.Close() + info, err := f.Stat() + if err != nil || !info.Mode().IsRegular() { + return "", failure + } + data, err := io.ReadAll(io.LimitReader(f, 4097)) + if err != nil || len(data) > 4096 { + return "", failure + } + password := strings.TrimSpace(string(data)) + if password == "" || strings.ContainsAny(password, "\x00\r\n") { + return "", failure + } + return password, nil +} diff --git a/services/agents-api/internal/databaseurl/databaseurl_test.go b/services/agents-api/internal/databaseurl/databaseurl_test.go new file mode 100644 index 000000000..14e0d5678 --- /dev/null +++ b/services/agents-api/internal/databaseurl/databaseurl_test.go @@ -0,0 +1,34 @@ +package databaseurl + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPasswordComesOnlyFromTheFile(t *testing.T) { + file := filepath.Join(t.TempDir(), "database.password") + if err := os.WriteFile(file, []byte("s3cret/value\n"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("AGENTS_API_DATABASE_PASSWORD_FILE", file) + t.Setenv("AGENTS_API_DATABASE_URL", "postgres://agents_api@database:5432/agents_api?sslmode=disable") + if got, err := FromEnvironment(); err != nil || got != "postgres://agents_api:s3cret%2Fvalue@database:5432/agents_api?sslmode=disable" { + t.Fatal(got, err) + } + t.Setenv("AGENTS_API_DATABASE_URL", "postgres://agents_api:inline@database:5432/agents_api") + if _, err := FromEnvironment(); err == nil || strings.Contains(err.Error(), "inline") { + t.Fatal("accepted a password in two places", err) + } + t.Setenv("AGENTS_API_DATABASE_PASSWORD_FILE", filepath.Join(t.TempDir(), "missing")) + t.Setenv("AGENTS_API_DATABASE_URL", "postgres://agents_api@database:5432/agents_api") + if _, err := FromEnvironment(); err == nil { + t.Fatal("accepted a missing password file") + } + t.Setenv("AGENTS_API_DATABASE_PASSWORD_FILE", "") + t.Setenv("AGENTS_API_DATABASE_URL", "postgres://agents_api:inline@database:5432/agents_api") + if got, err := FromEnvironment(); err != nil || got != "postgres://agents_api:inline@database:5432/agents_api" { + t.Fatal("standalone URL changed", got, err) + } +} diff --git a/services/agents-api/internal/db/queries/runtime_deployment.sql b/services/agents-api/internal/db/queries/runtime_deployment.sql index c23546b24..cd22b1420 100644 --- a/services/agents-api/internal/db/queries/runtime_deployment.sql +++ b/services/agents-api/internal/db/queries/runtime_deployment.sql @@ -12,3 +12,11 @@ SELECT WHERE s.deleted_at IS NULL AND e.status = 'pending' AND s.configuration->'environment'->>'type' = 'openai_hosted' AND NOT EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id))::bigint AS pending; + +-- name: CountAddressBindings :one +SELECT +(SELECT count(*) FROM runtime_nodes WHERE removed_at IS NULL)::bigint AS nodes, +(SELECT count(*) FROM runtime_nodes WHERE removed_at IS NULL AND core_url <> sqlc.arg(public_url)::text)::bigint AS nodes_on_other_address, +(SELECT count(*) FROM environment_executor_credentials c + LEFT JOIN environments e ON e.id = c.environment_id LEFT JOIN sessions s ON s.id = e.session_id + WHERE c.revoked_at IS NULL AND (c.environment_id IS NULL OR s.deleted_at IS NULL))::bigint AS self_hosted_executors; diff --git a/services/agents-api/internal/db/queries/runtime_nodes.sql b/services/agents-api/internal/db/queries/runtime_nodes.sql index 4447bf0cb..290e00e91 100644 --- a/services/agents-api/internal/db/queries/runtime_nodes.sql +++ b/services/agents-api/internal/db/queries/runtime_nodes.sql @@ -5,8 +5,8 @@ UPDATE runtime_deployment SET provider_kind=$1, local_node_id=$2, mode='nodes', SELECT * FROM runtime_deployment WHERE singleton=true; -- name: InsertRuntimeNode :one -INSERT INTO runtime_nodes(id,installation_id,name,backend_fingerprint,credential_sha256,max_active,max_retained,specification_digest,deployment_generation) -VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *; +INSERT INTO runtime_nodes(id,installation_id,name,backend_fingerprint,credential_sha256,max_active,max_retained,specification_digest,deployment_generation,core_url) +VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *; -- name: GetRuntimeNode :one SELECT * FROM runtime_nodes WHERE id=$1 AND removed_at IS NULL; diff --git a/services/agents-api/internal/db/queries/sandbox_deployment_setup.sql b/services/agents-api/internal/db/queries/sandbox_deployment_setup.sql index 095c33646..c96fd093d 100644 --- a/services/agents-api/internal/db/queries/sandbox_deployment_setup.sql +++ b/services/agents-api/internal/db/queries/sandbox_deployment_setup.sql @@ -3,8 +3,8 @@ UPDATE runtime_deployment SET installation_id=$1, web_managed=true, owner_epoch=owner_epoch+1, updated_at=clock_timestamp() WHERE singleton=true; -- name: InitializeSandboxDeployment :exec -UPDATE runtime_deployment SET provider_kind=$1, core_url=$2, backend_fingerprint=$3, -idle_seconds=$4, retention_seconds=$5, generation=$6, mode=$7, e2b_template=$8, e2b_credential=$9, specification=$10, +UPDATE runtime_deployment SET provider_kind=$1, backend_fingerprint=$2, +idle_seconds=$3, retention_seconds=$4, generation=$5, mode=$6, e2b_template=$7, e2b_credential=$8, specification=$9, e2b_template_build_status=sqlc.narg(e2b_template_build_status), e2b_template_cpus=sqlc.narg(e2b_template_cpus), e2b_template_memory_mib=sqlc.narg(e2b_template_memory_mib), e2b_template_root_disk_mib=sqlc.narg(e2b_template_root_disk_mib), updated_at=clock_timestamp() WHERE singleton=true; diff --git a/services/agents-api/internal/db/sqlc/models.go b/services/agents-api/internal/db/sqlc/models.go index 54b6a59a4..58620ef72 100644 --- a/services/agents-api/internal/db/sqlc/models.go +++ b/services/agents-api/internal/db/sqlc/models.go @@ -241,7 +241,6 @@ type RuntimeDeployment struct { LocalNodeID pgtype.UUID `json:"local_node_id"` OwnerEpoch int64 `json:"owner_epoch"` WebManaged bool `json:"web_managed"` - CoreUrl string `json:"core_url"` IdleSeconds int64 `json:"idle_seconds"` RetentionSeconds int64 `json:"retention_seconds"` Generation int64 `json:"generation"` @@ -299,6 +298,7 @@ type RuntimeNode struct { RemovedAt pgtype.Timestamptz `json:"removed_at"` SpecificationDigest string `json:"specification_digest"` DeploymentGeneration int64 `json:"deployment_generation"` + CoreUrl string `json:"core_url"` } type RuntimeNodeEnrollment struct { diff --git a/services/agents-api/internal/db/sqlc/runtime_deployment.sql.go b/services/agents-api/internal/db/sqlc/runtime_deployment.sql.go index 3439d0cf0..97dec6ea4 100644 --- a/services/agents-api/internal/db/sqlc/runtime_deployment.sql.go +++ b/services/agents-api/internal/db/sqlc/runtime_deployment.sql.go @@ -11,6 +11,28 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const countAddressBindings = `-- name: CountAddressBindings :one +SELECT +(SELECT count(*) FROM runtime_nodes WHERE removed_at IS NULL)::bigint AS nodes, +(SELECT count(*) FROM runtime_nodes WHERE removed_at IS NULL AND core_url <> $1::text)::bigint AS nodes_on_other_address, +(SELECT count(*) FROM environment_executor_credentials c + LEFT JOIN environments e ON e.id = c.environment_id LEFT JOIN sessions s ON s.id = e.session_id + WHERE c.revoked_at IS NULL AND (c.environment_id IS NULL OR s.deleted_at IS NULL))::bigint AS self_hosted_executors +` + +type CountAddressBindingsRow struct { + Nodes int64 `json:"nodes"` + NodesOnOtherAddress int64 `json:"nodes_on_other_address"` + SelfHostedExecutors int64 `json:"self_hosted_executors"` +} + +func (q *Queries) CountAddressBindings(ctx context.Context, publicUrl string) (CountAddressBindingsRow, error) { + row := q.db.QueryRow(ctx, countAddressBindings, publicUrl) + var i CountAddressBindingsRow + err := row.Scan(&i.Nodes, &i.NodesOnOtherAddress, &i.SelfHostedExecutors) + return i, err +} + const countRuntimeDeploymentResources = `-- name: CountRuntimeDeploymentResources :one SELECT (SELECT count(*) FROM runtime_allocations WHERE state <> 'released')::bigint AS allocations, @@ -33,7 +55,7 @@ func (q *Queries) CountRuntimeDeploymentResources(ctx context.Context) (CountRun } const lockRuntimeDeployment = `-- name: LockRuntimeDeployment :one -SELECT singleton, installation_id, backend_fingerprint, maintenance, updated_at, provider_kind, local_node_id, owner_epoch, web_managed, core_url, idle_seconds, retention_seconds, generation, mode, e2b_template, e2b_credential, specification, e2b_template_build_status, e2b_template_cpus, e2b_template_memory_mib, e2b_template_root_disk_mib FROM runtime_deployment WHERE singleton = true FOR UPDATE +SELECT singleton, installation_id, backend_fingerprint, maintenance, updated_at, provider_kind, local_node_id, owner_epoch, web_managed, idle_seconds, retention_seconds, generation, mode, e2b_template, e2b_credential, specification, e2b_template_build_status, e2b_template_cpus, e2b_template_memory_mib, e2b_template_root_disk_mib FROM runtime_deployment WHERE singleton = true FOR UPDATE ` func (q *Queries) LockRuntimeDeployment(ctx context.Context) (RuntimeDeployment, error) { @@ -49,7 +71,6 @@ func (q *Queries) LockRuntimeDeployment(ctx context.Context) (RuntimeDeployment, &i.LocalNodeID, &i.OwnerEpoch, &i.WebManaged, - &i.CoreUrl, &i.IdleSeconds, &i.RetentionSeconds, &i.Generation, diff --git a/services/agents-api/internal/db/sqlc/runtime_nodes.sql.go b/services/agents-api/internal/db/sqlc/runtime_nodes.sql.go index 84bd5d9d1..f59b6ee2e 100644 --- a/services/agents-api/internal/db/sqlc/runtime_nodes.sql.go +++ b/services/agents-api/internal/db/sqlc/runtime_nodes.sql.go @@ -160,7 +160,7 @@ func (q *Queries) DisconnectRuntimeNode(ctx context.Context, arg DisconnectRunti } const getRuntimeDeployment = `-- name: GetRuntimeDeployment :one -SELECT singleton, installation_id, backend_fingerprint, maintenance, updated_at, provider_kind, local_node_id, owner_epoch, web_managed, core_url, idle_seconds, retention_seconds, generation, mode, e2b_template, e2b_credential, specification, e2b_template_build_status, e2b_template_cpus, e2b_template_memory_mib, e2b_template_root_disk_mib FROM runtime_deployment WHERE singleton=true +SELECT singleton, installation_id, backend_fingerprint, maintenance, updated_at, provider_kind, local_node_id, owner_epoch, web_managed, idle_seconds, retention_seconds, generation, mode, e2b_template, e2b_credential, specification, e2b_template_build_status, e2b_template_cpus, e2b_template_memory_mib, e2b_template_root_disk_mib FROM runtime_deployment WHERE singleton=true ` func (q *Queries) GetRuntimeDeployment(ctx context.Context) (RuntimeDeployment, error) { @@ -176,7 +176,6 @@ func (q *Queries) GetRuntimeDeployment(ctx context.Context) (RuntimeDeployment, &i.LocalNodeID, &i.OwnerEpoch, &i.WebManaged, - &i.CoreUrl, &i.IdleSeconds, &i.RetentionSeconds, &i.Generation, @@ -212,7 +211,7 @@ func (q *Queries) GetRuntimeEnrollment(ctx context.Context, tokenSha256 string) } const getRuntimeNode = `-- name: GetRuntimeNode :one -SELECT id, installation_id, name, backend_fingerprint, credential_sha256, max_active, max_retained, connection_id, provider_ready, health, connected_epoch, last_seen_at, created_at, removed_at, specification_digest, deployment_generation FROM runtime_nodes WHERE id=$1 AND removed_at IS NULL +SELECT id, installation_id, name, backend_fingerprint, credential_sha256, max_active, max_retained, connection_id, provider_ready, health, connected_epoch, last_seen_at, created_at, removed_at, specification_digest, deployment_generation, core_url FROM runtime_nodes WHERE id=$1 AND removed_at IS NULL ` func (q *Queries) GetRuntimeNode(ctx context.Context, id pgtype.UUID) (RuntimeNode, error) { @@ -235,6 +234,7 @@ func (q *Queries) GetRuntimeNode(ctx context.Context, id pgtype.UUID) (RuntimeNo &i.RemovedAt, &i.SpecificationDigest, &i.DeploymentGeneration, + &i.CoreUrl, ) return i, err } @@ -303,8 +303,8 @@ func (q *Queries) HeartbeatRuntimeNode(ctx context.Context, arg HeartbeatRuntime } const insertRuntimeNode = `-- name: InsertRuntimeNode :one -INSERT INTO runtime_nodes(id,installation_id,name,backend_fingerprint,credential_sha256,max_active,max_retained,specification_digest,deployment_generation) -VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id, installation_id, name, backend_fingerprint, credential_sha256, max_active, max_retained, connection_id, provider_ready, health, connected_epoch, last_seen_at, created_at, removed_at, specification_digest, deployment_generation +INSERT INTO runtime_nodes(id,installation_id,name,backend_fingerprint,credential_sha256,max_active,max_retained,specification_digest,deployment_generation,core_url) +VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id, installation_id, name, backend_fingerprint, credential_sha256, max_active, max_retained, connection_id, provider_ready, health, connected_epoch, last_seen_at, created_at, removed_at, specification_digest, deployment_generation, core_url ` type InsertRuntimeNodeParams struct { @@ -317,6 +317,7 @@ type InsertRuntimeNodeParams struct { MaxRetained int32 `json:"max_retained"` SpecificationDigest string `json:"specification_digest"` DeploymentGeneration int64 `json:"deployment_generation"` + CoreUrl string `json:"core_url"` } func (q *Queries) InsertRuntimeNode(ctx context.Context, arg InsertRuntimeNodeParams) (RuntimeNode, error) { @@ -330,6 +331,7 @@ func (q *Queries) InsertRuntimeNode(ctx context.Context, arg InsertRuntimeNodePa arg.MaxRetained, arg.SpecificationDigest, arg.DeploymentGeneration, + arg.CoreUrl, ) var i RuntimeNode err := row.Scan( @@ -349,6 +351,7 @@ func (q *Queries) InsertRuntimeNode(ctx context.Context, arg InsertRuntimeNodePa &i.RemovedAt, &i.SpecificationDigest, &i.DeploymentGeneration, + &i.CoreUrl, ) return i, err } @@ -462,7 +465,7 @@ func (q *Queries) ListNodeRuntimeAllocations(ctx context.Context, nodeID pgtype. } const listRuntimeNodes = `-- name: ListRuntimeNodes :many -SELECT n.id, n.installation_id, n.name, n.backend_fingerprint, n.credential_sha256, n.max_active, n.max_retained, n.connection_id, n.provider_ready, n.health, n.connected_epoch, n.last_seen_at, n.created_at, n.removed_at, n.specification_digest, n.deployment_generation, (n.connection_id IS NOT NULL AND n.connected_epoch=d.owner_epoch AND n.last_seen_at > clock_timestamp()-interval '45 seconds')::boolean AS online, +SELECT n.id, n.installation_id, n.name, n.backend_fingerprint, n.credential_sha256, n.max_active, n.max_retained, n.connection_id, n.provider_ready, n.health, n.connected_epoch, n.last_seen_at, n.created_at, n.removed_at, n.specification_digest, n.deployment_generation, n.core_url, (n.connection_id IS NOT NULL AND n.connected_epoch=d.owner_epoch AND n.last_seen_at > clock_timestamp()-interval '45 seconds')::boolean AS online, d.provider_kind, (SELECT count(*) FROM runtime_placements p LEFT JOIN runtime_allocations a ON a.environment_id=p.environment_id WHERE p.node_id=n.id AND p.released_at IS NULL AND (a.id IS NULL OR a.compute_phase <> 'suspended'))::bigint AS active, (SELECT count(*) FROM runtime_placements p WHERE p.node_id=n.id AND p.released_at IS NULL)::bigint AS retained, @@ -492,6 +495,7 @@ type ListRuntimeNodesRow struct { RemovedAt pgtype.Timestamptz `json:"removed_at"` SpecificationDigest string `json:"specification_digest"` DeploymentGeneration int64 `json:"deployment_generation"` + CoreUrl string `json:"core_url"` Online bool `json:"online"` ProviderKind string `json:"provider_kind"` Active int64 `json:"active"` @@ -528,6 +532,7 @@ func (q *Queries) ListRuntimeNodes(ctx context.Context, nodeID pgtype.UUID) ([]L &i.RemovedAt, &i.SpecificationDigest, &i.DeploymentGeneration, + &i.CoreUrl, &i.Online, &i.ProviderKind, &i.Active, @@ -622,7 +627,7 @@ func (q *Queries) SetRuntimeObservation(ctx context.Context, arg SetRuntimeObser } const updateRuntimeNode = `-- name: UpdateRuntimeNode :one -UPDATE runtime_nodes SET name=$2,max_active=$3,max_retained=$4 WHERE id=$1 AND removed_at IS NULL RETURNING id, installation_id, name, backend_fingerprint, credential_sha256, max_active, max_retained, connection_id, provider_ready, health, connected_epoch, last_seen_at, created_at, removed_at, specification_digest, deployment_generation +UPDATE runtime_nodes SET name=$2,max_active=$3,max_retained=$4 WHERE id=$1 AND removed_at IS NULL RETURNING id, installation_id, name, backend_fingerprint, credential_sha256, max_active, max_retained, connection_id, provider_ready, health, connected_epoch, last_seen_at, created_at, removed_at, specification_digest, deployment_generation, core_url ` type UpdateRuntimeNodeParams struct { @@ -657,6 +662,7 @@ func (q *Queries) UpdateRuntimeNode(ctx context.Context, arg UpdateRuntimeNodePa &i.RemovedAt, &i.SpecificationDigest, &i.DeploymentGeneration, + &i.CoreUrl, ) return i, err } diff --git a/services/agents-api/internal/db/sqlc/sandbox_deployment_setup.sql.go b/services/agents-api/internal/db/sqlc/sandbox_deployment_setup.sql.go index 2cc6ea01a..c02e9fde7 100644 --- a/services/agents-api/internal/db/sqlc/sandbox_deployment_setup.sql.go +++ b/services/agents-api/internal/db/sqlc/sandbox_deployment_setup.sql.go @@ -31,16 +31,15 @@ func (q *Queries) ClaimWebSandboxDeployment(ctx context.Context, installationID } const initializeSandboxDeployment = `-- name: InitializeSandboxDeployment :exec -UPDATE runtime_deployment SET provider_kind=$1, core_url=$2, backend_fingerprint=$3, -idle_seconds=$4, retention_seconds=$5, generation=$6, mode=$7, e2b_template=$8, e2b_credential=$9, specification=$10, -e2b_template_build_status=$11, e2b_template_cpus=$12, -e2b_template_memory_mib=$13, e2b_template_root_disk_mib=$14, +UPDATE runtime_deployment SET provider_kind=$1, backend_fingerprint=$2, +idle_seconds=$3, retention_seconds=$4, generation=$5, mode=$6, e2b_template=$7, e2b_credential=$8, specification=$9, +e2b_template_build_status=$10, e2b_template_cpus=$11, +e2b_template_memory_mib=$12, e2b_template_root_disk_mib=$13, updated_at=clock_timestamp() WHERE singleton=true ` type InitializeSandboxDeploymentParams struct { ProviderKind string `json:"provider_kind"` - CoreUrl string `json:"core_url"` BackendFingerprint string `json:"backend_fingerprint"` IdleSeconds int64 `json:"idle_seconds"` RetentionSeconds int64 `json:"retention_seconds"` @@ -58,7 +57,6 @@ type InitializeSandboxDeploymentParams struct { func (q *Queries) InitializeSandboxDeployment(ctx context.Context, arg InitializeSandboxDeploymentParams) error { _, err := q.db.Exec(ctx, initializeSandboxDeployment, arg.ProviderKind, - arg.CoreUrl, arg.BackendFingerprint, arg.IdleSeconds, arg.RetentionSeconds, diff --git a/services/agents-api/internal/execution/sandbox_deployment_setup.go b/services/agents-api/internal/execution/sandbox_deployment_setup.go index f0dcf3ec9..1f94e795c 100644 --- a/services/agents-api/internal/execution/sandbox_deployment_setup.go +++ b/services/agents-api/internal/execution/sandbox_deployment_setup.go @@ -140,7 +140,7 @@ func (m *runtimeManager) prepareCandidate(ctx context.Context, input store.Sandb return PreparedRuntimeDeployment{}, err } config := candidate.Config - if config == nil || config.InstallationID != setup.InstallationID || config.ProviderKind != setup.Provider || config.Mode != setup.Mode || config.CoreURL != setup.CoreURL+"/api/v1" || config.BackendFingerprint != setup.BackendFingerprint || config.LocalNodeID != "" || config.loadDeployment != nil || config.prepareDeployment != nil { + if config == nil || config.InstallationID != setup.InstallationID || config.ProviderKind != setup.Provider || config.Mode != setup.Mode || config.CoreURL == "" || config.BackendFingerprint != setup.BackendFingerprint || config.LocalNodeID != "" || config.loadDeployment != nil || config.prepareDeployment != nil { return PreparedRuntimeDeployment{}, sandbox.ErrInvalid } copied, err := validatedRuntimeProvider(config, m.registry) diff --git a/services/agents-api/internal/execution/sandbox_deployment_setup_test.go b/services/agents-api/internal/execution/sandbox_deployment_setup_test.go index 4cc76ff85..7670b5a7e 100644 --- a/services/agents-api/internal/execution/sandbox_deployment_setup_test.go +++ b/services/agents-api/internal/execution/sandbox_deployment_setup_test.go @@ -140,7 +140,7 @@ func TestRejectedSandboxCandidatePreservesActiveGeneration(t *testing.T) { if err != nil { t.Fatal(err) } - input := store.SandboxDeploymentSetupRequest{Provider: "e2b", CoreURL: "https://core.example", E2B: &store.SandboxE2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}, DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024}}} + input := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &store.SandboxE2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}, DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024}}} if _, err := m.prepareCandidate(t.Context(), input); !errors.Is(err, rejected) { t.Fatal("candidate rejection was lost", err) } @@ -170,7 +170,7 @@ func TestSandboxCandidateValidationDoesNotHoldManagerLock(t *testing.T) { done := make(chan struct{}) go func() { defer close(done) - _, _ = m.prepareCandidate(t.Context(), store.SandboxDeploymentSetupRequest{Provider: "e2b", CoreURL: "https://core.example", E2B: &store.SandboxE2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}, DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024}}}) + _, _ = m.prepareCandidate(t.Context(), store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &store.SandboxE2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}, DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024}}}) }() <-entered stopped := make(chan struct{}) diff --git a/services/agents-api/internal/execution/sandbox_deployment_switch_test.go b/services/agents-api/internal/execution/sandbox_deployment_switch_test.go index f3e1802f5..53c493a56 100644 --- a/services/agents-api/internal/execution/sandbox_deployment_switch_test.go +++ b/services/agents-api/internal/execution/sandbox_deployment_switch_test.go @@ -158,7 +158,7 @@ func TestSandboxSetupRetryCannotBypassOutstandingDrain(t *testing.T) { m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return nil, nil }, func(_ context.Context, setup store.SandboxSetup) (PreparedRuntimeDeployment, error) { - return PreparedRuntimeDeployment{Config: &RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, Mode: setup.Mode, CoreURL: setup.CoreURL + "/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: hub.Proxy(uuid.NewString(), "docker")}}, nil + return PreparedRuntimeDeployment{Config: &RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, Mode: setup.Mode, CoreURL: "https://core.example/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: hub.Proxy(uuid.NewString(), "docker")}}, nil })) if err != nil { t.Fatal(err) @@ -177,7 +177,7 @@ func TestSandboxSetupRetryCannotBypassOutstandingDrain(t *testing.T) { ctx, cancel = context.WithTimeout(t.Context(), 10*time.Millisecond) defer cancel() w := &Worker{runtimes: m} - _, err = w.InitializeSandboxDeployment(ctx, store.SandboxDeploymentSetupRequest{Provider: "e2b", CoreURL: "https://core.example", E2B: &store.SandboxE2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}, DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024}}}) + _, err = w.InitializeSandboxDeployment(ctx, store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &store.SandboxE2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}, DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024}}}) if !errors.Is(err, context.DeadlineExceeded) { t.Fatal("setup retry bypassed the unfinished drain", err) } diff --git a/services/agents-api/internal/execution/sandbox_template_build_test.go b/services/agents-api/internal/execution/sandbox_template_build_test.go index 4590fa601..b09550bf5 100644 --- a/services/agents-api/internal/execution/sandbox_template_build_test.go +++ b/services/agents-api/internal/execution/sandbox_template_build_test.go @@ -8,7 +8,7 @@ import ( ) func TestOmittedE2BResourcesComeFromValidatedTemplateBuild(t *testing.T) { - request := store.SandboxDeploymentSetupRequest{Provider: "e2b", CoreURL: "https://core.example", E2B: &store.SandboxE2BConfiguration{APIKey: "key", Template: "runtime:build"}} + request := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &store.SandboxE2BConfiguration{APIKey: "key", Template: "runtime:build"}} build := &store.SandboxE2BTemplateBuild{Status: "ready", CPUs: 4, MemoryMiB: 4096} saved := withTemplateBuild(request, PreparedRuntimeDeployment{E2BTemplateBuild: build}) if saved.Resources != (sandbox.Resources{CPUs: 4, MemoryMiB: 4096}) || saved.E2B.TemplateBuild == nil || *saved.E2B.TemplateBuild != *build { diff --git a/services/agents-api/internal/store/admin_session_archive_race_test.go b/services/agents-api/internal/store/admin_session_archive_race_test.go index 31b823509..d31dd37ad 100644 --- a/services/agents-api/internal/store/admin_session_archive_race_test.go +++ b/services/agents-api/internal/store/admin_session_archive_race_test.go @@ -17,7 +17,7 @@ func TestManagedSessionArchiveReleasesPendingNodePlacement(t *testing.T) { if err := w.ClaimWebSandboxDeployment(t.Context(), installation); err != nil { t.Fatal(err) } - if _, err := w.InitializeSandboxDeployment(t.Context(), installation, SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker", CoreURL: "https://core.example"}); err != nil { + if _, err := w.InitializeSandboxDeployment(t.Context(), installation, SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker"}); err != nil { t.Fatal(err) } token, _, err := s.CreateRuntimeEnrollment(t.Context(), RuntimeNodeCapacity{MaxActive: 1, MaxRetained: 1}) diff --git a/services/agents-api/internal/store/admin_session_archive_worker_http_test.go b/services/agents-api/internal/store/admin_session_archive_worker_http_test.go index 8e6dbe7a6..c84f659c2 100644 --- a/services/agents-api/internal/store/admin_session_archive_worker_http_test.go +++ b/services/agents-api/internal/store/admin_session_archive_worker_http_test.go @@ -34,7 +34,7 @@ func TestAdminSessionArchiveWorkerHTTPPostgres(t *testing.T) { installation := uuid.NewString() provider := &lifecycleProvider{resources: map[string]sandbox.Info{}} providerConfig := func(setup store.SandboxSetup) *execution.RuntimeProvider { - return &execution.RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, Generation: setup.Generation, Mode: setup.Mode, Maintenance: setup.Maintenance, CoreURL: setup.CoreURL + "/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: provider} + return &execution.RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, Generation: setup.Generation, Mode: setup.Mode, Maintenance: setup.Maintenance, CoreURL: "https://core.example/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: provider} } configuration := execution.NewDeferredRuntimeProvider(installation, func(ctx context.Context) (*execution.RuntimeProvider, error) { setup, err := s.GetSandboxSetup(ctx) @@ -58,7 +58,7 @@ func TestAdminSessionArchiveWorkerHTTPPostgres(t *testing.T) { }) } t.Cleanup(stop) - selection := store.SandboxDeploymentSetupRequest{DeploymentSpec: store.SandboxDeploymentTestSpec("e2b"), Provider: "e2b", CoreURL: "https://core.example", E2B: &store.SandboxE2BConfiguration{APIKey: "fixture-api-key", Template: "runtime:" + uuid.NewString()}} + selection := store.SandboxDeploymentSetupRequest{DeploymentSpec: store.SandboxDeploymentTestSpec("e2b"), Provider: "e2b", E2B: &store.SandboxE2BConfiguration{APIKey: "fixture-api-key", Template: "runtime:" + uuid.NewString()}} if _, err := worker.InitializeSandboxDeployment(t.Context(), selection); err != nil { t.Fatal(err) } diff --git a/services/agents-api/internal/store/credential_matrix_http_test.go b/services/agents-api/internal/store/credential_matrix_http_test.go index 03b8064c4..5d92d2506 100644 --- a/services/agents-api/internal/store/credential_matrix_http_test.go +++ b/services/agents-api/internal/store/credential_matrix_http_test.go @@ -93,9 +93,9 @@ func TestCredentialNamespaceMatrix(t *testing.T) { if err != nil || setup.Provider == "" { return nil, err } - return &execution.RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, BackendFingerprint: setup.BackendFingerprint, CoreURL: setup.CoreURL + "/api/v1", Provider: provider}, nil + return &execution.RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, BackendFingerprint: setup.BackendFingerprint, CoreURL: "https://core.example/api/v1", Provider: provider}, nil }, func(_ context.Context, setup store.SandboxSetup) (execution.PreparedRuntimeDeployment, error) { - return execution.PreparedRuntimeDeployment{Config: &execution.RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, Mode: setup.Mode, Maintenance: setup.Maintenance, CoreURL: setup.CoreURL + "/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: provider}}, nil + return execution.PreparedRuntimeDeployment{Config: &execution.RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, Mode: setup.Mode, Maintenance: setup.Maintenance, CoreURL: "https://core.example/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: provider}}, nil }) worker, err := execution.StartWorker(ctx, &execution.Dispatcher{Store: s, Registry: gateway.NewRegistry(), ManagedRuntimes: runtimes}) if err != nil { @@ -110,7 +110,7 @@ func TestCredentialNamespaceMatrix(t *testing.T) { }) }) specification := store.SandboxDeploymentTestSpec("docker") - if _, err := worker.InitializeSandboxDeployment(ctx, store.SandboxDeploymentSetupRequest{DeploymentSpec: specification, Provider: "docker", CoreURL: "https://core.example"}); err != nil { + if _, err := worker.InitializeSandboxDeployment(ctx, store.SandboxDeploymentSetupRequest{DeploymentSpec: specification, Provider: "docker"}); err != nil { t.Fatal(err) } var enrollment api.SandboxEnrollmentToken diff --git a/services/agents-api/internal/store/public_url.go b/services/agents-api/internal/store/public_url.go new file mode 100644 index 000000000..f6a9d7e08 --- /dev/null +++ b/services/agents-api/internal/store/public_url.go @@ -0,0 +1,39 @@ +package store + +import ( + "context" + "errors" +) + +// ErrSandboxPublicURLUnreachable rejects E2B while the installation public URL +// is loopback: E2B sandboxes reach Core from E2B's cloud. +var ErrSandboxPublicURLUnreachable = errors.New("E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback (public_url in config.json, AGENTS_API_PUBLIC_URL for Core), then save again.") + +// SetPublicURL records AGENTS_API_PUBLIC_URL, validated by the caller. Core +// reports it as the deployment and node configuration core_url and records it +// on each node it enrolls. Call it once, before serving requests. +func (s *Store) SetPublicURL(value string) { s.publicURL = value } + +// AddressBindings counts what is bound to an installation address: nodes +// connect to the address they enrolled with, hosted sandboxes were started with +// the address current at the time, and self-hosted executors were installed +// with an advertised remote_url. +type AddressBindings struct { + Nodes int64 `json:"nodes"` + NodesOnOtherAddress int64 `json:"nodes_on_other_address"` + HostedSandboxes int64 `json:"hosted_sandboxes"` + SelfHostedExecutors int64 `json:"self_hosted_executors"` +} + +func (s *Store) AddressBindings(ctx context.Context) (AddressBindings, error) { + bindings, err := s.queries.CountAddressBindings(ctx, s.publicURL) + if err != nil { + return AddressBindings{}, err + } + resources, err := s.queries.CountRuntimeDeploymentResources(ctx) + if err != nil { + return AddressBindings{}, err + } + return AddressBindings{Nodes: bindings.Nodes, NodesOnOtherAddress: bindings.NodesOnOtherAddress, + HostedSandboxes: resources.Allocations + resources.Pending, SelfHostedExecutors: bindings.SelfHostedExecutors}, nil +} diff --git a/services/agents-api/internal/store/public_url_migration_test.go b/services/agents-api/internal/store/public_url_migration_test.go new file mode 100644 index 000000000..6e1afbb18 --- /dev/null +++ b/services/agents-api/internal/store/public_url_migration_test.go @@ -0,0 +1,77 @@ +package store + +import ( + "context" + "database/sql" + "os" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" +) + +// The deployment's saved Core address moves to each node it enrolled, and a +// rollback takes it back from them. +func TestPublicURLMigrationMovesTheAddressToNodes(t *testing.T) { + _, pool := testStore(t) + ctx := t.Context() + schema := "public_url_" + uuid.NewString()[:8] + quoted := pgx.Identifier{schema}.Sanitize() + if _, err := pool.Exec(ctx, "CREATE SCHEMA "+quoted); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if _, err := pool.Exec(context.Background(), "DROP SCHEMA "+quoted+" CASCADE"); err != nil { + t.Error(err) + } + }) + cfg := pool.Config().ConnConfig.Copy() + cfg.RuntimeParams["search_path"] = schema + db := sql.OpenDB(stdlib.GetConnector(*cfg)) + t.Cleanup(func() { _ = db.Close() }) + provider, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../../migrations"), goose.WithTableName("agents_api_schema_version")) + if err != nil { + t.Fatal(err) + } + if _, err := provider.UpTo(ctx, 74); err != nil { + t.Fatal(err) + } + installation, active, removed := uuid.NewString(), uuid.NewString(), uuid.NewString() + if _, err := db.ExecContext(ctx, `UPDATE runtime_deployment SET installation_id=$1, web_managed=true, provider_kind='docker', mode='nodes', generation=1, + core_url='https://core.example', backend_fingerprint=$2`, installation, strings.Repeat("a", 64)); err != nil { + t.Fatal(err) + } + for _, node := range []struct { + id string + removed bool + }{{active, false}, {removed, true}} { + if _, err := db.ExecContext(ctx, `INSERT INTO runtime_nodes(id,installation_id,name,backend_fingerprint,credential_sha256,max_active,max_retained,removed_at) + VALUES ($1,$2,'node',$3,$4,2,8,CASE WHEN $5 THEN clock_timestamp() END)`, node.id, installation, strings.Repeat("a", 64), strings.Repeat("c", 64), node.removed); err != nil { + t.Fatal(err) + } + } + if _, err := provider.UpTo(ctx, 75); err != nil { + t.Fatal(err) + } + address := func(id string) string { + t.Helper() + var value string + if err := db.QueryRowContext(ctx, "SELECT core_url FROM runtime_nodes WHERE id=$1", id).Scan(&value); err != nil { + t.Fatal(err) + } + return value + } + if address(active) != "https://core.example" || address(removed) != "" { + t.Fatal("migration did not record the enrolled address on the active node only") + } + if _, err := provider.DownTo(ctx, 74); err != nil { + t.Fatal(err) + } + var restored string + if err := db.QueryRowContext(ctx, "SELECT core_url FROM runtime_deployment").Scan(&restored); err != nil || restored != "https://core.example" { + t.Fatal("rollback lost the deployment address", restored, err) + } +} diff --git a/services/agents-api/internal/store/runtime_node_configuration.go b/services/agents-api/internal/store/runtime_node_configuration.go index a57d91c55..306dbb2f7 100644 --- a/services/agents-api/internal/store/runtime_node_configuration.go +++ b/services/agents-api/internal/store/runtime_node_configuration.go @@ -67,7 +67,7 @@ func (s *Store) RuntimeNodeConfiguration(ctx context.Context, nodeID, token stri if node != nil && (node.DeploymentGeneration != d.Generation || node.SpecificationDigest != spec.Digest(d.ProviderKind)) { return ErrRuntimeSpecificationMismatch } - result = RuntimeNodeConfiguration{MaxActive: int(active), MaxRetained: retainedLimit(d.ProviderKind, int(active), int(retained)), InstallationID: runtimeUUID(d.InstallationID), Provider: d.ProviderKind, CoreURL: d.CoreUrl, Generation: uint64(d.Generation), Specification: spec, SpecificationDigest: spec.Digest(d.ProviderKind)} + result = RuntimeNodeConfiguration{MaxActive: int(active), MaxRetained: retainedLimit(d.ProviderKind, int(active), int(retained)), InstallationID: runtimeUUID(d.InstallationID), Provider: d.ProviderKind, CoreURL: s.publicURL, Generation: uint64(d.Generation), Specification: spec, SpecificationDigest: spec.Digest(d.ProviderKind)} return nil }) return result, err diff --git a/services/agents-api/internal/store/runtime_node_types.go b/services/agents-api/internal/store/runtime_node_types.go index 748e968f7..c6d6240a1 100644 --- a/services/agents-api/internal/store/runtime_node_types.go +++ b/services/agents-api/internal/store/runtime_node_types.go @@ -62,6 +62,9 @@ type RuntimeNode struct { Retained int64 `json:"retained"` CleanupPending int64 `json:"cleanup_pending"` CreatedAt time.Time `json:"created_at"` + // The Core address this node enrolled with. A node whose address differs + // from the installation public URL receives no new sandboxes; re-add it. + CoreURL string `json:"core_url"` } type RuntimeNodeUpdate struct { Name string `json:"name"` @@ -115,11 +118,12 @@ type RuntimeDeploymentView struct { E2B *SandboxE2BView `json:"e2b,omitempty"` // Idle suspension policy; microsandbox only, otherwise null. Suspension *SandboxSuspensionView `json:"suspension" extensions:"x-nullable"` - CoreURL string `json:"core_url"` InstallationID string `json:"installation_id"` Provider string `json:"provider"` Maintenance bool `json:"maintenance"` OwnerEpoch uint64 `json:"owner_epoch"` + // Read-only: the installation public URL (AGENTS_API_PUBLIC_URL), which nodes and sandboxes use to reach Core. The deployment API does not accept it. + CoreURL string `json:"core_url"` } type RuntimeNodeAllocation struct { Diagnostic string `json:"diagnostic"` diff --git a/services/agents-api/internal/store/runtime_nodes.go b/services/agents-api/internal/store/runtime_nodes.go index a4fdcc4b6..cb60b1bb0 100644 --- a/services/agents-api/internal/store/runtime_nodes.go +++ b/services/agents-api/internal/store/runtime_nodes.go @@ -66,9 +66,11 @@ func runtimeDeploymentInitialized(d sqlc.RuntimeDeployment) bool { return d.InstallationID.Valid && d.ProviderKind != "" } func (s *Store) GetRuntimeDeployment(ctx context.Context) (RuntimeDeploymentView, error) { - return getRuntimeDeploymentView(ctx, s.queries) + return s.deploymentView(ctx, s.queries) } -func getRuntimeDeploymentView(ctx context.Context, q *sqlc.Queries) (RuntimeDeploymentView, error) { + +// deploymentView reports the public URL as the deployment's read-only core_url. +func (s *Store) deploymentView(ctx context.Context, q *sqlc.Queries) (RuntimeDeploymentView, error) { d, err := q.GetRuntimeDeployment(ctx) if err != nil { return RuntimeDeploymentView{}, err @@ -77,7 +79,7 @@ func getRuntimeDeploymentView(ctx context.Context, q *sqlc.Queries) (RuntimeDepl if err != nil { return RuntimeDeploymentView{}, err } - result := runtimeDeploymentView(d) + result := runtimeDeploymentView(d, s.publicURL) result.Resources = SandboxDeploymentResources{Allocations: resources.Allocations, Pending: resources.Pending} return result, nil } @@ -112,7 +114,7 @@ func runtimeNodeViews(rows []sqlc.ListRuntimeNodesRow) ([]RuntimeNode, error) { return nil, err } health.ProviderReady = n.ProviderReady - out = append(out, RuntimeNode{RuntimeNodeHealth: health, Running: n.Running, Snapshots: n.Snapshots, ID: runtimeUUID(n.ID), Name: n.Name, Provider: n.ProviderKind, Online: n.Online, LastSeenAt: seen, MaxActive: int(n.MaxActive), MaxRetained: retainedLimit(n.ProviderKind, int(n.MaxActive), int(n.MaxRetained)), Active: n.Active, Reserved: n.Reserved, Retained: n.Retained, CleanupPending: n.CleanupPending, CreatedAt: n.CreatedAt.Time}) + out = append(out, RuntimeNode{RuntimeNodeHealth: health, Running: n.Running, Snapshots: n.Snapshots, ID: runtimeUUID(n.ID), Name: n.Name, CoreURL: n.CoreUrl, Provider: n.ProviderKind, Online: n.Online, LastSeenAt: seen, MaxActive: int(n.MaxActive), MaxRetained: retainedLimit(n.ProviderKind, int(n.MaxActive), int(n.MaxRetained)), Active: n.Active, Reserved: n.Reserved, Retained: n.Retained, CleanupPending: n.CleanupPending, CreatedAt: n.CreatedAt.Time}) } return out, nil } @@ -182,7 +184,7 @@ func (s *Store) EnrollRuntimeNode(ctx context.Context, token string, input Runti } else if !errors.Is(err, pgx.ErrNoRows) { return err } - row, err := q.InsertRuntimeNode(ctx, sqlc.InsertRuntimeNodeParams{ID: id, InstallationID: d.InstallationID, Name: input.Name, BackendFingerprint: input.BackendFingerprint, CredentialSha256: runtimeTokenDigest(input.Credential), MaxActive: receipt.MaxActive, MaxRetained: int32(retainedLimit(d.ProviderKind, int(receipt.MaxActive), int(receipt.MaxRetained))), SpecificationDigest: input.SpecificationDigest, DeploymentGeneration: int64(input.DeploymentGeneration)}) + row, err := q.InsertRuntimeNode(ctx, sqlc.InsertRuntimeNodeParams{ID: id, InstallationID: d.InstallationID, Name: input.Name, BackendFingerprint: input.BackendFingerprint, CredentialSha256: runtimeTokenDigest(input.Credential), MaxActive: receipt.MaxActive, MaxRetained: int32(retainedLimit(d.ProviderKind, int(receipt.MaxActive), int(receipt.MaxRetained))), SpecificationDigest: input.SpecificationDigest, DeploymentGeneration: int64(input.DeploymentGeneration), CoreUrl: s.publicURL}) if err != nil { return err } diff --git a/services/agents-api/internal/store/runtime_placements.go b/services/agents-api/internal/store/runtime_placements.go index 99275328f..c668743f6 100644 --- a/services/agents-api/internal/store/runtime_placements.go +++ b/services/agents-api/internal/store/runtime_placements.go @@ -8,8 +8,9 @@ import ( ) // reserveRuntimePlacement shares the deployment lock with restore and node removal. -// A Session creation retry never reaches this function. -func reserveRuntimePlacement(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { +// A Session creation retry never reaches this function. A node enrolled with +// another Core address receives no new sandbox; restores still reach it. +func reserveRuntimePlacement(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, publicURL string) error { d, err := q.LockRuntimeDeployment(ctx) if err != nil { return err @@ -36,7 +37,7 @@ func reserveRuntimePlacement(ctx context.Context, q *sqlc.Queries, session pgtyp var chosen *sqlc.ListRuntimeNodesRow for i := range rows { n := &rows[i] - if !n.Online || !n.ProviderReady || n.Active >= int64(n.MaxActive) || n.Retained >= int64(n.MaxRetained) { + if !n.Online || !n.ProviderReady || n.Active >= int64(n.MaxActive) || n.Retained >= int64(n.MaxRetained) || (d.WebManaged && n.CoreUrl != publicURL) { continue } if chosen == nil || n.Active < chosen.Active { diff --git a/services/agents-api/internal/store/sandbox_deployment_mutations.go b/services/agents-api/internal/store/sandbox_deployment_mutations.go index 6e163d8f0..e9d8e8985 100644 --- a/services/agents-api/internal/store/sandbox_deployment_mutations.go +++ b/services/agents-api/internal/store/sandbox_deployment_mutations.go @@ -57,9 +57,6 @@ func validateSandboxSelection(input SandboxDeploymentSetupRequest) error { } else if err := input.DeploymentSpec.Validate(input.Provider); err != nil { return &SandboxConfigurationError{Message: err.Error()} } - if ValidateSandboxCoreURL(input.CoreURL) != nil { - return ErrInvalidInput - } switch input.Provider { case "docker", "microsandbox": if input.E2B != nil { @@ -93,7 +90,7 @@ func (s *Store) sandboxSelectionEqual(d sqlc.RuntimeDeployment, input SandboxDep if spec.Digest(d.ProviderKind) != input.DeploymentSpec.Digest(input.Provider) { return false, nil } - if d.ProviderKind != input.Provider || d.CoreUrl != input.CoreURL { + if d.ProviderKind != input.Provider { return false, nil } if input.E2B == nil { @@ -125,7 +122,7 @@ func (s *Store) saveSandboxSelection(ctx context.Context, q *sqlc.Queries, d sql namespace = "e2b:" + runtimeUUID(d.InstallationID) } digest := sha256.Sum256([]byte(input.Provider + "\x00" + namespace)) - params := sqlc.InitializeSandboxDeploymentParams{ProviderKind: input.Provider, CoreUrl: input.CoreURL, BackendFingerprint: hex.EncodeToString(digest[:]), Generation: generation, Mode: mode} + params := sqlc.InitializeSandboxDeploymentParams{ProviderKind: input.Provider, BackendFingerprint: hex.EncodeToString(digest[:]), Generation: generation, Mode: mode} params.Specification, _ = json.Marshal(input.DeploymentSpec) if input.Provider == "microsandbox" { params.IdleSeconds, params.RetentionSeconds = 300, 86400 @@ -203,7 +200,7 @@ func (s *Store) InitializeSandboxDeployment(ctx context.Context, installationID } else if err := s.saveSandboxSelection(ctx, q, d, input); err != nil { return err } - result, err = getRuntimeDeploymentView(ctx, q) + result, err = s.deploymentView(ctx, q) return err }) return result, err @@ -230,7 +227,7 @@ func (s *Store) CheckSandboxDeploymentSwitch(ctx context.Context, installation s }) } func checkSandboxSwitch(ctx context.Context, q *sqlc.Queries, d sqlc.RuntimeDeployment, installation string, input SandboxDeploymentUpdateRequest) error { - if !d.WebManaged || runtimeUUID(d.InstallationID) != installation || d.ProviderKind == "" || !d.Maintenance || uint64(d.Generation) != input.ExpectedGeneration || d.CoreUrl != input.CoreURL { + if !d.WebManaged || runtimeUUID(d.InstallationID) != installation || d.ProviderKind == "" || !d.Maintenance || uint64(d.Generation) != input.ExpectedGeneration { return ErrSandboxDeploymentConflict } resources, err := q.CountRuntimeDeploymentResources(ctx) @@ -282,7 +279,7 @@ func (s *Store) UpdateSandboxDeployment(ctx context.Context, installation string } else if err := recordTemplateBuild(ctx, q, input.SandboxDeploymentSetupRequest); err != nil { return err } - result, err = getRuntimeDeploymentView(ctx, q) + result, err = s.deploymentView(ctx, q) return err }) return result, err @@ -308,7 +305,7 @@ func (s *Store) SetSandboxMaintenance(ctx context.Context, installation string, if err := q.SetSandboxMaintenance(ctx, input.Maintenance); err != nil { return err } - result, err = getRuntimeDeploymentView(ctx, q) + result, err = s.deploymentView(ctx, q) return err }) return result, err diff --git a/services/agents-api/internal/store/sandbox_deployment_setup.go b/services/agents-api/internal/store/sandbox_deployment_setup.go index f9781c8a6..bbcabd86f 100644 --- a/services/agents-api/internal/store/sandbox_deployment_setup.go +++ b/services/agents-api/internal/store/sandbox_deployment_setup.go @@ -20,20 +20,19 @@ var ErrSandboxDeploymentConflict = errors.New("sandbox deployment is already con type SandboxDeploymentSetupRequest struct { sandbox.DeploymentSpec Provider string `json:"provider"` - CoreURL string `json:"core_url"` E2B *SandboxE2BConfiguration `json:"e2b,omitempty"` } // SandboxSetup is the immutable configuration selected by the deployment admin. // An empty Provider means that Web setup has not yet selected an adapter. type SandboxSetup struct { - Specification sandbox.DeploymentSpec - InstallationID, Provider, CoreURL, BackendFingerprint string - Generation uint64 - Mode string - Maintenance bool - E2B *SandboxE2BConfiguration - IdleSeconds, RetentionSeconds int64 + Specification sandbox.DeploymentSpec + InstallationID, Provider, BackendFingerprint string + Generation uint64 + Mode string + Maintenance bool + E2B *SandboxE2BConfiguration + IdleSeconds, RetentionSeconds int64 } func (s *Store) GetSandboxSetup(ctx context.Context) (SandboxSetup, error) { @@ -46,7 +45,7 @@ func (s *Store) GetSandboxSetup(ctx context.Context) (SandboxSetup, error) { if !d.WebManaged { return SandboxSetup{}, ErrSandboxDeploymentConflict } - result := SandboxSetup{InstallationID: runtimeUUID(d.InstallationID), Provider: d.ProviderKind, CoreURL: d.CoreUrl, BackendFingerprint: d.BackendFingerprint, IdleSeconds: d.IdleSeconds, RetentionSeconds: d.RetentionSeconds, Generation: uint64(d.Generation), Mode: d.Mode, Maintenance: d.Maintenance} + result := SandboxSetup{InstallationID: runtimeUUID(d.InstallationID), Provider: d.ProviderKind, BackendFingerprint: d.BackendFingerprint, IdleSeconds: d.IdleSeconds, RetentionSeconds: d.RetentionSeconds, Generation: uint64(d.Generation), Mode: d.Mode, Maintenance: d.Maintenance} if err := json.Unmarshal(d.Specification, &result.Specification); err != nil { return SandboxSetup{}, err } @@ -99,6 +98,7 @@ func (s *Store) ClaimWebSandboxDeployment(ctx context.Context, installationID st // ValidateSandboxCoreURL accepts a canonical public origin, never a path or // credential. Plain HTTP is reserved for explicit loopback development hosts. +// AGENTS_API_PUBLIC_URL must pass it. func ValidateSandboxCoreURL(value string) error { u, err := url.Parse(value) if err != nil || u.Hostname() == "" || u.User != nil || u.Path != "" || u.RawPath != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || u.RawFragment != "" || u.Opaque != "" || u.String() != value || u.Host != strings.ToLower(u.Host) { @@ -137,8 +137,21 @@ func ValidateSandboxCoreURL(value string) error { return nil } -func runtimeDeploymentView(d sqlc.RuntimeDeployment) RuntimeDeploymentView { - result := RuntimeDeploymentView{InstallationID: runtimeUUID(d.InstallationID), Provider: d.ProviderKind, CoreURL: d.CoreUrl, Maintenance: d.Maintenance, OwnerEpoch: uint64(d.OwnerEpoch), Generation: uint64(d.Generation), Mode: d.Mode} +// LoopbackOrigin reports whether a validated origin names a loopback host, which +// nothing outside the Core host can reach. +func LoopbackOrigin(value string) bool { + u, err := url.Parse(value) + if err != nil { + return false + } + if ip := net.ParseIP(u.Hostname()); ip != nil { + return ip.IsLoopback() + } + return u.Hostname() == "localhost" +} + +func runtimeDeploymentView(d sqlc.RuntimeDeployment, publicURL string) RuntimeDeploymentView { + result := RuntimeDeploymentView{InstallationID: runtimeUUID(d.InstallationID), Provider: d.ProviderKind, CoreURL: publicURL, Maintenance: d.Maintenance, OwnerEpoch: uint64(d.OwnerEpoch), Generation: uint64(d.Generation), Mode: d.Mode} if len(d.Specification) > 0 && string(d.Specification) != "{}" { var spec sandbox.DeploymentSpec if json.Unmarshal(d.Specification, &spec) == nil { diff --git a/services/agents-api/internal/store/sandbox_deployment_setup_test.go b/services/agents-api/internal/store/sandbox_deployment_setup_test.go index ee660524d..a9dd54e37 100644 --- a/services/agents-api/internal/store/sandbox_deployment_setup_test.go +++ b/services/agents-api/internal/store/sandbox_deployment_setup_test.go @@ -31,6 +31,7 @@ func TestSandboxCoreURLValidation(t *testing.T) { func TestSandboxDeploymentSetupPersistsWithoutExecution(t *testing.T) { s, pool := newManagedTestStore(t) + s.SetPublicURL("https://core.example") lease := executionLease(t, s) w := lease.Store() id := uuid.NewString() @@ -38,22 +39,22 @@ func TestSandboxDeploymentSetupPersistsWithoutExecution(t *testing.T) { t.Fatal(err) } before, err := s.GetRuntimeDeployment(t.Context()) - if err != nil || before.InstallationID != id || before.Provider != "" || before.CoreURL != "" { + if err != nil || before.InstallationID != id || before.Provider != "" || before.CoreURL != "https://core.example" { t.Fatal(before, err) } if _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString())); !errors.Is(err, ErrRuntimeNodeUnavailable) { t.Fatal("uninitialized hosted admission", err) } - input := SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("microsandbox"), Provider: "microsandbox", CoreURL: "https://core.example"} + input := SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("microsandbox"), Provider: "microsandbox"} selected, err := w.InitializeSandboxDeployment(t.Context(), id, input) - if err != nil || selected.Provider != input.Provider || selected.CoreURL != input.CoreURL || selected.OwnerEpoch != before.OwnerEpoch { + if err != nil || selected.Provider != input.Provider || selected.CoreURL != "https://core.example" || selected.OwnerEpoch != before.OwnerEpoch { t.Fatal(selected, err) } replay, err := w.InitializeSandboxDeployment(t.Context(), id, input) if err != nil || !reflect.DeepEqual(replay, selected) { t.Fatal("identical retry changed selection", replay, err) } - for _, changed := range []SandboxDeploymentSetupRequest{{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker", CoreURL: input.CoreURL}, {DeploymentSpec: input.DeploymentSpec, Provider: input.Provider, CoreURL: "https://another.example"}} { + for _, changed := range []SandboxDeploymentSetupRequest{{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker"}} { if _, err := w.InitializeSandboxDeployment(t.Context(), id, changed); !errors.Is(err, ErrSandboxDeploymentConflict) { t.Fatal("changed selection accepted", err) } @@ -104,7 +105,7 @@ func TestSandboxDeploymentSetupConcurrentSelection(t *testing.T) { if i%2 == 1 { provider = "microsandbox" } - value, err := w.InitializeSandboxDeployment(t.Context(), id, SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec(provider), Provider: provider, CoreURL: "https://core.example"}) + value, err := w.InitializeSandboxDeployment(t.Context(), id, SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec(provider), Provider: provider}) results <- value errorsFound <- err }() @@ -136,7 +137,7 @@ func TestSandboxDeploymentSetupConcurrentSelection(t *testing.T) { func TestSandboxDeploymentSetupRejectsFileManagedAndUnleasedWrites(t *testing.T) { s, w, selection := managerFixture(t, 4, 16) - input := SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker", CoreURL: "https://core.example"} + input := SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker"} if _, err := s.InitializeSandboxDeployment(t.Context(), selection.InstallationID, input); !errors.Is(err, ErrInvalidInput) { t.Fatal("unleased setup accepted", err) } diff --git a/services/agents-api/internal/store/sandbox_deployment_switch_test.go b/services/agents-api/internal/store/sandbox_deployment_switch_test.go index 70f18b886..a65d8eafb 100644 --- a/services/agents-api/internal/store/sandbox_deployment_switch_test.go +++ b/services/agents-api/internal/store/sandbox_deployment_switch_test.go @@ -14,7 +14,7 @@ import ( ) func e2bSelection() SandboxDeploymentSetupRequest { - return SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("e2b"), Provider: "e2b", CoreURL: "https://core.example", E2B: &SandboxE2BConfiguration{APIKey: "fixture-private-api-key", Template: "runtime:" + uuid.NewString()}} + return SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("e2b"), Provider: "e2b", E2B: &SandboxE2BConfiguration{APIKey: "fixture-private-api-key", Template: "runtime:" + uuid.NewString()}} } func TestSandboxDirectDeploymentOwnershipAndCleanSwitch(t *testing.T) { _, pool := newManagedTestStore(t) @@ -76,7 +76,7 @@ func TestSandboxDirectDeploymentOwnershipAndCleanSwitch(t *testing.T) { if _, err := w.SetSandboxMaintenance(t.Context(), id, SandboxMaintenanceRequest{Maintenance: true, ExpectedGeneration: 1}); err != nil { t.Fatal(err) } - update := SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker", CoreURL: input.CoreURL}, ExpectedGeneration: 1} + update := SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker"}, ExpectedGeneration: 1} if _, err := w.UpdateSandboxDeployment(t.Context(), id, update); !errors.Is(err, ErrSandboxDeploymentConflict) { t.Fatal("unknown allocation allowed switch", err) } @@ -113,7 +113,7 @@ func TestSandboxSwitchRetiresNodesAndEnrollment(t *testing.T) { if err := w.ClaimWebSandboxDeployment(t.Context(), id); err != nil { t.Fatal(err) } - if _, err := w.InitializeSandboxDeployment(t.Context(), id, SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker", CoreURL: "https://core.example"}); err != nil { + if _, err := w.InitializeSandboxDeployment(t.Context(), id, SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker"}); err != nil { t.Fatal(err) } token, _, err := s.CreateRuntimeEnrollment(t.Context(), RuntimeNodeCapacity{MaxActive: 2, MaxRetained: 4}) @@ -161,7 +161,7 @@ func TestSandboxSwitchRetiresNodesAndEnrollment(t *testing.T) { if _, err := w.SetSandboxMaintenance(t.Context(), id, SandboxMaintenanceRequest{Maintenance: true, ExpectedGeneration: 2}); err != nil { t.Fatal(err) } - if _, err := w.UpdateSandboxDeployment(t.Context(), id, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker", CoreURL: input.CoreURL}, ExpectedGeneration: 2}); err != nil { + if _, err := w.UpdateSandboxDeployment(t.Context(), id, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker"}, ExpectedGeneration: 2}); err != nil { t.Fatal(err) } if _, err := w.SetSandboxMaintenance(t.Context(), id, SandboxMaintenanceRequest{ExpectedGeneration: 3}); err != nil { @@ -214,7 +214,7 @@ func TestSandboxMaintenanceSerializesFreshDirectSessions(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = w.UpdateSandboxDeployment(t.Context(), id, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker", CoreURL: input.CoreURL}, ExpectedGeneration: 1}) + _, err = w.UpdateSandboxDeployment(t.Context(), id, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker"}, ExpectedGeneration: 1}) if view.Resources.Pending > 0 && !errors.Is(err, ErrSandboxDeploymentConflict) { t.Fatal("committed pending Session bypassed switch guard", err) } @@ -275,7 +275,7 @@ func TestSandboxSwitchPreservesReleasedAllocationAndItemHistory(t *testing.T) { if _, err := w.SetSandboxMaintenance(t.Context(), installation, SandboxMaintenanceRequest{Maintenance: true, ExpectedGeneration: 1}); err != nil { t.Fatal(err) } - if _, err := w.UpdateSandboxDeployment(t.Context(), installation, SandboxDeploymentUpdateRequest{ExpectedGeneration: 1, SandboxDeploymentSetupRequest: SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker", CoreURL: selection.CoreURL}}); err != nil { + if _, err := w.UpdateSandboxDeployment(t.Context(), installation, SandboxDeploymentUpdateRequest{ExpectedGeneration: 1, SandboxDeploymentSetupRequest: SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker"}}); err != nil { t.Fatal(err) } items, err = s.ListItems(t.Context(), tenant, history.ID, "", 100, true) @@ -314,7 +314,7 @@ func TestUnspecifiedNodeDeploymentDrainsBeforeReplacement(t *testing.T) { t.Fatal(err) } spec := SandboxDeploymentTestSpec("docker") - selection := SandboxDeploymentSetupRequest{DeploymentSpec: spec, Provider: "docker", CoreURL: "https://core.example"} + selection := SandboxDeploymentSetupRequest{DeploymentSpec: spec, Provider: "docker"} if _, err := w.InitializeSandboxDeployment(t.Context(), id, selection); err != nil { t.Fatal(err) } diff --git a/services/agents-api/internal/store/sandbox_deployment_switch_worker_test.go b/services/agents-api/internal/store/sandbox_deployment_switch_worker_test.go index 3064ba07e..15a1b5900 100644 --- a/services/agents-api/internal/store/sandbox_deployment_switch_worker_test.go +++ b/services/agents-api/internal/store/sandbox_deployment_switch_worker_test.go @@ -30,12 +30,12 @@ func TestSandboxWorkerSwitchesAndRecoversFailedActivation(t *testing.T) { if err != nil || setup.Provider == "" { return nil, err } - return &execution.RuntimeProvider{InstallationID: id, ProviderKind: setup.Provider, Generation: setup.Generation, Mode: setup.Mode, Maintenance: setup.Maintenance, CoreURL: setup.CoreURL + "/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: p}, nil + return &execution.RuntimeProvider{InstallationID: id, ProviderKind: setup.Provider, Generation: setup.Generation, Mode: setup.Mode, Maintenance: setup.Maintenance, CoreURL: "https://core.example/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: p}, nil }, func(ctx context.Context, setup store.SandboxSetup) (execution.PreparedRuntimeDeployment, error) { if fail.Load() { return execution.PreparedRuntimeDeployment{}, errors.New("fixture provider unavailable") } - return execution.PreparedRuntimeDeployment{Config: &execution.RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, Mode: setup.Mode, Maintenance: setup.Maintenance, CoreURL: setup.CoreURL + "/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: p}}, nil + return execution.PreparedRuntimeDeployment{Config: &execution.RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, Mode: setup.Mode, Maintenance: setup.Maintenance, CoreURL: "https://core.example/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: p}}, nil }) w, err := execution.StartWorker(t.Context(), &execution.Dispatcher{Store: s, Registry: gateway.NewRegistry(), ManagedRuntimes: configuration}) if err != nil { @@ -53,7 +53,7 @@ func TestSandboxWorkerSwitchesAndRecoversFailedActivation(t *testing.T) { } }) fail.Store(true) - if _, err := w.InitializeSandboxDeployment(t.Context(), store.SandboxDeploymentSetupRequest{DeploymentSpec: store.SandboxDeploymentTestSpec("docker"), Provider: "docker", CoreURL: "https://core.example"}); err == nil { + if _, err := w.InitializeSandboxDeployment(t.Context(), store.SandboxDeploymentSetupRequest{DeploymentSpec: store.SandboxDeploymentTestSpec("docker"), Provider: "docker"}); err == nil { t.Fatal("rejected initial provider configuration was committed") } empty, err := s.GetRuntimeDeployment(t.Context()) @@ -61,7 +61,7 @@ func TestSandboxWorkerSwitchesAndRecoversFailedActivation(t *testing.T) { t.Fatal("failed initial candidate changed the deployment", err) } fail.Store(false) - if _, err := w.InitializeSandboxDeployment(t.Context(), store.SandboxDeploymentSetupRequest{DeploymentSpec: store.SandboxDeploymentTestSpec("docker"), Provider: "docker", CoreURL: "https://core.example"}); err != nil { + if _, err := w.InitializeSandboxDeployment(t.Context(), store.SandboxDeploymentSetupRequest{DeploymentSpec: store.SandboxDeploymentTestSpec("docker"), Provider: "docker"}); err != nil { t.Fatal(err) } enrollment, _, err := s.CreateRuntimeEnrollment(t.Context(), store.RuntimeNodeCapacity{MaxActive: 2, MaxRetained: 4}) @@ -79,7 +79,7 @@ func TestSandboxWorkerSwitchesAndRecoversFailedActivation(t *testing.T) { if err != nil { t.Fatal(err) } - input := store.SandboxDeploymentUpdateRequest{ExpectedGeneration: 1, SandboxDeploymentSetupRequest: store.SandboxDeploymentSetupRequest{DeploymentSpec: store.SandboxDeploymentTestSpec("e2b"), Provider: "e2b", CoreURL: "https://core.example", E2B: &store.SandboxE2BConfiguration{APIKey: "fixture-api-key", Template: "runtime:" + uuid.NewString()}}} + input := store.SandboxDeploymentUpdateRequest{ExpectedGeneration: 1, SandboxDeploymentSetupRequest: store.SandboxDeploymentSetupRequest{DeploymentSpec: store.SandboxDeploymentTestSpec("e2b"), Provider: "e2b", E2B: &store.SandboxE2BConfiguration{APIKey: "fixture-api-key", Template: "runtime:" + uuid.NewString()}}} fail.Store(true) if _, err := w.UpdateSandboxDeployment(t.Context(), input); err == nil { t.Fatal("failed activation reported success") @@ -109,7 +109,7 @@ func TestSandboxWorkerSwitchesAndRecoversFailedActivation(t *testing.T) { if _, err := w.SetSandboxMaintenance(t.Context(), store.SandboxMaintenanceRequest{Maintenance: true, ExpectedGeneration: 2}); err != nil { t.Fatal(err) } - next := store.SandboxDeploymentUpdateRequest{ExpectedGeneration: 2, SandboxDeploymentSetupRequest: store.SandboxDeploymentSetupRequest{DeploymentSpec: store.SandboxDeploymentTestSpec("docker"), Provider: "docker", CoreURL: "https://core.example"}} + next := store.SandboxDeploymentUpdateRequest{ExpectedGeneration: 2, SandboxDeploymentSetupRequest: store.SandboxDeploymentSetupRequest{DeploymentSpec: store.SandboxDeploymentTestSpec("docker"), Provider: "docker"}} if _, err := w.UpdateSandboxDeployment(t.Context(), next); !errors.Is(err, store.ErrSandboxDeploymentConflict) { t.Fatal("dirty switch accepted", err) } diff --git a/services/agents-api/internal/store/sandbox_deployment_view_test.go b/services/agents-api/internal/store/sandbox_deployment_view_test.go index 3753dbf50..e6c3b0ecb 100644 --- a/services/agents-api/internal/store/sandbox_deployment_view_test.go +++ b/services/agents-api/internal/store/sandbox_deployment_view_test.go @@ -76,7 +76,7 @@ func TestSandboxDeploymentViewRecordsTemplateBuildAndSuspension(t *testing.T) { t.Fatalf("identical PUT did not record the build: %s %v", raw, err) } update := SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: SandboxDeploymentSetupRequest{ - DeploymentSpec: SandboxDeploymentTestSpec("microsandbox"), Provider: "microsandbox", CoreURL: input.CoreURL}, ExpectedGeneration: 1} + DeploymentSpec: SandboxDeploymentTestSpec("microsandbox"), Provider: "microsandbox"}, ExpectedGeneration: 1} view, err = w.UpdateSandboxDeployment(t.Context(), id, update) if err != nil || view.E2B != nil || view.Suspension == nil || view.Suspension.IdleSeconds != 300 || view.Suspension.RetentionSeconds != 86400 { t.Fatalf("microsandbox suspension view = %+v %v", view, err) diff --git a/services/agents-api/internal/store/sandbox_deployment_worker_test.go b/services/agents-api/internal/store/sandbox_deployment_worker_test.go index fdcd9507f..61c415ddf 100644 --- a/services/agents-api/internal/store/sandbox_deployment_worker_test.go +++ b/services/agents-api/internal/store/sandbox_deployment_worker_test.go @@ -24,10 +24,10 @@ func TestSandboxDeploymentWorkerActivatesWithoutRestart(t *testing.T) { if err != nil || setup.Provider == "" { return nil, err } - return &execution.RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, BackendFingerprint: setup.BackendFingerprint, CoreURL: setup.CoreURL + "/api/v1", Provider: p}, nil + return &execution.RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, BackendFingerprint: setup.BackendFingerprint, CoreURL: "https://core.example/api/v1", Provider: p}, nil }, func(ctx context.Context, setup store.SandboxSetup) (execution.PreparedRuntimeDeployment, error) { - return execution.PreparedRuntimeDeployment{Config: &execution.RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, Mode: setup.Mode, Maintenance: setup.Maintenance, CoreURL: setup.CoreURL + "/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: p}}, nil + return execution.PreparedRuntimeDeployment{Config: &execution.RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, Mode: setup.Mode, Maintenance: setup.Maintenance, CoreURL: "https://core.example/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: p}}, nil }) start := func() (*execution.Worker, func()) { t.Helper() @@ -47,7 +47,7 @@ func TestSandboxDeploymentWorkerActivatesWithoutRestart(t *testing.T) { if _, err := w.CreateSession(t.Context(), uuid.NewString(), input); !errors.Is(err, execution.ErrExecutionUnavailable) { t.Fatal("uninitialized worker admitted hosted Session", err) } - if _, err := w.InitializeSandboxDeployment(t.Context(), store.SandboxDeploymentSetupRequest{DeploymentSpec: store.SandboxDeploymentTestSpec("docker"), Provider: "docker", CoreURL: "https://core.example"}); err != nil { + if _, err := w.InitializeSandboxDeployment(t.Context(), store.SandboxDeploymentSetupRequest{DeploymentSpec: store.SandboxDeploymentTestSpec("docker"), Provider: "docker"}); err != nil { t.Fatal(err) } if _, err := s.CreateSession(t.Context(), uuid.NewString(), input); !errors.Is(err, store.ErrRuntimeNodeUnavailable) { diff --git a/services/agents-api/internal/store/sandbox_specification.go b/services/agents-api/internal/store/sandbox_specification.go index 5f5c19b72..893657f26 100644 --- a/services/agents-api/internal/store/sandbox_specification.go +++ b/services/agents-api/internal/store/sandbox_specification.go @@ -26,7 +26,7 @@ func SandboxSetupForSelection(installationID string, input SandboxDeploymentSetu if err := validateSandboxSelection(input); err != nil { return SandboxSetup{}, err } - result := SandboxSetup{InstallationID: installationID, Provider: input.Provider, CoreURL: input.CoreURL, + result := SandboxSetup{InstallationID: installationID, Provider: input.Provider, Mode: "nodes", Specification: input.DeploymentSpec, E2B: input.E2B} namespace := "nodes:" + installationID if input.Provider == "e2b" { diff --git a/services/agents-api/internal/store/sandbox_specification_store_test.go b/services/agents-api/internal/store/sandbox_specification_store_test.go index a772fc5c1..c4fe87a7a 100644 --- a/services/agents-api/internal/store/sandbox_specification_store_test.go +++ b/services/agents-api/internal/store/sandbox_specification_store_test.go @@ -26,7 +26,7 @@ func webSpecificationFixture(t *testing.T, provider string) (*Store, *Store, Run if err := w.ClaimWebSandboxDeployment(t.Context(), id); err != nil { t.Fatal(err) } - input := SandboxDeploymentSetupRequest{Provider: provider, CoreURL: "https://core.example", DeploymentSpec: SandboxDeploymentTestSpec(provider)} + input := SandboxDeploymentSetupRequest{Provider: provider, DeploymentSpec: SandboxDeploymentTestSpec(provider)} if provider == "e2b" { input = e2bSelection() } @@ -341,3 +341,27 @@ func TestSandboxSpecificationAllocationRaceWithMaintenance(t *testing.T) { t.Fatal("allocation race bypassed replacement guard", err) } } + +// A node keeps the public URL it enrolled with. After the public URL changes it +// receives no new sandboxes until it is re-added. +func TestNodeBoundToAnotherPublicURLGetsNoNewSandboxes(t *testing.T) { + s, _, view, _ := webSpecificationFixture(t, "docker") + s.SetPublicURL("https://old.example") + node := specificationNode(t, s, view) + nodes, err := s.ListRuntimeNodes(t.Context()) + if err != nil || len(nodes) != 1 || nodes[0].ID != node.NodeID || nodes[0].CoreURL != "https://old.example" { + t.Fatal("enrollment did not record the node's address", nodes, err) + } + s.SetPublicURL("https://new.example") + if _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString())); !errors.Is(err, ErrRuntimeNodeUnavailable) { + t.Fatal("placed a new sandbox on a node bound to the old address", err) + } + bindings, err := s.AddressBindings(t.Context()) + if err != nil || bindings.Nodes != 1 || bindings.NodesOnOtherAddress != 1 || bindings.HostedSandboxes != 0 { + t.Fatal(bindings, err) + } + s.SetPublicURL("https://old.example") + if _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString())); err != nil { + t.Fatal("node on the current address rejected placement", err) + } +} diff --git a/services/agents-api/internal/store/session_initial_input.go b/services/agents-api/internal/store/session_initial_input.go index cf148c288..991eb5f3a 100644 --- a/services/agents-api/internal/store/session_initial_input.go +++ b/services/agents-api/internal/store/session_initial_input.go @@ -96,7 +96,7 @@ func (s *Store) createSessionResources(ctx context.Context, tenant string, param return err } if placement.Environment.Type == "openai_hosted" { - if err := reserveRuntimePlacement(ctx, q, row.ID); err != nil { + if err := reserveRuntimePlacement(ctx, q, row.ID, s.publicURL); err != nil { return err } } diff --git a/services/agents-api/internal/store/sessions.go b/services/agents-api/internal/store/sessions.go index 67357c8e8..1e429f2a0 100644 --- a/services/agents-api/internal/store/sessions.go +++ b/services/agents-api/internal/store/sessions.go @@ -83,6 +83,9 @@ type Store struct { executionLease *ExecutionLease credentialCipher *credentialcrypto.Cipher oauthRefresher oauthrefresh.Refresher + // publicURL is AGENTS_API_PUBLIC_URL. Core derives every address it gives + // nodes, sandboxes and administrators from it. + publicURL string } func New(pool *pgxpool.Pool) *Store { return &Store{queries: sqlc.New(pool), pool: pool} } diff --git a/services/agents-api/migrations/000075_public_url_binding.sql b/services/agents-api/migrations/000075_public_url_binding.sql new file mode 100644 index 000000000..f4bd26275 --- /dev/null +++ b/services/agents-api/migrations/000075_public_url_binding.sql @@ -0,0 +1,42 @@ +-- +goose Up +-- Core derives the address nodes and sandboxes use from AGENTS_API_PUBLIC_URL, +-- so the deployment no longer stores one. Each node keeps the address it +-- enrolled with, which tells an administrator which nodes a change affects. +ALTER TABLE runtime_nodes ADD COLUMN core_url text NOT NULL DEFAULT ''; +UPDATE runtime_nodes n SET core_url = d.core_url FROM runtime_deployment d WHERE d.singleton AND n.removed_at IS NULL; +ALTER TABLE runtime_deployment DROP CONSTRAINT runtime_deployment_setup_check; +ALTER TABLE runtime_deployment ADD CONSTRAINT runtime_deployment_setup_check CHECK ( + NOT web_managed OR (local_node_id IS NULL AND ( + (provider_kind='' AND mode='' AND generation=0 AND idle_seconds=0 AND retention_seconds=0) OR + (provider_kind='docker' AND mode='nodes' AND generation>0 AND idle_seconds=0 AND retention_seconds=0) OR + (provider_kind='microsandbox' AND mode='nodes' AND generation>0 AND idle_seconds>0 AND retention_seconds>0) OR + (provider_kind='e2b' AND mode='direct' AND generation>0 AND idle_seconds=0 AND retention_seconds=0) + )) +); +ALTER TABLE runtime_deployment DROP COLUMN core_url; + +-- +goose Down +ALTER TABLE runtime_deployment ADD COLUMN core_url text NOT NULL DEFAULT ''; +UPDATE runtime_deployment d SET core_url = COALESCE((SELECT n.core_url FROM runtime_nodes n + WHERE n.removed_at IS NULL AND n.core_url <> '' ORDER BY n.created_at DESC LIMIT 1), '') +WHERE d.web_managed AND d.provider_kind <> ''; + +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM runtime_deployment WHERE web_managed AND provider_kind <> '' AND core_url = '') THEN + RAISE EXCEPTION 'Cannot restore the sandbox deployment Core address: no enrolled node records it'; + END IF; +END $$; +-- +goose StatementEnd + +ALTER TABLE runtime_deployment DROP CONSTRAINT runtime_deployment_setup_check; +ALTER TABLE runtime_deployment ADD CONSTRAINT runtime_deployment_setup_check CHECK ( + NOT web_managed OR (local_node_id IS NULL AND ( + (provider_kind='' AND core_url='' AND mode='' AND generation=0 AND idle_seconds=0 AND retention_seconds=0) OR + (provider_kind='docker' AND core_url<>'' AND mode='nodes' AND generation>0 AND idle_seconds=0 AND retention_seconds=0) OR + (provider_kind='microsandbox' AND core_url<>'' AND mode='nodes' AND generation>0 AND idle_seconds>0 AND retention_seconds>0) OR + (provider_kind='e2b' AND core_url<>'' AND mode='direct' AND generation>0 AND idle_seconds=0 AND retention_seconds=0) + )) +); +ALTER TABLE runtime_nodes DROP COLUMN core_url; diff --git a/services/agents-api/tests/container_server.py b/services/agents-api/tests/container_server.py index 6cd303c90..69c8923d7 100755 --- a/services/agents-api/tests/container_server.py +++ b/services/agents-api/tests/container_server.py @@ -20,7 +20,7 @@ "--mount", f"type=bind,source={credential_key},target=/run/credential.key,readonly", "--env", "AGENTS_API_CREDENTIAL_KEY_FILE=/run/credential.key", ]) -for name in ("AGENTS_API_DATABASE_URL", "AGENTS_API_ADDR", "AGENTS_API_ENGINE", "AGENTS_API_DAEMON_WS_URL"): +for name in ("AGENTS_API_DATABASE_URL", "AGENTS_API_ADDR", "AGENTS_API_ENGINE", "AGENTS_API_PUBLIC_URL"): args.extend(["--env", name]) args.append(os.environ["AGENTS_API_IMAGE"]) # Docker forwards termination to the API and --rm removes the stopped container. diff --git a/services/agents-api/tests/official_client.py b/services/agents-api/tests/official_client.py index da2b814e1..d8b42c905 100644 --- a/services/agents-api/tests/official_client.py +++ b/services/agents-api/tests/official_client.py @@ -119,7 +119,7 @@ def project_bindings(directory): env["AGENTS_API_CREDENTIAL_KEY_FILE"] = str(credential_key) # Enable the real Worker/gateway admission path without connecting a daemon. # Synthetic fixture inputs remain queued; this is not live model acceptance. - env["AGENTS_API_DAEMON_WS_URL"] = f"ws://127.0.0.1:{port}/api/v1/agent-daemon/ws" + env["AGENTS_API_PUBLIC_URL"] = f"http://127.0.0.1:{port}" with (Path(directory) / "server.log").open("w+") as log: def start(): child = subprocess.Popen([binary], env=env, stdout=log, stderr=log) diff --git a/services/core-console/main.go b/services/core-console/main.go index 9990cfe96..f1c051b74 100644 --- a/services/core-console/main.go +++ b/services/core-console/main.go @@ -21,6 +21,7 @@ func main() { } func run() error { + log.Init(log.ConfigFromEnv()) c, err := loadConfig() if err != nil { return err From 1431360e84e76f172b34ad8c72d7ec356b87e0b8 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:21:19 +0000 Subject: [PATCH 02/19] Close E2B admission on a loopback public URL and match Core's origin rule An E2B selection saved while the public URL was reachable stayed usable after the URL became loopback, so new hosted Sessions would have created sandboxes that cannot reach Core. Session placement now refuses them with 409 sandbox_configuration_error. The saved provider still loads, so existing E2B sandboxes remain reachable for cleanup and startup is not refused. The installer checks --public-url with valid_core_origin, which accepts exactly the origins Core's ValidateSandboxCoreURL accepts, including IPv6 literals. It now rejects underscores, trailing dots, non-ASCII hosts and non-canonical ports, which Core would refuse at startup. Both tests share the same cases. The client passes Core's public-URL rejection on as a new error with only its message and param. AGENTS_API_CONFIG_FILE now says to remove it instead of naming a replacement. A migration test covers the refused rollback of an E2B deployment. The upgrade guide lists the retired address settings and tells operators to reuse the deployment's core_url; docs no longer describe a saved or submitted Core origin, and the deployment contract says that work already placed on a node finishes there. --- CONTRIBUTING.md | 13 ++++--- contracts/agents-api/sandbox-deployment.md | 10 +++-- deploy/install/configuration.py | 37 +++++++++++++++++++ deploy/install/install.py | 20 ++++++---- deploy/install/test_install.py | 17 ++++++++- docs/configuration.md | 2 +- docs/getting-started/operations.md | 11 ++++++ .../agents-client/src/sandbox-client.test.ts | 8 ++-- packages/agents-client/src/sandbox-client.ts | 5 ++- services/agents-api/HOSTED-SANDBOX-MANAGER.md | 2 +- .../cmd/server/process_configuration.go | 2 +- .../cmd/server/process_configuration_test.go | 2 +- .../agents-api/internal/store/public_url.go | 7 ++-- .../store/public_url_migration_test.go | 31 ++++++++++++++-- .../internal/store/runtime_placements.go | 6 +++ .../store/sandbox_deployment_setup_test.go | 5 ++- .../store/sandbox_specification_store_test.go | 17 +++++++++ 17 files changed, 160 insertions(+), 35 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 519c25105..d1c1014e7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -737,8 +737,9 @@ server receives the private key, uses it for sign-in and injects it only on never receives that key. Node and daemon connections use `/api/v1` with their own credentials; the reverse proxy sends them directly to Core, never through Web. Zero-node Core receives neither the Docker socket nor KVM. -The Web and deployment administrator API select one provider, public Core origin, -per-sandbox resources and immutable Runtime release. PostgreSQL owns this complete, +The Web and deployment administrator API select one provider, per-sandbox +resources and immutable Runtime release; the Core address comes from the +installation public URL. PostgreSQL owns this complete, generation-tagged selection under the existing execution lease and deployment lock. The shared `sandbox.DeploymentSpec` defines required CPU/memory and supported disk limits plus Runtime provenance; neither a node file nor the installer owns another @@ -779,13 +780,13 @@ A locally unavailable provider dependency keeps hosted admission closed while th existing scan waits for repair; administrator recovery remains available, including on restart. Database and ownership errors remain failures. Unconfigured hosted admission creates no Session state. Derive Runtime bootstrap and daemon WebSocket -addresses from the saved validated origin, never inbound Host headers. Read the +addresses from the validated installation public URL, never inbound Host headers. Read the current selection from the live deployment API; there is no startup configuration read. Provider, resources and Runtime changes require maintenance, the current generation -and verified zero retained or pending resources. The Core origin remains unchanged -by this operation. Maintenance prevents fresh hosted Sessions and allocations while +and verified zero retained or pending resources. They never change the Core +address, which comes from the installation public URL. Maintenance prevents fresh hosted Sessions and allocations while retaining admitted work, known receipts, queries and explicit cleanup. Unreleased allocations include stopped compute, snapshots, uncertain operations and pending cleanup. Pending hosted Environments without allocations also block changes. @@ -1880,7 +1881,7 @@ liveness only, never invented traffic or work; offline/stale connections are static and reduced-motion preferences disable decorative animation. Node selection reveals inspection details. Installation identifiers, provider metadata and allocation records are secondary content. Node enrollment is an explicit Add node action in a focused -dialog, using the saved Core origin or the paired console origin by default. +dialog, using the deployment's `core_url` (the installation public URL). Do not expose routine network wiring or manual runtime setup as the primary flow. Generate a one-time command only on user intent, never retry enrollment writes automatically, and discard credentials and late responses when the dialog closes diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index d83ae9eb7..ec7b2005c 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -45,8 +45,10 @@ safely, but never raise its limits. Each node in `GET /core/v1/sandbox/nodes` and its detail reports `core_url`: the installation public URL when the node enrolled. A node whose `core_url` differs from -the current public URL receives no new sandboxes; its retained sandboxes can still -resume while the old address reaches Core. Remove it and add it again. +the current public URL receives no new placements. Work already placed on it +finishes there: a hosted Environment that was placed but not yet allocated before +the change is still allocated on that node, and its retained sandboxes can still +resume, while the old address reaches Core. Remove it and add it again. `GET /core/v1/sandbox/nodes/{node_id}/allocations` lists the node's unreleased allocations. Each item's `compute_phase_changed_at` is the time the allocation @@ -194,8 +196,8 @@ For a replacement: 2. Verify both response counts are zero before replacing the selection. Stopped compute, snapshots, uncertain operations, pending cleanup and unallocated hosted Environments remain blockers. Use the explicit Session archive flow below. -3. PUT the complete replacement selection with `expected_generation: N` and the - unchanged Core origin. Core checks the generation and resources, prepares and +3. PUT the complete replacement selection with `expected_generation: N`, without + `core_url` (a request that contains it gets 400). Core checks the generation and resources, prepares and validates the candidate, then drains the existing manager calls. A short Store transaction repeats the guards and commits a changed selection, increments its generation and retires old nodes and unused enrollment tokens together. diff --git a/deploy/install/configuration.py b/deploy/install/configuration.py index 27618aba3..b79f653ce 100644 --- a/deploy/install/configuration.py +++ b/deploy/install/configuration.py @@ -1,8 +1,45 @@ """Deployment files for the existing Core, Runtime and production console.""" +import ipaddress import os import re import stat from pathlib import Path +from urllib.parse import urlsplit + + +_HOST_LABEL = re.compile(r"[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?") + + +def valid_core_origin(value): + """Accept exactly the origins Core's ValidateSandboxCoreURL accepts + (services/agents-api/internal/store/sandbox_deployment_setup.go), so an + installer value never fails Core's AGENTS_API_PUBLIC_URL check at startup.""" + if not isinstance(value, str) or any(char in value for char in "?#@\\% \t\r\n"): + return False + try: + parsed = urlsplit(value) + except ValueError: + return False + netloc = parsed.netloc + if (parsed.scheme not in ("http", "https") or value != parsed.scheme + "://" + netloc + or not netloc or netloc != netloc.lower() or netloc.endswith(":")): + return False + if netloc.startswith("["): + host, _, rest = netloc[1:].partition("]") + if rest and not rest.startswith(":"): + return False + port = rest[1:] if rest else "" + else: + host, _, port = netloc.partition(":") + if port and not (port.isdigit() and str(int(port)) == port and 1 <= int(port) <= 65535): + return False + try: + loopback = ipaddress.ip_address(host).is_loopback + except ValueError: + if netloc.startswith("[") or len(host) > 253 or not all(_HOST_LABEL.fullmatch(label) for label in host.split(".")): + return False + loopback = host == "localhost" + return parsed.scheme == "https" or loopback def environment_text(values): diff --git a/deploy/install/install.py b/deploy/install/install.py index 27167e4ea..bbccf60d6 100644 --- a/deploy/install/install.py +++ b/deploy/install/install.py @@ -22,7 +22,7 @@ from urllib.parse import urlsplit import uuid -from configuration import compose_config, core_environment, environment_text, read_core_environment +from configuration import compose_config, core_environment, environment_text, read_core_environment, valid_core_origin import local_node import native_service from distribution import DistributionError, artifact, image_identities, ensure_docker_image @@ -122,12 +122,18 @@ def origin_port(value): def public_origin(value): - # Core accepts only a canonical origin: lowercase scheme and host, no IPv6 literal. - value = core_target(value) - parsed = urlsplit(value) - if "[" in parsed.netloc: - raise argparse.ArgumentTypeError("Public URL must use a host name or IPv4 address") - return parsed._replace(scheme=parsed.scheme.lower(), netloc=parsed.netloc.lower()).geturl() + # Normalize case and a trailing slash, then apply Core's exact origin rule. + try: + parsed = urlsplit(value.strip()) + if parsed.path == "/": + parsed = parsed._replace(path="") + value = parsed._replace(scheme=parsed.scheme.lower(), netloc=parsed.netloc.lower()).geturl() + except ValueError: + value = "" + if not valid_core_origin(value): + raise argparse.ArgumentTypeError("Public URL must be an HTTPS origin such as https://core.example, " + "without path, credentials, query or fragment; plain HTTP only for a loopback host") + return value def arguments(argv=None): diff --git a/deploy/install/test_install.py b/deploy/install/test_install.py index 248a5ddd4..eb30826ed 100644 --- a/deploy/install/test_install.py +++ b/deploy/install/test_install.py @@ -555,11 +555,24 @@ def test_public_url_is_shared_across_packaging_modes(self): def test_public_url_is_canonical_for_core(self): for origin, expected in (("HTTPS://Core.Example", "https://core.example"), ("http://localhost:8080/", "http://localhost:8080"), - ("http://127.0.0.1:8080", "http://127.0.0.1:8080")): + ("https://[2001:db8::1]", "https://[2001:db8::1]")): with self.subTest(origin=origin): self.assertEqual(self.args("--public-url", origin).public_url, expected) with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): - install.arguments(["--public-url", "https://[2001:db8::1]"]) + install.arguments(["--public-url", "https://core_example"]) + + def test_public_url_rule_matches_core(self): + # The same cases as Core's TestSandboxCoreURLValidation. + for origin in ("https://core.example", "https://core.example:8443", "http://localhost:8091", + "http://127.0.0.2:8091", "http://[::1]:8091", "https://[2001:db8::1]"): + self.assertTrue(install.valid_core_origin(origin), origin) + for origin in ("", "http://core.example", "http://core:8091", "http://host.localhost", "https://core.example/", + "https://user:secret@core.example", "https://core.example/path", "https://core.example?", + "https://core.example#x", "https://CORE.example", "https://core.example:", "https://core.example:0", + "https://core.example:65536", "https://core.example:0080", "https://core.example:0443", + "https://core.example\\evil", "https://[not-an-ip]", "https://-core.example", "https://core..example", + "https://core_example", "https://core.example.", "https://b\u00fccher.example"): + self.assertFalse(install.valid_core_origin(origin), origin) def test_local_opt_in_requires_non_loopback_https_origin_before_installation(self): for provider in ("docker", "microsandbox"): diff --git a/docs/configuration.md b/docs/configuration.md index 6850f5873..e479ee8bf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -62,7 +62,7 @@ flags. Changing `core.env` does not require editing those launcher files. | `AGENTS_API_HARNESSES` | Binary: default harness; installer: `codex,claude_sdk,mcode` | Comma-separated supported harness IDs; unknown IDs fail startup | | `AGENTS_API_PUBLIC_URL` | Installer: `--public-url`, or `http://127.0.0.1:` without one | The one origin applications, nodes, sandbox guests and self-hosted executors use: canonical HTTPS origin without path, or HTTP only for a loopback host. Enables daemon transport; Core derives the daemon WebSocket URL, self-hosted `remote_url`, hosted bootstrap and the deployment's read-only `core_url` from it. Required with `AGENTS_API_SANDBOX_INSTALLATION_ID`. The retired `AGENTS_API_DAEMON_WS_URL` fails startup | | `AGENTS_API_DATABASE_PASSWORD_FILE` | Installer: private `database.password` path | The database password, which `AGENTS_API_DATABASE_URL` then must not contain; also read by migrations and the maintenance commands | -| `AGENTS_API_SETTINGS_FILE` | Unset | The installer's non-secret settings snapshot, reported by `GET /core/v1/installation`; Core does not act on it. The retired `AGENTS_API_CONFIG_FILE` fails startup | +| `AGENTS_API_SETTINGS_FILE` | Unset | The installer's non-secret settings snapshot, reported by `GET /core/v1/installation`; Core does not act on it | | `AGENTS_API_SANDBOX_INSTALLATION_ID` | Installer-generated canonical UUID | Stable identity pinned to the database, not provider selection; preserve it | | `AGENTS_API_CORE_KEY_DIGESTS_FILE` | Generated private `admin/core-key-digests.json` path | JSON array of [Core key](getting-started/operations.md#core-key) SHA-256 digests that authorize `/core/v1`; the key itself stays in Web's `CORE_CONSOLE_CORE_KEY_FILE`. The old name `AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE` fails startup | | `AGENTS_API_CREDENTIAL_KEY_FILE` | Generated private encryption-key path | Preserve with the database; never regenerate to repair credentials | diff --git a/docs/getting-started/operations.md b/docs/getting-started/operations.md index 04afbb0fc..91c0b8652 100644 --- a/docs/getting-started/operations.md +++ b/docs/getting-started/operations.md @@ -150,6 +150,8 @@ replacement. Stop the services, then: | `AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE` | `AGENTS_API_CORE_KEY_DIGESTS_FILE` | `config/core.env`; its value names the renamed file | | `CORE_CONSOLE_ADMIN_TOKEN_FILE` | `CORE_CONSOLE_CORE_KEY_FILE` | Web `environment` in `compose.json` | | `--admin-token-file` | `--core-key-file` | `install.sh --web-only` flag; the installer rejects the old flag | +| `AGENTS_API_DAEMON_WS_URL` (a `wss://…/api/v1/agent-daemon/ws` URL) | `AGENTS_API_PUBLIC_URL` (the origin only, such as `https://core.example`) | `config/core.env` | +| `AGENTS_API_CONFIG_FILE` | None; delete the line | `config/core.env` | Also remove `CORE_CONSOLE_AUTH_MODE`, `CORE_CONSOLE_STATE_DIR` and `CORE_CONSOLE_PASSWORD_FILE` from Web's environment, with their `state/console` @@ -158,6 +160,15 @@ Basic authentication, and it refuses to start while those settings are present. The old account state is not read; delete it after the upgrade or keep it as a backup. Then start the services and sign in with the Core key. +Before upgrading an installation with sandbox nodes, read +`GET /core/v1/sandbox/deployment` with the Core key and set +`AGENTS_API_PUBLIC_URL` to its `core_url`. The deployment no longer stores that +address, and nodes keep the one they enrolled with; with any other value, every +existing node counts as bound to another address and must be re-added. +Optionally move the password out of `AGENTS_API_DATABASE_URL` into the file named +by `AGENTS_API_DATABASE_PASSWORD_FILE` (for example `/config/database.password`). +The installer's upcoming `install.sh --convert` makes these changes for you. + ## Projects and API keys Use Web, or the [Core API](../../contracts/agents-api/admin-api.md) under diff --git a/packages/agents-client/src/sandbox-client.test.ts b/packages/agents-client/src/sandbox-client.test.ts index e47460981..5a26ccf7c 100644 --- a/packages/agents-client/src/sandbox-client.test.ts +++ b/packages/agents-client/src/sandbox-client.test.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it, vi } from "vitest"; -import { OpenAIAgentsClient } from "./client"; +import { AgentCoreError, OpenAIAgentsClient } from "./client"; import { SandboxAdminClient, type SandboxNode } from "./sandbox-client"; function response(value: unknown, status = 200) { return new Response(JSON.stringify(value), { status }); } @@ -132,12 +132,14 @@ describe("hosted provider configuration", () => { expect(fetch).toHaveBeenCalledTimes(2); }); it("shows Core's public-URL rejection unless it reflects the E2B key", async () => { - const rejection = { message: "E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback.", code: "sandbox_configuration_error", param: null }; + const rejection = { message: "E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback.", code: "sandbox_configuration_error", param: null, type: "reflected" }; const fetch = vi.fn() .mockResolvedValueOnce(response({ error: rejection }, 409)) .mockResolvedValueOnce(response({ error: { ...rejection, message: rejection.message + e2b.api_key } }, 409)); const client = new SandboxAdminClient({ fetch }); - await expect(client.initializeDeployment({ provider: "e2b", e2b })).rejects.toMatchObject({ status: 409, code: "sandbox_configuration_error", message: rejection.message, param: null }); + const shown = await client.initializeDeployment({ provider: "e2b", e2b }).catch((error: unknown) => error); + expect(shown).toMatchObject({ status: 409, code: "sandbox_configuration_error", message: rejection.message, param: null }); + expect((shown as AgentCoreError).errorType).toBeUndefined(); await expect(client.initializeDeployment({ provider: "e2b", e2b })).rejects.toMatchObject({ status: 409, code: "sandbox_configuration_unconfirmed" }); }); it("never retries uncertain switch or maintenance writes", async () => { diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts index 5626615a1..11f394330 100644 --- a/packages/agents-client/src/sandbox-client.ts +++ b/packages/agents-client/src/sandbox-client.ts @@ -153,7 +153,10 @@ export class SandboxAdminClient { // The public-URL rejection is safe to show unless it somehow reflects the key. const key = input.e2b.api_key; if (error instanceof AgentCoreError && error.status === 409 && error.code === "sandbox_configuration_error" - && !error.message.includes(key) && !(error.param ?? "").includes(key)) throw error; + && !error.message.includes(key) && !(error.param ?? "").includes(key)) { + // Only Core's message and param pass through; nothing else from the response does. + throw new AgentCoreError(error.message, 409, "sandbox_configuration_error", error.param ?? null); + } // Any other credential-bearing rejection may reflect the key in any error field. throw new AgentCoreError("Sandbox configuration could not be confirmed. Refresh before submitting again.", error instanceof AgentCoreError ? error.status : 0, "sandbox_configuration_unconfirmed"); } diff --git a/services/agents-api/HOSTED-SANDBOX-MANAGER.md b/services/agents-api/HOSTED-SANDBOX-MANAGER.md index 9d13d85de..5c74136bd 100644 --- a/services/agents-api/HOSTED-SANDBOX-MANAGER.md +++ b/services/agents-api/HOSTED-SANDBOX-MANAGER.md @@ -83,7 +83,7 @@ account/resource ownership before accepting a replacement key; an inaccessible sandbox or empty listing from another account is not proof of cleanup. For own machines, click **Add node**, copy the installation command from the dialog, and run it on -the target Linux amd64 host. The command uses the saved deployment origin. Before +the target Linux amd64 host. The command uses the installation public URL. Before installing, it reads the active specification with its enrollment token; this read does not consume the token. The local provider file is an installed copy of the server configuration and cannot select a different Runtime or resource profile. diff --git a/services/agents-api/cmd/server/process_configuration.go b/services/agents-api/cmd/server/process_configuration.go index 5a9d63811..45fad664e 100644 --- a/services/agents-api/cmd/server/process_configuration.go +++ b/services/agents-api/cmd/server/process_configuration.go @@ -32,7 +32,7 @@ func validateProcessConfiguration() error { return errors.New("AGENTS_API_DAEMON_WS_URL is retired; set AGENTS_API_PUBLIC_URL to the public Core origin, such as https://core.example, and Core derives the daemon WebSocket URL") } if _, present := os.LookupEnv("AGENTS_API_CONFIG_FILE"); present { - return errors.New("AGENTS_API_CONFIG_FILE is retired; the installer names its settings snapshot with AGENTS_API_SETTINGS_FILE") + return errors.New("AGENTS_API_CONFIG_FILE is retired and has no replacement; remove it") } for _, retired := range []string{"AGENTS_API_MANAGED_RUNTIMES_FILE", "AGENTS_API_SANDBOX_NODE_STATE_DIR", "AGENTS_API_SANDBOX_NODE_CORE_URL"} { if _, present := os.LookupEnv(retired); present { diff --git a/services/agents-api/cmd/server/process_configuration_test.go b/services/agents-api/cmd/server/process_configuration_test.go index 420716d08..e48011c50 100644 --- a/services/agents-api/cmd/server/process_configuration_test.go +++ b/services/agents-api/cmd/server/process_configuration_test.go @@ -41,7 +41,7 @@ func TestRetiredConfigurationRejectedWithoutReadingValues(t *testing.T) { } func TestRetiredAddressAndConfigSettingsNameTheirReplacement(t *testing.T) { - for key, replacement := range map[string]string{"AGENTS_API_DAEMON_WS_URL": "AGENTS_API_PUBLIC_URL", "AGENTS_API_CONFIG_FILE": "AGENTS_API_SETTINGS_FILE"} { + for key, replacement := range map[string]string{"AGENTS_API_DAEMON_WS_URL": "AGENTS_API_PUBLIC_URL", "AGENTS_API_CONFIG_FILE": "remove it"} { t.Run(key, func(t *testing.T) { t.Setenv(key, "wss://private.example/api/v1/agent-daemon/ws") err := validateProcessConfiguration() diff --git a/services/agents-api/internal/store/public_url.go b/services/agents-api/internal/store/public_url.go index f6a9d7e08..98459fa76 100644 --- a/services/agents-api/internal/store/public_url.go +++ b/services/agents-api/internal/store/public_url.go @@ -5,9 +5,10 @@ import ( "errors" ) -// ErrSandboxPublicURLUnreachable rejects E2B while the installation public URL -// is loopback: E2B sandboxes reach Core from E2B's cloud. -var ErrSandboxPublicURLUnreachable = errors.New("E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback (public_url in config.json, AGENTS_API_PUBLIC_URL for Core), then save again.") +// ErrSandboxPublicURLUnreachable rejects E2B selections and new E2B Sessions +// while the installation public URL is loopback: E2B sandboxes reach Core from +// E2B's cloud. +var ErrSandboxPublicURLUnreachable = errors.New("E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback (public_url in config.json, AGENTS_API_PUBLIC_URL for Core).") // SetPublicURL records AGENTS_API_PUBLIC_URL, validated by the caller. Core // reports it as the deployment and node configuration core_url and records it diff --git a/services/agents-api/internal/store/public_url_migration_test.go b/services/agents-api/internal/store/public_url_migration_test.go index 6e1afbb18..857f3cda0 100644 --- a/services/agents-api/internal/store/public_url_migration_test.go +++ b/services/agents-api/internal/store/public_url_migration_test.go @@ -13,9 +13,9 @@ import ( "github.com/pressly/goose/v3" ) -// The deployment's saved Core address moves to each node it enrolled, and a -// rollback takes it back from them. -func TestPublicURLMigrationMovesTheAddressToNodes(t *testing.T) { +// publicURLMigrationSchema migrates an isolated schema up to version 74. +func publicURLMigrationSchema(t *testing.T) (*sql.DB, *goose.Provider) { + t.Helper() _, pool := testStore(t) ctx := t.Context() schema := "public_url_" + uuid.NewString()[:8] @@ -39,6 +39,14 @@ func TestPublicURLMigrationMovesTheAddressToNodes(t *testing.T) { if _, err := provider.UpTo(ctx, 74); err != nil { t.Fatal(err) } + return db, provider +} + +// The deployment's saved Core address moves to each node it enrolled, and a +// rollback takes it back from them. +func TestPublicURLMigrationMovesTheAddressToNodes(t *testing.T) { + ctx := t.Context() + db, provider := publicURLMigrationSchema(t) installation, active, removed := uuid.NewString(), uuid.NewString(), uuid.NewString() if _, err := db.ExecContext(ctx, `UPDATE runtime_deployment SET installation_id=$1, web_managed=true, provider_kind='docker', mode='nodes', generation=1, core_url='https://core.example', backend_fingerprint=$2`, installation, strings.Repeat("a", 64)); err != nil { @@ -75,3 +83,20 @@ func TestPublicURLMigrationMovesTheAddressToNodes(t *testing.T) { t.Fatal("rollback lost the deployment address", restored, err) } } + +// An E2B deployment has no node to take its address back from, so the +// rollback refuses instead of leaving the deployment without one. +func TestPublicURLMigrationRollbackRefusesE2B(t *testing.T) { + ctx := t.Context() + db, provider := publicURLMigrationSchema(t) + if _, err := db.ExecContext(ctx, `UPDATE runtime_deployment SET installation_id=$1, web_managed=true, provider_kind='e2b', mode='direct', generation=1, + core_url='https://core.example', backend_fingerprint=$2, e2b_template='runtime:build', e2b_credential='\x01'`, uuid.NewString(), strings.Repeat("a", 64)); err != nil { + t.Fatal(err) + } + if _, err := provider.UpTo(ctx, 75); err != nil { + t.Fatal(err) + } + if _, err := provider.DownTo(ctx, 74); err == nil || !strings.Contains(err.Error(), "Cannot restore the sandbox deployment Core address") { + t.Fatal("rollback discarded the E2B deployment address", err) + } +} diff --git a/services/agents-api/internal/store/runtime_placements.go b/services/agents-api/internal/store/runtime_placements.go index c668743f6..3ae225e96 100644 --- a/services/agents-api/internal/store/runtime_placements.go +++ b/services/agents-api/internal/store/runtime_placements.go @@ -19,6 +19,12 @@ func reserveRuntimePlacement(ctx context.Context, q *sqlc.Queries, session pgtyp if d.Maintenance { return ErrRuntimeNodeUnavailable } + // E2B guests reach Core over the internet. A selection saved before the + // public URL became loopback admits nothing, while its existing sandboxes + // stay reachable for cleanup through the loaded provider. + if LoopbackOrigin(publicURL) { + return ErrSandboxPublicURLUnreachable + } return nil } if d.ProviderKind == "" { diff --git a/services/agents-api/internal/store/sandbox_deployment_setup_test.go b/services/agents-api/internal/store/sandbox_deployment_setup_test.go index a9dd54e37..6a2409db7 100644 --- a/services/agents-api/internal/store/sandbox_deployment_setup_test.go +++ b/services/agents-api/internal/store/sandbox_deployment_setup_test.go @@ -12,7 +12,8 @@ import ( ) func TestSandboxCoreURLValidation(t *testing.T) { - for _, value := range []string{"https://core.example", "https://core.example:8443", "http://localhost:8091", "http://127.0.0.2:8091", "http://[::1]:8091"} { + // deploy/install/test_install.py checks the installer's valid_core_origin against the same cases. + for _, value := range []string{"https://core.example", "https://core.example:8443", "http://localhost:8091", "http://127.0.0.2:8091", "http://[::1]:8091", "https://[2001:db8::1]"} { if err := ValidateSandboxCoreURL(value); err != nil { t.Errorf("rejected %q: %v", value, err) } @@ -22,7 +23,7 @@ func TestSandboxCoreURLValidation(t *testing.T) { t.Errorf("accepted %q: %v", value, err) } } - for _, value := range []string{"https://[not-an-ip]", "https://-core.example", "https://core..example", "https://core_example"} { + for _, value := range []string{"https://[not-an-ip]", "https://-core.example", "https://core..example", "https://core_example", "https://core.example.", "https://bücher.example", "https://core.example:0443"} { if err := ValidateSandboxCoreURL(value); !errors.Is(err, ErrInvalidInput) { t.Errorf("accepted invalid hostname %q: %v", value, err) } diff --git a/services/agents-api/internal/store/sandbox_specification_store_test.go b/services/agents-api/internal/store/sandbox_specification_store_test.go index c4fe87a7a..7170ffa11 100644 --- a/services/agents-api/internal/store/sandbox_specification_store_test.go +++ b/services/agents-api/internal/store/sandbox_specification_store_test.go @@ -365,3 +365,20 @@ func TestNodeBoundToAnotherPublicURLGetsNoNewSandboxes(t *testing.T) { t.Fatal("node on the current address rejected placement", err) } } + +// An E2B selection saved before the public URL became loopback admits no new +// Session, and its configuration stays readable for cleanup. +func TestE2BAdmitsNothingWhileThePublicURLIsLoopback(t *testing.T) { + s, _, _, _ := webSpecificationFixture(t, "e2b") + s.SetPublicURL("http://127.0.0.1:8091") + if _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString())); !errors.Is(err, ErrSandboxPublicURLUnreachable) { + t.Fatal("admitted an E2B Session that could not reach Core", err) + } + if setup, err := s.GetSandboxSetup(t.Context()); err != nil || setup.Provider != "e2b" { + t.Fatal("the saved E2B selection became unreadable", err) + } + s.SetPublicURL("https://core.example") + if _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString())); err != nil { + t.Fatal(err) + } +} From 32b661e8a7eafe9de008bbbc2066854ac3e73a11 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 18:20:19 +0000 Subject: [PATCH 03/19] Read Core's address from config.json on the Nodes page The sandbox wizard no longer asks for a Core address or sends core_url: its review shows the deployment's read-only address (config.json's public_url) with where it is set. When Core rejects a configuration it cannot serve (409 sandbox_configuration_error, such as E2B without an HTTPS public_url), the wizard keeps Core's message and adds the config file and apply command from GET /core/v1/installation; nothing is marked uncertain. Nodes warns when nodes are still bound to an old address. --- .../sandbox/SandboxDeploymentSettings.tsx | 2 +- .../features/sandbox/SandboxManagerView.css | 7 ++ .../features/sandbox/SandboxManagerView.tsx | 19 ++++-- .../features/sandbox/SandboxSetupWizard.tsx | 64 +++++++++++-------- .../src/features/sandbox/sandbox-wizard.css | 43 +++++++++++++ apps/web/src/lib/installation.ts | 13 ++++ apps/web/src/lib/locale-strings.ts | 11 ++-- apps/web/src/lib/sandbox-labels.ts | 9 +++ 8 files changed, 128 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/lib/installation.ts diff --git a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx index 218b6db26..2f4043148 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx @@ -67,7 +67,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onMaint

{!changing ? : <> (null); - const initialCoreUrl = window.location.origin; // When a deployment write last had an uncertain outcome; only a read begun after it confirms the state again. const [uncertainSince, setUncertainSince] = useState(null); const setupNeedsRefresh = uncertainSince !== null && !(snapshot && snapshot.readAt > uncertainSince); @@ -102,8 +104,8 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const controller = new AbortController(); lifetime.current = controller; return () => { controller.abort(); lifetime.current = null; }; }, []); - /** Whether Core confirmed the change. */ - async function changeDeployment(operation: (signal: AbortSignal) => Promise): Promise { + /** Whether Core confirmed the change. `fromWizard`: a configuration Core rejects is thrown back to the wizard that sent it. */ + async function changeDeployment(operation: (signal: AbortSignal) => Promise, fromWizard = false): Promise { const controller = lifetime.current; if (!controller || busy || loading || setupNeedsRefresh || !fresh) return false; setBusy(true); setWriteFailure(null); @@ -124,6 +126,8 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig } } catch (error) { if (!controller.signal.aborted) { + // Core rejected the configuration and saved nothing: the wizard explains why. + if (fromWizard && sandboxConfigurationRejection(error) !== null) throw error; setUncertainSince(performance.now()); setWriteFailure({ error, open: true }); // Nothing re-reads on its own: the operator refreshes to confirm. A later visit reads again. void queryClient.invalidateQueries({ queryKey: sandboxScope, refetchType: "none" }); @@ -133,10 +137,10 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig } /** First setup; own machines continue straight to adding the first node. */ async function initialize(input: InitializeSandboxDeployment) { - if (await changeDeployment((signal) => client.initializeDeployment(input, { signal })) && input.provider !== "e2b") setAdding(true); + if (await changeDeployment((signal) => client.initializeDeployment(input, { signal }), true) && input.provider !== "e2b") setAdding(true); } async function update(input: InitializeSandboxDeployment) { - await changeDeployment((signal) => client.updateDeployment({ ...input, core_url: snapshot!.deployment.core_url, expected_generation: snapshot!.deployment.generation }, { signal })); + await changeDeployment((signal) => client.updateDeployment({ ...input, expected_generation: snapshot!.deployment.generation }, { signal }), true); } async function maintenance(maintenance: boolean) { await changeDeployment((signal) => client.setMaintenance({ maintenance, expected_generation: snapshot!.deployment.generation }, { signal })); @@ -255,9 +259,10 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig
{status} - {snapshot && !snapshot.deployment.provider ? : null} + {snapshot && !snapshot.deployment.provider ? : null} {snapshot?.deployment.provider ? <> {snapshot.deployment.maintenance ?

{t("Maintenance is enabled. New sandbox placement is paused.")}

: null} + {hostedNodes && staleNodes > 0 ?

{staleNodes === 1 ? t("1 node is still bound to an old Core address. Add it again.") : t("{{count}} nodes are still bound to an old Core address. Add them again.", { count: staleNodes })}

: null} {hostedNodes ?
{nodes.length diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index 1322e994a..2f9a64390 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -7,9 +7,11 @@ import { useId, useState, type FormEvent, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { HelpTip } from "../../components/console-ui"; +import { CopyableId } from "../../components/list-ui"; import { formatBytes } from "../../lib/format"; +import { installationQuery } from "../../lib/installation"; import type { MessageKey } from "../../lib/locale-strings"; -import { sandboxSetupOrigin } from "./core-origin"; +import { sandboxConfigurationRejection } from "../../lib/sandbox-labels"; import { defaultSandboxResources, distributionRuntime, savedSpecification, validSandboxResources } from "./deployment-specification"; import { isRuntimeRelease, isRuntimeReleaseField, RUNTIME_RELEASE_FIELDS } from "./runtime-release"; import "./sandbox-wizard.css"; @@ -66,11 +68,13 @@ function presetOf(provider: SandboxProvider, resources: SandboxResources): Prese * release comes from this console's distribution manifest when it serves one. * `current` pre-selects the saved choices when a deployment changes. Keeping * the backend keeps its saved size and Runtime; another backend starts from its - * defaults and this console's Runtime. The Core address stays as it is and an - * E2B key must be entered again. + * defaults and this console's Runtime. An E2B key must be entered again. + * Core's address is config.json's `public_url`: the review only shows it, and + * a configuration Core rejects for it is explained here, where it was saved. */ -export function SandboxSetupWizard({ initialCoreUrl, current, disabled, switching = false, onSubmit }: { - initialCoreUrl: string; +export function SandboxSetupWizard({ coreUrl, current, disabled, switching = false, onSubmit }: { + /** The deployment's read-only Core address. */ + coreUrl: string; current?: { provider: SandboxProvider; specification?: SandboxSpecification; e2bTemplate?: string }; disabled: boolean; switching?: boolean; @@ -84,21 +88,21 @@ export function SandboxSetupWizard({ initialCoreUrl, current, disabled, switchin const saved = provider && current ? savedSpecification(provider, current.provider, current.specification) : null; const [resources, setResources] = useState(current?.specification?.resources ?? defaultSandboxResources("docker")); const [size, setSize] = useState(current?.specification ? presetOf(current.provider, current.specification.resources) ?? "current" : "standard"); - // An unusable console address (loopback, plain HTTP) is not offered as the Core address. - const [coreUrl, setCoreUrl] = useState(sandboxSetupOrigin(initialCoreUrl) ? initialCoreUrl : ""); const [apiKey, setApiKey] = useState(""); const [template, setTemplate] = useState(current?.e2bTemplate ?? ""); const [runtime, setRuntime] = useState>({}); const [busy, setBusy] = useState(false); + // Core's reason for rejecting the saved configuration, such as an E2B deployment without an HTTPS public_url. + const [rejection, setRejection] = useState(null); + const installation = useQuery(installationQuery); + const address = coreUrl || installation.data?.public_url || null; + const configuration = installation.data?.configuration ?? null; // 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 }); const release: Partial = Object.keys(runtime).length ? runtime : saved?.runtime ?? matched.data ?? {}; - const origin = switching ? initialCoreUrl : sandboxSetupOrigin(coreUrl); - // The review asks for the Core address only when the console's own cannot serve. - const askOrigin = !switching && !sandboxSetupOrigin(initialCoreUrl); const needsRuntime = provider === "docker" || provider === "microsandbox"; const runtimeReady = !needsRuntime || isRuntimeRelease(release); // Core keeps no key across a change: E2B always needs one. @@ -106,7 +110,7 @@ export function SandboxSetupWizard({ initialCoreUrl, current, disabled, switchin // 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)); - const ready = provider !== null && origin !== null && runtimeReady && e2bReady && sizeReady && !disabled && !busy; + const ready = provider !== null && runtimeReady && e2bReady && sizeReady && !disabled && !busy; const order: Step[] = where === "direct" ? ["where", "e2b", "review"] : ["where", "backend", "size", "review"]; const index = Math.max(0, order.indexOf(step === "advanced" ? "review" : step)); @@ -115,6 +119,7 @@ export function SandboxSetupWizard({ initialCoreUrl, current, disabled, switchin // The saved backend keeps its size and Runtime; another starts from its standard size and this console's Runtime. function choose(next: SandboxProvider) { if (next !== provider) { + setRejection(null); const kept = current ? savedSpecification(next, current.provider, current.specification) : null; setResources(kept?.resources ?? presets(next).standard); setSize(kept ? presetOf(next, kept.resources) ?? "current" : "standard"); @@ -126,15 +131,19 @@ export function SandboxSetupWizard({ initialCoreUrl, current, disabled, switchin async function save(event?: FormEvent) { event?.preventDefault(); if (!ready || !provider) return; - setBusy(true); + setBusy(true); setRejection(null); try { await onSubmit({ provider, - core_url: origin!, ...(sized ? { resources } : {}), ...(needsRuntime ? { runtime: release as SandboxRuntimeRelease } : {}), ...(provider === "e2b" ? { e2b: { api_key: apiKey.trim(), template: template.trim() } } : {}), }); + } catch (error) { + // A configuration Core rejected is explained here; the page reports every other failure. + const reason = sandboxConfigurationRejection(error); + if (reason === null) throw error; + setRejection(reason); } finally { setApiKey(""); setBusy(false); @@ -217,17 +226,21 @@ export function SandboxSetupWizard({ initialCoreUrl, current, disabled, switchin
{runtimeReady ? {release.source_commit!.slice(0, 12)} : {t("Runtime release needed")}{t("This console serves no Runtime manifest. Enter the release under advanced settings.")}}
) : null} - {askOrigin ? null : ( -
-
{t("Core address")}{t("The address nodes and sandboxes use to reach Core. It must be HTTPS and reachable from them; the console's own address may differ.")}
-
{origin ? {origin} : {t("Core address needed")}}
-
- )} +
+
{t("Core address")}{t("The address nodes and sandboxes use to reach Core.")}
+
{address ? {address} : "—"}{t("Set by public_url in config.json")}
+
- {askOrigin ? ( - - setCoreUrl(event.target.value)} placeholder="https://core.example" /> - + {rejection ? ( +
+

{rejection}

+ {configuration ? ( +
+
{t("Config file")}
+
{t("Then run")}
+
+ ) : null} +
) : null}

+ :

{t("howToCall.loading")}

; + } else if (base === null) { + body =

{t("howToCall.localOnly")}

; + } else { + const samples = callSamples(base, apiKey); + body = <> + + + +

{t("howToCall.model")}

+ ; + } + return ( +
+

{t("howToCall.title")}

+ {body} +
+ ); +} + +function CodeSample({ label, value }: { label: string; value: string }) { + const { t } = useTranslation("keys"); + return ( +
+
+ {label} + +
+
{value}
+
+ ); +} diff --git a/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx b/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx index fe5cc8fb6..c9dcf003e 100644 --- a/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx +++ b/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx @@ -1,3 +1,5 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; @@ -10,10 +12,12 @@ const project: Project = { id: "proj_7f3a91c2", name: "Production", status: "act const secret = "pc_live_" + "s".repeat(40); const issued: AdminIssuedKey = { id: "9f0e1d2c-3b4a-4c5d-8e6f-7a8b9c0d1e2f", project_id: "proj_7f3a91c2", name: "bob-laptop", prefix: "pc_live_Zq8", created_at: 300, revoked_at: null, key: secret }; const controls = (flow: KeyFlow): KeyFlowControls => ({ flow, dispatch: () => undefined, submit: async () => undefined }); +// The how-to-call card under a new key reads the installation through the query cache. +const render = (element: ReactElement) => renderToStaticMarkup({element}); describe("one-time key display", () => { it("shows the plaintext read-only, without autofill, with a copy button and a saved confirmation", () => { - const html = renderToStaticMarkup(); + const html = render(); expect(html).toContain(`value="${secret}"`); expect(html).toContain("readOnly"); expect(html).toContain('autoComplete="off"'); @@ -25,7 +29,7 @@ describe("one-time key display", () => { it("keeps a closed dialog's key on the page until it is confirmed saved", () => { const open = renderToStaticMarkup(); expect(open).toBe(""); - const closed = renderToStaticMarkup(); + const closed = render(); expect(closed).toContain(`value="${secret}"`); expect(closed).toContain("is shown only until you confirm it was saved"); }); diff --git a/apps/web/src/features/api-keys/KeyFlowDialogs.tsx b/apps/web/src/features/api-keys/KeyFlowDialogs.tsx index 8df6107e7..102e08de3 100644 --- a/apps/web/src/features/api-keys/KeyFlowDialogs.tsx +++ b/apps/web/src/features/api-keys/KeyFlowDialogs.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { HelpTip } from "../../components/console-ui"; import { Modal } from "../../components/Modal"; import { isUsableName, keyNameProblem, normalizeName, type FlowError, type KeyFlow, type NameProblem } from "./key-flows"; +import { HowToCall } from "./HowToCall"; import { PlaintextKey } from "./IssuedKey"; import type { KeyFlowControls } from "./use-key-flow"; @@ -126,6 +127,7 @@ export function KeyFlowDialogs({ controls, taken }: { controls: KeyFlowControls;

{t("issued.onceNotice")}

+
); @@ -143,6 +145,7 @@ export function PendingKeyNotice({ controls }: { controls: KeyFlowControls }) {

{t("issued.pending", { name: flow.issued.name, project: flow.project.name })}

+
diff --git a/apps/web/src/features/api-keys/how-to-call.css b/apps/web/src/features/api-keys/how-to-call.css new file mode 100644 index 000000000..501219b54 --- /dev/null +++ b/apps/web/src/features/api-keys/how-to-call.css @@ -0,0 +1,56 @@ +/* How to call Core with a new key: labelled, copyable samples and one note. */ + +.how-to-call { + display: grid; + gap: 10px; +} + +.how-to-call > h3 { + margin: 0; + color: var(--fg); + font-size: 13px; + font-weight: 600; + line-height: 18px; +} + +.how-to-call-sample { + min-width: 0; + overflow: hidden; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 8px; +} + +.how-to-call-sample-head { + display: flex; + min-height: 28px; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 4px 0 12px; + color: var(--fg-muted); + font-size: 12px; + border-bottom: 1px solid var(--line); +} + +.how-to-call-sample pre { + margin: 0; + padding: 10px 12px; + overflow-x: auto; + color: var(--fg); + font-family: var(--font-mono); + font-size: 12px; + line-height: 19px; +} + +.how-to-call-sample pre:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.how-to-call .how-to-call-note { + margin: 0; + color: var(--fg-muted); + font-size: 12.5px; + line-height: 18px; +} diff --git a/apps/web/src/i18n/locales/en/keys.ts b/apps/web/src/i18n/locales/en/keys.ts index 7f847339c..f0a663bca 100644 --- a/apps/web/src/i18n/locales/en/keys.ts +++ b/apps/web/src/i18n/locales/en/keys.ts @@ -93,6 +93,15 @@ export const keys = { uncertain: "Core did not confirm the result. Refresh to check before trying again.", rejected: "Core rejected the request: {{message}}", }, + howToCall: { + title: "How to call", + env: "Environment", + copy: "Copy {{label}}", + loading: "Reading the API address…", + failed: "The API address couldn't be read.", + localOnly: "Core's API is only reachable on this machine. To call it from elsewhere, set public_url in config.json and apply it.", + model: "Running an Agent needs a model: set a default model on the System page, or put the model fields in each request.", + }, detail: { back: "Back", facts: "Project details", diff --git a/apps/web/src/i18n/locales/zh-CN/keys.ts b/apps/web/src/i18n/locales/zh-CN/keys.ts index 2cbf33265..8b7040c56 100644 --- a/apps/web/src/i18n/locales/zh-CN/keys.ts +++ b/apps/web/src/i18n/locales/zh-CN/keys.ts @@ -95,6 +95,15 @@ export const keys: TranslationShape = { uncertain: "Core 没有确认结果。请先刷新查看,再决定是否重试。", rejected: "Core 拒绝了请求:{{message}}", }, + howToCall: { + title: "如何调用", + env: "环境变量", + copy: "复制 {{label}}", + loading: "正在读取 API 地址…", + failed: "无法读取 API 地址。", + localOnly: "Core 的 API 只能在本机访问。要从其他机器调用,请在 config.json 中设置 public_url 并应用。", + model: "运行 Agent 需要模型:在“系统”页面设置默认模型,或在每个请求里填写模型字段。", + }, detail: { back: "返回", facts: "项目信息", From d26ddd23c13d6fb20f48c339c1a5039ca67931c0 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 18:20:19 +0000 Subject: [PATCH 05/19] Show installation facts and startup settings on System System lists the public address, API base URL, installation ID and source commit, then the config.json startup settings read-only: each value (sensitive ones only as configured or not set), default and fixed markers and the services a change restarts, under the copyable file and apply command. The sandbox section links to the Nodes page where it changes. --- .../src/features/system/StartupSettings.tsx | 77 +++++++++++++++++++ apps/web/src/features/system/SystemPage.tsx | 41 +++++++--- apps/web/src/features/system/system.css | 56 ++++++++++++++ apps/web/src/i18n/locales/en/system.ts | 38 ++++++++- apps/web/src/i18n/locales/zh-CN/system.ts | 38 ++++++++- 5 files changed, 234 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/features/system/StartupSettings.tsx diff --git a/apps/web/src/features/system/StartupSettings.tsx b/apps/web/src/features/system/StartupSettings.tsx new file mode 100644 index 000000000..25432cf4d --- /dev/null +++ b/apps/web/src/features/system/StartupSettings.tsx @@ -0,0 +1,77 @@ +import type { CoreInstallationConfiguration, CoreInstallationSetting } from "@agents-core-web/agents-client"; +import { Trans, useTranslation } from "react-i18next"; + +import { ValuePill } from "../../components/atoms/ValuePill"; +import { Section } from "../../components/console-ui"; +import { CopyableId } from "../../components/list-ui"; +import { formatDateTime } from "../../lib/format"; + +function display(value: unknown): string | null { + if (value === null || value === undefined) return null; + return typeof value === "string" ? value : JSON.stringify(value); +} + +function isDefault(setting: CoreInstallationSetting): boolean { + return !setting.sensitive && JSON.stringify(setting.value ?? null) === JSON.stringify(setting.default ?? null); +} + +/** + * Platform › System: Core's startup settings, read-only. They live in + * config.json and take effect with the apply command; the console only says + * where to change them. A sensitive setting shows whether it is set, never + * its value. + */ +export function StartupSettings({ configuration }: { configuration: CoreInstallationConfiguration | null }) { + const { t, i18n } = useTranslation("system"); + const locale = i18n.resolvedLanguage; + return ( +
+ {configuration === null ?

{t("startup.none")}

: <> +

+ + , + command: , + }} + /> + + {configuration.applied_at ? {t("startup.appliedAt", { time: formatDateTime(Date.parse(configuration.applied_at) / 1000, locale) })} : null} +

+
+ + + + + + + + + + {configuration.settings.map((setting) => { + const value = setting.sensitive ? null : display(setting.value); + return ( + + + + + + ); + })} + +
{t("startup.columns.key")}{t("startup.columns.value")}{t("startup.columns.restarts")}
{setting.key} + + {setting.sensitive + ? t(setting.configured ? "startup.configured" : "startup.notSet") + : value === null ? {t("startup.notSet")} : {value}} + {isDefault(setting) ? {t("startup.default")} : null} + {setting.changeable ? null : {t("startup.fixed")}} + + {setting.restarts.length ? setting.restarts.join(", ") : {t("startup.noRestart")}}
+
+ } +
+ ); +} diff --git a/apps/web/src/features/system/SystemPage.tsx b/apps/web/src/features/system/SystemPage.tsx index e894be83e..878fb9399 100644 --- a/apps/web/src/features/system/SystemPage.tsx +++ b/apps/web/src/features/system/SystemPage.tsx @@ -6,11 +6,14 @@ import { useTranslation } from "react-i18next"; import { EmptyState, HelpTip, PageBody, PageHeader, RefreshButton, Section, StatusDot } from "../../components/console-ui"; import { ErrorState } from "../../components/ErrorState"; +import { CopyableId } from "../../components/list-ui"; import { DetailSkeleton } from "../../components/Skeleton"; import { useConsoleNavigation } from "../../lib/console-navigation"; import { formatBytes, formatPeriod } from "../../lib/format"; +import { installationQuery } from "../../lib/installation"; import { sandboxSize, templateBuildSize, templateBuildStatus } from "../sandbox/deployment-specification"; import { sandboxDeploymentQuery } from "../sandbox/sandbox-queries"; +import { StartupSettings } from "./StartupSettings"; import "./system.css"; const MIB = 2 ** 20; @@ -29,11 +32,11 @@ function Fact({ label, help, children }: { label: string; help?: string; childre } /** - * Platform › System: the sandbox configuration every project shares — where - * sandboxes run, how big each one is, the Runtime or E2B template build they - * run, microsandbox's idle suspension, the Core address and maintenance. - * Nothing else: harnesses and gateway flags are Core's own startup settings, - * not something an administrator acts on here. + * Platform › System: this installation's addresses, the sandbox configuration + * every project shares — where sandboxes run, how big each one is, the Runtime + * or E2B template build they run, microsandbox's idle suspension and + * maintenance — and Core's startup settings. Each says where it is changed: + * sandboxes on the Nodes page, startup settings in config.json. */ export function SystemPage() { const { t, i18n } = useTranslation("system"); @@ -41,8 +44,9 @@ export function SystemPage() { const { t: tNav } = useTranslation("navigation"); const { navigate } = useConsoleNavigation(); const deployment = useQuery(sandboxDeploymentQuery); + const installation = useQuery(installationQuery); const data = deployment.data ?? null; - const refresh = () => { void deployment.refetch(); }; + const refresh = () => { void deployment.refetch(); void installation.refetch(); }; const spec = data?.specification ?? null; // An E2B selection may adopt its template build's size instead of saving one. const each = data ? sandboxSize(data) : null; @@ -66,7 +70,7 @@ export function SystemPage() { ); } else { body = ( -
+
navigate("nodes")}>{t("sandbox.change")}}>
{data.provider === "e2b" ? t("sandbox.e2b") : t("sandbox.ownMachines", { provider: providerNames[data.provider] ?? data.provider })} {each ? ( @@ -92,7 +96,6 @@ export function SystemPage() { {formatPeriod(data.suspension.idle_seconds, locale)} {formatPeriod(data.suspension.retention_seconds, locale)} : null} - {data.core_url ? {data.core_url} : null} {data.maintenance ? : t("values.off")} @@ -101,15 +104,33 @@ export function SystemPage() { ); } + const about = installation.data; + const facts = about ? ( +
+
+ {about.public_url ? {about.public_url} : {t("installation.notSet")}} + {about.api_base_url ? : {t("installation.localOnly")}} + {about.installation_id ? : {t("installation.unknown")}} + {about.source_commit ? {about.source_commit.slice(0, 12)} : {t("installation.unknown")}} +
+
+ ) : installation.isError && !installation.isFetching + ? void installation.refetch()} /> + : ; + return (
} + actions={} /> - {body} + + {facts} + {body} + {about ? : null} +
); } diff --git a/apps/web/src/features/system/system.css b/apps/web/src/features/system/system.css index 5c935702b..90bd450fb 100644 --- a/apps/web/src/features/system/system.css +++ b/apps/web/src/features/system/system.css @@ -54,3 +54,59 @@ font-size: 12px; overflow-wrap: anywhere; } + +.system-muted { + color: var(--ink-3); +} + +/* Where startup settings are changed: the file and the apply command, both copyable. */ +.system-where { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 12px; + margin: 0; + color: var(--ink-2); + font-size: 13px; + line-height: 24px; +} + +/* The file and the command read as chips that carry their own copy button. */ +.system-where .copyable-id { + margin: 0 2px; + padding-left: 8px; + vertical-align: middle; + background: var(--surface); + border-radius: var(--radius-chip); + box-shadow: var(--shadow-hairline); +} + +.system-where .copyable-id code { + color: var(--fg); + font-family: var(--font-mono); + font-size: 12px; +} + +/* Setting names and values are the table's data, not secondary IDs. */ +.data-table.system-settings code { + color: var(--ink); + font-size: 12px; +} + +.system-applied { + color: var(--ink-3); + font-size: 12.5px; +} + +.system-note { + margin: 0; + color: var(--ink-2); + font-size: 13px; +} + +.system-setting-value { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 6px; +} diff --git a/apps/web/src/i18n/locales/en/system.ts b/apps/web/src/i18n/locales/en/system.ts index 6a8edb4c7..0af76c477 100644 --- a/apps/web/src/i18n/locales/en/system.ts +++ b/apps/web/src/i18n/locales/en/system.ts @@ -1,9 +1,43 @@ export const system = { - help: "The sandbox configuration every project shares: where sandboxes run, how big each one is and what they run. Change it on the Nodes page.", + help: "This installation's addresses and the settings every project shares. Sandboxes are changed on the Nodes page, startup settings in config.json.", refresh: "Refresh system configuration", loading: "Loading…", + installation: { + title: "Installation", + failed: "The installation could not be read.", + publicUrl: "Public address", + publicUrlHelp: "The one HTTPS address apps, nodes and sandboxes use to reach Core: public_url in config.json.", + apiBaseUrl: "API base URL", + apiBaseUrlHelp: "Applications use it as OPENAI_BASE_URL, with a Project API key as OPENAI_API_KEY.", + copyApiBaseUrl: "Copy API base URL", + localOnly: "Only reachable on this machine", + notSet: "Not set", + id: "Installation ID", + sourceCommit: "Source commit", + unknown: "Unknown", + }, + startup: { + title: "Startup settings", + help: "Core reads these from config.json when it starts. The console only shows them.", + none: "Core was not started from a config.json, so there are no startup settings to show.", + where: "Change these in , then run ", + copyPath: "Copy path", + copyCommand: "Copy command", + appliedAt: "Last applied {{time}}", + columns: { + key: "Setting", + value: "Value", + restarts: "Restarts", + }, + configured: "Configured", + notSet: "Not set", + default: "Default", + fixed: "Fixed after install", + noRestart: "None", + }, sandbox: { title: "Sandboxes", + change: "Change on the Nodes page", deploymentFailed: "The sandbox deployment could not be loaded.", unconfigured: "Sandboxes are not set up yet", setUp: "Set up on the Nodes page", @@ -24,8 +58,6 @@ export const system = { suspendAfterHelp: "A sandbox idle this long after its Session's last Turn is suspended into a snapshot on its node. The Session's next Turn resumes it.", keepSuspended: "Keep suspended for", keepSuspendedHelp: "How long a suspended sandbox's snapshot is kept on its node.", - coreOrigin: "Core address", - coreOriginHelp: "The address nodes and sandboxes use to reach Core.", maintenance: "Maintenance", maintenanceHelp: "While on, no new sandboxes are placed.", }, diff --git a/apps/web/src/i18n/locales/zh-CN/system.ts b/apps/web/src/i18n/locales/zh-CN/system.ts index a63ae10a9..60c305e47 100644 --- a/apps/web/src/i18n/locales/zh-CN/system.ts +++ b/apps/web/src/i18n/locales/zh-CN/system.ts @@ -1,11 +1,45 @@ import type { system as english } from "../en/system"; type TranslationShape = { [K in keyof T]: T[K] extends string ? string : TranslationShape }; export const system: TranslationShape = { - help: "所有项目共用的沙箱配置:沙箱在哪里运行、每个多大、运行什么。修改请到“节点”页面。", + help: "这个安装的地址,以及所有项目共用的设置。沙箱在“节点”页面修改,启动设置在 config.json 中修改。", refresh: "刷新系统配置", loading: "正在加载…", + installation: { + title: "安装", + failed: "无法读取安装信息。", + publicUrl: "公开地址", + publicUrlHelp: "应用、节点和沙箱访问 Core 使用的唯一 HTTPS 地址,即 config.json 中的 public_url。", + apiBaseUrl: "API 基础地址", + apiBaseUrlHelp: "应用把它设为 OPENAI_BASE_URL,并把项目 API key 设为 OPENAI_API_KEY。", + copyApiBaseUrl: "复制 API 基础地址", + localOnly: "只能在本机访问", + notSet: "未设置", + id: "安装 ID", + sourceCommit: "源码提交", + unknown: "未知", + }, + startup: { + title: "启动设置", + help: "Core 启动时从 config.json 读取这些设置。控制台只显示它们。", + none: "Core 不是通过 config.json 启动的,没有可显示的启动设置。", + where: "在 中修改,然后运行 ", + copyPath: "复制路径", + copyCommand: "复制命令", + appliedAt: "上次应用于 {{time}}", + columns: { + key: "设置", + value: "值", + restarts: "重启的服务", + }, + configured: "已设置", + notSet: "未设置", + default: "默认值", + fixed: "安装后不可修改", + noRestart: "无", + }, sandbox: { title: "沙箱", + change: "在“节点”页面修改", deploymentFailed: "无法加载沙箱部署。", unconfigured: "还没有设置沙箱", setUp: "去“节点”页面设置", @@ -26,8 +60,6 @@ export const system: TranslationShape = { suspendAfterHelp: "Session 的最后一个 Turn 结束后,沙箱空闲这么久就会被挂起为节点上的快照;Session 的下一个 Turn 会恢复它。", keepSuspended: "挂起后保留", keepSuspendedHelp: "挂起的沙箱快照在节点上保留的时长。", - coreOrigin: "Core 地址", - coreOriginHelp: "节点和沙箱访问 Core 使用的地址。", maintenance: "维护模式", maintenanceHelp: "开启时不再分配新沙箱。", }, From 9642ec5e558fd99eb4c33d12916821d2dc0b6a8d Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 18:20:19 +0000 Subject: [PATCH 06/19] Cover the public address and installation read in browser acceptance The fixture serves GET /core/v1/installation (public, local-only and stale-node modes), rejects core_url on deployment writes with 400, returns a read-only core_url and rejects E2B with 409 sandbox_configuration_error when public_url is not HTTPS. Acceptance covers the wizard without an address field, the E2B rejection, the how-to-call card, the startup settings and the stale-node warning. --- apps/web/e2e/console.ts | 8 ++-- apps/web/e2e/fixture-console.mjs | 71 ++++++++++++++++++++++++++------ apps/web/e2e/nodes.spec.ts | 3 +- apps/web/e2e/projects.spec.ts | 6 +++ apps/web/e2e/public-url.spec.ts | 56 +++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 17 deletions(-) create mode 100644 apps/web/e2e/public-url.spec.ts diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index 0c3c9c5b7..7e56fa0cf 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -8,13 +8,15 @@ export const FIXTURE_CORE_KEY = "fixture-core-key-3f9a2c71"; /** * Fixture state options: `fresh` is a new install (no project, Session or Runtime), - * `sandbox` the sandbox deployment, and `nodes: "none"` a deployment no node has joined. + * `sandbox` the sandbox deployment, `nodes: "none"` a deployment no node has joined, and + * `installation` how config.json's public_url is set: "public" (HTTPS, the default), "local" (loopback, so the API has no + * public base URL and E2B is rejected) or "stale" (public, with a node enrolled with an earlier address). */ -export interface FixtureOptions { fresh?: boolean; sandbox?: "configured" | "none" | "e2b"; nodes?: "none" } +export interface FixtureOptions { fresh?: boolean; sandbox?: "configured" | "none" | "e2b"; nodes?: "none"; installation?: "public" | "local" | "stale" } /** Fresh fixture state: signed out ("login") or already signed in ("authenticated"). */ export async function resetFixture(request: APIRequestContext, auth: "login" | "authenticated" = "authenticated", options: FixtureOptions = {}) { - await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}&sandbox=${options.sandbox ?? "configured"}${options.nodes ? `&nodes=${options.nodes}` : ""}`); + await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}&sandbox=${options.sandbox ?? "configured"}${options.nodes ? `&nodes=${options.nodes}` : ""}&installation=${options.installation ?? "public"}`); } /** Opens the console already signed in, in English. */ diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 95ab9cc32..2008d57b1 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -1,6 +1,6 @@ // Browser acceptance fixture: the console service's routes (/console/**) and the -// Core management tree it forwards (/core/v1/**: projects, summary, audit log, -// metrics and /core/v1/sandbox/**), with synthetic, deterministic data and +// Core management tree it forwards (/core/v1/**: installation, projects, summary, +// audit log, metrics and /core/v1/sandbox/**), with synthetic, deterministic data and // in-memory writes. It never serves /v1; any /v1 request, and any // browser-supplied Authorization header, is recorded so a test can assert that // the console stays on its management boundary. @@ -25,8 +25,42 @@ const hex = (c) => c.repeat(64); const release = { source_commit: "c0ffee".padEnd(40, "0"), image_id: `sha256:${hex("1")}`, image_manifest_digest: `sha256:${hex("2")}`, microsandbox_ref: `parsar-core-runtime@sha256:${hex("3")}`, runtime_sha256: hex("4"), firmware_sha256: hex("5") }; const manifest = { platform: "linux/amd64", source_commit: release.source_commit, images: { runtime: release.image_id }, image_manifest_digests: { runtime: release.image_manifest_digest }, runtime_ref: release.microsandbox_ref, microsandbox: { runtime_sha256: release.runtime_sha256, firmware_sha256: release.firmware_sha256 }, artifacts: {} }; +/** + * config.json's public_url. "public": an HTTPS address, so applications get an API base URL; + * "local": the installer's loopback default, reachable only on the Core machine; + * "stale": public, with a node still enrolled with an earlier address. + */ +const PUBLIC_URL = "https://core.example.com"; +const LOCAL_URL = "http://127.0.0.1:8091"; +const publicUrl = () => (state.installation === "local" ? LOCAL_URL : PUBLIC_URL); + +/** GET /core/v1/installation: the address, the startup settings from config.json and what is bound to the address. */ +function installation() { + const local = state.installation === "local"; + const setting = (key, value, fallback, restarts, extra = {}) => ({ key, value, default: fallback, changeable: true, sensitive: false, restarts, ...extra }); + const secret = (key, restarts) => ({ key, value: null, default: null, changeable: true, sensitive: true, restarts, configured: true }); + const nodes = state.deployment?.provider && state.deployment.provider !== "e2b" ? state.nodes.length : 0; + return { + object: "core.installation", installation_id: "7f3c2a90-fixture", public_url: publicUrl(), api_base_url: local ? null : `${PUBLIC_URL}/v1`, + local_only: local, source_commit: release.source_commit, + configuration: { + path: "/opt/parsar/config.json", apply_command: "sudo parsar apply", applied_at: "2026-09-24T09:30:00Z", + settings: [ + setting("public_url", publicUrl(), LOCAL_URL, ["core", "web"]), + setting("listen_address", "127.0.0.1:8091", "127.0.0.1:8091", ["core"]), + setting("web_listen_address", "127.0.0.1:4173", "127.0.0.1:4173", ["web"]), + setting("data_dir", "/var/lib/parsar", "/var/lib/parsar", [], { changeable: false }), + setting("log_level", "debug", "info", ["core", "web"]), + secret("core_key", ["core", "web"]), + secret("database_url", ["core"]), + ], + }, + address_bindings: { nodes, nodes_on_other_address: state.installation === "stale" ? Math.min(1, nodes) : 0, hosted_sandboxes: 0, self_hosted_executors: 0 }, + }; +} + function configuredDeployment() { - return { installation_id: "7f3c2a90-fixture", provider: "docker", core_url: `http://127.0.0.1:${port}`, maintenance: false, owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 }, runtime: release }, specification_digest: "fixture", suspension: null }; + return { installation_id: "7f3c2a90-fixture", provider: "docker", core_url: publicUrl(), maintenance: false, owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 }, runtime: release }, specification_digest: "fixture", suspension: null }; } /** The E2B template build as Core read it when the selection was saved. */ const templateBuild = { status: "ready", resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 10240 } }; @@ -36,7 +70,7 @@ function e2bDeployment() { return { ...configuredDeployment(), provider: "e2b", mode: "direct", resources: { allocations: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, e2b: { template: "parsar-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", credential_configured: true, template_build: templateBuild } }; } -function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "demo") { +function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "demo", address = "public") { const base = buildDemo(); const now = Math.floor(Date.now() / 1000); const resources = buildResources(now, base.agents, base.sessions); @@ -52,9 +86,12 @@ function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "d violations: [], writes: [], failNext: null, nextId: 1, // Executor credential metadata by environment ID; tokens are never kept. executorCredentials: new Map(), + // How config.json's public_url is set: "public", "local" or "stale". + installation: address, // "none": the deployment is not configured yet, so the Nodes page offers setup. - deployment: sandbox === "none" ? null : sandbox === "e2b" ? e2bDeployment() : configuredDeployment(), + deployment: null, }; + state.deployment = sandbox === "none" ? null : sandbox === "e2b" ? e2bDeployment() : configuredDeployment(); if (sandbox === "e2b") Object.assign(state, { nodes: [], allocations: [] }); } reset(); @@ -63,11 +100,11 @@ function send(response, status, body, headers = {}) { response.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", ...headers }); response.end(JSON.stringify(body)); } -function error(response, status, message, code = null) { +function error(response, status, message, code = null, param = null) { // Derive `type` as Core's writeError does (services/agents-api/internal/api/errors.go). const type = status >= 500 ? "server_error" : status === 409 ? "conflict_error" : code === "not_found_error" || code === "invalid_beta" ? code : "invalid_request_error"; - send(response, status, { error: { message, type, code, param: null } }); + send(response, status, { error: { message, type, code, param } }); } async function body(request) { const chunks = []; @@ -173,6 +210,7 @@ async function adminWrite(request, response, path) { function adminRead(response, path, url) { const a = state.admin; + if (path === "/installation") return send(response, 200, installation()); if (path === "/projects") return send(response, 200, { data: a.projects.map(a.publicProject), has_more: false }); if (path === "/summary") return send(response, 200, a.summary(url)); if (path === "/metrics") return send(response, 200, coreMetrics(url.searchParams.get("range") ?? "1h")); @@ -235,23 +273,30 @@ async function sandboxRoute(request, response, path) { const data = state.admin.runtimeObservations().map((entry) => ({ ...entry, observation: { ...entry.observation, disk: e2b && entry.observation.status === "observed" ? { usage_bytes: 3 * 2 ** 30, limit_bytes: 10 * 2 ** 30 } : null } })); return send(response, 200, { object: "list", data, has_more: false, first_id: data[0]?.observation.id ?? null, last_id: data.at(-1)?.observation.id ?? null }); } - if (path === "/deployment" && request.method === "POST") { + if (path === "/deployment" && (request.method === "POST" || request.method === "PUT")) { const input = await body(request); - if (state.deployment) return error(response, 409, "The sandbox deployment is already configured.", "sandbox_deployment_conflict"); + const initialize = request.method === "POST"; + if (initialize && state.deployment) return error(response, 409, "The sandbox deployment is already configured.", "sandbox_deployment_conflict"); + if (!initialize && !state.deployment) return error(response, 409, "The sandbox deployment is not configured.", "sandbox_deployment_conflict"); + // As Core: the address is config.json's public_url and read-only. + if ("core_url" in input) return error(response, 400, "core_url is read-only; set public_url in config.json.", "invalid_sandbox_configuration", "core_url"); const e2b = input.provider === "e2b"; if (!e2b && (!input.resources || !input.runtime)) return error(response, 400, "resources and runtime are required.", "invalid_sandbox_configuration"); + // E2B sandboxes reach Core over the internet, which a loopback or HTTP public_url cannot serve. + if (e2b && !publicUrl().startsWith("https://")) return error(response, 409, "E2B sandboxes need an HTTPS public_url. Set public_url in config.json, then run parsar apply.", "sandbox_configuration_error"); // As Core: E2B may omit resources and adopt its template build's CPU and memory; only microsandbox suspends. const resources = input.resources ?? { cpus: templateBuild.resources.cpus, memory_mib: templateBuild.resources.memory_mib }; state.deployment = { - ...configuredDeployment(), provider: input.provider, core_url: input.core_url, mode: e2b ? "direct" : "nodes", + ...configuredDeployment(), provider: input.provider, mode: e2b ? "direct" : "nodes", + ...(initialize ? {} : { maintenance: true, generation: state.deployment.generation + 1 }), specification: { resources, ...(input.runtime ? { runtime: input.runtime } : {}) }, ...(e2b ? { e2b: { template: input.e2b?.template ?? "", credential_configured: true, template_build: templateBuild } } : {}), suspension: input.provider === "microsandbox" ? { idle_seconds: 300, retention_seconds: 86400 } : null, }; - return send(response, 201, state.deployment); + return send(response, initialize ? 201 : 200, state.deployment); } if (path === "/deployment") { - return send(response, 200, state.deployment ?? { installation_id: "7f3c2a90-fixture", provider: "", core_url: `http://127.0.0.1:${port}`, maintenance: false, owner_epoch: 3, generation: 0, mode: "", resources: { allocations: 0, pending: 0 }, suspension: null }); + return send(response, 200, state.deployment ?? { installation_id: "7f3c2a90-fixture", provider: "", core_url: publicUrl(), maintenance: false, owner_epoch: 3, generation: 0, mode: "", resources: { allocations: 0, pending: 0 }, suspension: null }); } if (path === "/nodes") return send(response, 200, { data: state.nodes }); if (path === "/enrollment-tokens" && request.method === "POST") { @@ -341,7 +386,7 @@ function registeredNode(nodeId) { async function fixtureRoute(request, response, url) { if (url.pathname === "/__fixture/health") return send(response, 200, { ok: true }); if (url.pathname === "/__fixture/reset" && request.method === "POST") { - reset(url.searchParams.get("auth") ?? "login", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured", url.searchParams.get("nodes") ?? "demo"); + reset(url.searchParams.get("auth") ?? "login", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured", url.searchParams.get("nodes") ?? "demo", url.searchParams.get("installation") ?? "public"); return send(response, 200, { ok: true }); } if (url.pathname === "/__fixture/fail-next" && request.method === "POST") { diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index f3e2f3a41..b102f4cb3 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -106,7 +106,6 @@ test("sets up own-machine sandboxes page by page, with the Runtime from the dist await page.getByRole("button", { name: "Docker" }).click(); await page.getByRole("button", { name: /^Standard/ }).click(); await expect(page.getByRole("heading", { name: "Review and save" })).toBeVisible(); - await page.getByLabel("Core address").fill("https://core.example.com"); await page.getByRole("button", { name: "Save configuration" }).click(); // The saved specification carries the Runtime read from the console's manifest. @@ -155,6 +154,8 @@ test("keeps the saved size and Runtime for the same backend, and starts another await save.click(); await expect.poll(() => submitted?.resources).toEqual(current.resources); expect(submitted?.runtime).toEqual(runtime); + // Core's address is config.json's public_url: a change never sends it. + expect(submitted).not.toHaveProperty("core_url"); // Another backend starts from its own size, with disks, and this console's Runtime. await back.click(); diff --git a/apps/web/e2e/projects.spec.ts b/apps/web/e2e/projects.spec.ts index 89ec72a72..b12bde08f 100644 --- a/apps/web/e2e/projects.spec.ts +++ b/apps/web/e2e/projects.spec.ts @@ -16,6 +16,12 @@ test("creates a project, shows a new key once, revokes it and archives the proje await page.getByRole("dialog").getByRole("button", { name: "Issue key" }).click(); const issued = page.getByRole("dialog", { name: "Key issued" }); await expect(issued.getByLabel("New key ci")).toHaveValue(/fixture-secret/); + // How to call Core with it: the official SDK's variables, set to the API base URL and this key. + const key = await issued.getByLabel("New key ci").inputValue(); + const call = issued.getByRole("region", { name: "How to call" }); + await expect(call.getByLabel("Environment", { exact: true })).toHaveText(`OPENAI_BASE_URL=https://core.example.com/v1\nOPENAI_API_KEY=${key}`); + await expect(call.getByLabel("curl", { exact: true })).toContainText('curl "$OPENAI_BASE_URL/agents"'); + await expect(call.getByLabel("Python", { exact: true })).toContainText("pip install openai==3.13.0"); await issued.getByRole("button", { name: "I've saved this key" }).click(); await expect(page.getByRole("table", { name: "Keys of Acceptance" })).toContainText("ci"); await expect(page.getByLabel("New key ci")).toHaveCount(0); diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts new file mode 100644 index 000000000..e0100bb1c --- /dev/null +++ b/apps/web/e2e/public-url.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from "@playwright/test"; + +import { expectManagementBoundary, openConsole } from "./console"; + +test.afterEach(async ({ request }) => expectManagementBoundary(request)); + +test("sets up sandboxes with Core's address read-only, never sending it", async ({ page, request }) => { + const bodies: string[] = []; + page.on("request", (sent) => { if (sent.url().includes("/core/v1/sandbox/deployment")) bodies.push(sent.postData() ?? ""); }); + await openConsole(page, request, "nodes", { sandbox: "none" }); + await page.getByRole("button", { name: "Own machines" }).click(); + await page.getByRole("button", { name: "Docker" }).click(); + await page.getByRole("button", { name: /^Standard/ }).click(); + const review = page.getByRole("definition").filter({ hasText: "https://core.example.com" }); + await expect(review).toContainText("Set by public_url in config.json"); + await expect(page.getByRole("textbox", { name: "Core address" })).toHaveCount(0); + await page.getByRole("button", { name: "Save configuration" }).click(); + await expect(page.getByText("c0ffee000000")).toBeVisible(); + expect(bodies.some((entry) => entry.includes("core_url"))).toBe(false); +}); + +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, "nodes", { 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 page.getByRole("button", { name: "Next" }).click(); + await expect(page.getByRole("definition").filter({ hasText: "http://127.0.0.1:8091" })).toBeVisible(); + await page.getByRole("button", { name: "Save configuration" }).click(); + const rejection = page.locator(".wizard-rejection"); + await expect(rejection).toContainText("E2B sandboxes need an HTTPS public_url."); + await expect(rejection).toContainText("/opt/parsar/config.json"); + await expect(rejection).toContainText("sudo parsar apply"); + // Nothing was saved and nothing is uncertain: no dialog, and the wizard stays on its review. + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Review and save" })).toBeVisible(); +}); + +test("lists the startup settings on System with where to change them", async ({ page, request }) => { + await openConsole(page, request, "system"); + const installation = page.getByRole("region", { name: "Installation" }); + await expect(installation).toContainText("https://core.example.com/v1"); + const startup = page.getByRole("region", { name: "Startup settings" }); + await expect(startup).toContainText("Change these in /opt/parsar/config.json, then run sudo parsar apply"); + const settings = startup.getByRole("table", { name: "Startup settings" }); + await expect(settings.getByRole("row", { name: /^log_level/ })).toContainText("debug"); + await expect(settings.getByRole("row", { name: /^listen_address/ })).toContainText("Default"); + await expect(settings.getByRole("row", { name: /^data_dir/ })).toContainText("Fixed after install"); + await expect(settings.getByRole("row", { name: /^core_key/ })).toContainText("Configured"); + await expect(settings.getByRole("row", { name: /^public_url/ })).toContainText("core, web"); +}); + +test("warns on Nodes about nodes bound to an old Core address", async ({ page, request }) => { + await openConsole(page, request, "nodes", { installation: "stale" }); + await expect(page.getByRole("alert").filter({ hasText: "old Core address" })).toHaveText("1 node is still bound to an old Core address. Add it again."); +}); From b2023f851817ad4360d0d6a241d67b3386ea175f Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 18:27:00 +0000 Subject: [PATCH 07/19] Describe the read-only public address and installation read in Web docs DESIGN.md: the wizard shows Core's address read-only from config.json's public_url and explains an E2B rejection inline; the how-to-call card; the System page's sections; the Nodes old-address warning. PRODUCT.md: System and the OPENAI_BASE_URL/OPENAI_API_KEY guidance beside a new key. docs/web: the Platform row, the Agents API row, a GET /core/v1/installation row and the deployment row's read-only core_url. --- apps/web/DESIGN.md | 27 ++++++++++++++++++++++++--- apps/web/PRODUCT.md | 10 ++++++---- docs/web/README.md | 2 +- docs/web/protocol-coverage.md | 11 ++++++----- 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index fe0aec7dd..dffc3d1bb 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -504,6 +504,12 @@ request runs. red underneath. The issued key appears in a read-only field with a copy button, under a notice that it is shown once; only "I've saved this key" dismisses it. Closing the dialog moves the key into a pending notice card on the page. +- **How to call**: wherever a new key is shown, a card under it gives three + copyable samples, each a Margin Gray block with a Hairline and its label and copy + button in a header row: `OPENAI_BASE_URL` (the installation's API base URL) and + `OPENAI_API_KEY` (the new key), a curl request and a Python example with the + pinned SDK. One Graphite line says execution needs a model. Without a public API + address the card only says the API is reachable on the Core machine alone. ### Navigation Sidebar groups Monitor, Resources and Platform with 12px Graphite group labels; @@ -567,7 +573,9 @@ data on screen, projects that could not be read, and other failed actions are reported in an error toast with the reason. Only when a page or section has nothing to show does an error state take the place of its content; errors inside a dialog or a form stay beside what they concern. Coverage notes (Margin Gray, Hairline, 12px corners, -12.5px Graphite) state bounded aggregation. Partial-data chips are amber-tinted pills +12.5px Graphite) state bounded aggregation. A standing warning that needs action, +such as nodes still bound to an old Core address on the Nodes page, is an +amber-tinted line at the top of the page body. Partial-data chips are amber-tinted pills with a help tip. Safety notices (a key shown once, a destructive consequence) stay visible in body text. @@ -617,13 +625,26 @@ each sandbox takes the template build's size), then a review. Choices are large cards that advance on a click; short indigo dashes show the progress; pages slide and blur across. The review states where sandboxes run, the size, the Runtime (taken from this console's distribution manifest) and the -Core address, asking for it only when the console's own address cannot serve. +Core address, read-only: it is config.json's `public_url`, and the console never +asks for it. When Core rejects the configuration for it (E2B without an HTTPS +`public_url`), a red-tinted block under the review keeps Core's message and adds +the config file and apply command as copyable values. Advanced settings, one link away, hold the complete form: resources (not for -E2B), the Core address, the Runtime release and the E2B template. A change keeps the saved size +E2B), the Runtime release and the E2B template. A change keeps the saved size and Runtime while the backend stays the same (a saved size outside the presets is offered as Current); another backend starts from its standard size and this console's Runtime, and E2B always needs its key again. Rules sit behind help tips. +### System page +Read-only facts in three sections, each saying where it changes. Installation: the +public address, API base URL, installation ID and source commit as a fact card. +Sandboxes: the shared sandbox configuration, with a "Change on the Nodes page" +text action in the section header. Startup settings: a line naming the config file +and the apply command as copyable chips, with when they were last applied, over a +table of each setting, its value and the services a change restarts. Sensitive +settings show only Configured or Not set; Default and Fixed after install are +neutral pills beside the value. + ### Loading and motion The console has no spinners and no "Loading…" lines. Reads are cached (TanStack Query) and prefetched on navigation hover, so revisits show data at once and diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index ec979796d..b7c62fe08 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -78,9 +78,10 @@ workbench. template build's), a review, and advanced settings with the complete form — then the node list with each node's capacity, host figures and allocations, enrollment, renaming, sandbox limits and removal; Add node - asks for the node's sandbox limits before it issues the one-time command), System (only the sandbox - configuration every project shares: where sandboxes run, each sandbox's size, the - Runtime or E2B template build, microsandbox's idle suspension, the Core address and maintenance). + asks for the node's sandbox limits before it issues the one-time command), System (read-only: the + installation's public address, API base URL, installation ID and source commit; Core's + startup settings from config.json, with the file and the apply command that change them; + and the sandbox configuration every project shares, with a link to Nodes where it changes). - A node whose provider is not ready names the reason (Docker unreachable, no Docker limits, missing Runtime image, no KVM, missing microsandbox components, a host too small) and its fix in the help tip beside its status, wherever that status shows. @@ -113,7 +114,8 @@ workbench. its named API keys; projects do not see each other's assets. Issuing or revoking a key never touches assets. Archiving a project revokes every key and keeps its assets viewable and deletable. Key plaintext is shown once, at issuance, and never - stored by the console. + stored by the console. Beside it the console tells developers to set + `OPENAI_BASE_URL` (the installation's API base URL) and `OPENAI_API_KEY` (the key). - **Web API only.** Every read and write goes through `/core/v1/**`. The console holds no API key and sends nothing to `/v1`. - **No asset writes except delete.** Assets are created and changed only by diff --git a/docs/web/README.md b/docs/web/README.md index 1308418ab..85dcff6f8 100644 --- a/docs/web/README.md +++ b/docs/web/README.md @@ -21,7 +21,7 @@ management client, and it sends nothing to `/v1`. | Monitor | Sandbox metrics | Node capacity and hosted Runtime CPU and memory across Projects | | Monitor | Session log | Every Session, opening one Session's read-only conversation, trace and Turns; a self-hosted Session's page also manages its executor credentials | | Resources | Agents, Environment templates, Skills, Files, Vaults | Inspection and permitted deletion | -| Platform | Projects and keys, Nodes, System | Project and key lifecycle; sandbox deployment and nodes; the sandbox configuration every Project shares: provider, sandbox size, Runtime or E2B template build, idle suspension, Core address and maintenance | +| Platform | Projects and keys, Nodes, System | Project and key lifecycle; sandbox deployment and nodes; System, read-only: the installation's public address, API base URL, ID and source commit, Core's config.json startup settings with where to change them, and the sandbox configuration every Project shares: provider, sandbox size, Runtime or E2B template build, idle suspension and maintenance | Missing data is shown as missing (—), never as zero. How each figure is read and bounded is recorded in [management interface coverage](protocol-coverage.md). diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index 4fc7c1c20..e49b63f6c 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -16,9 +16,9 @@ the administrator API are defined by the [administrator API contract](../../cont | Interface | Paths | Authentication | Console use | | --- | --- | --- | --- | | Console server | `/console/auth`, `/console/auth/{login,logout}`, `/console/config` | Core key at sign-in, then the console session cookie | Sign-in with the Core key and sign-out; the node installer (`node_installer`, `node_installer_sha256`) | -| Administrator API | `/core/v1/**` outside `/core/v1/sandbox` | Core key, added by the console server | Projects, keys, resource reads and deletion, executor credentials, provenance, summaries, Core metrics | +| Administrator API | `/core/v1/**` outside `/core/v1/sandbox` | Core key, added by the console server | Projects, keys, resource reads and deletion, executor credentials, provenance, summaries, Core metrics, the installation | | Sandbox administration | `/core/v1/sandbox/**` | Core key, added by the console server | Nodes page; fleet and capacity figures on Overview and Sandbox metrics; Runtime observations of every project | -| Agents API | `/v1/**` | Project API key | Not used; the console never sends it | +| Agents API | `/v1/**` | Project API key | Not used. Wherever a new key is shown, the console gives `OPENAI_BASE_URL` (the installation's `api_base_url`) and `OPENAI_API_KEY` (the new key) with `curl` and Python examples for `/v1/agents`, and sends neither; without an `api_base_url` it says the API is reachable only on the Core machine | Browser requests are same-origin and carry only the console session. The browser sends the Core key once, in the sign-in request body, and never stores it; it never @@ -106,19 +106,20 @@ allows. | Resource owners | `GET /core/v1/projects/{project_id}/resource-owners` | The Creator column of every resource list and the creator fact of detail pages, in batches of up to 100 IDs. An asset an administrator copied in an earlier release shows **Admin copy**; a resource without a record shows **Unknown** | | Write operations | `GET /core/v1/projects/{project_id}/write-operations` | A project's write history, newest first, filtered by key and resource type, 50 per page | | Summary | `GET /core/v1/summary` | Overview (per project), the Agents list (`group_by=agent`), a project's page (per project and `group_by=key`), Agent metrics (to skip idle projects, and usage by creating key since the start of the range), the Projects list (last activity) | +| Installation | `GET /core/v1/installation` | System's Installation facts (`public_url`, `api_base_url`, `installation_id`, `source_commit`) and read-only Startup settings (`configuration.settings` under its `path`, `apply_command` and `applied_at`; a sensitive setting shows only whether it is `configured`); `api_base_url` in the how-to-call samples under a new key; `path` and `apply_command` beside a sandbox configuration Core rejected; `address_bindings.nodes_on_other_address` for the Nodes warning about nodes bound to an old address. A sensitive setting with a value, or an unknown member, fails the read; `configuration: null` shows a note | | Core metrics | `GET /core/v1/metrics?range=` | Core metrics page; the Core popover on Overview. A Core without the route (404) is shown as not reporting; the popover then shows only Core's status. Measurements are defined in the [Core metrics contract](../../contracts/agents-api/core-metrics.md); the Process section's CPU and resident memory are a [requested extension](core-process-metrics-requirements.md) and show as missing until Core reports them | Summary figures are cumulative per Session and are not billing records. Sessions without reported usage count toward coverage but not toward token sums, and the console shows missing values as missing, never as zero. The administrator audit -log (`GET /core/v1/audit-log`) is not consumed; System shows the sandbox -deployment only. +log (`GET /core/v1/audit-log`) is not consumed; System shows the installation and +the sandbox deployment. ## Sandbox administration | Operation | Route | Console use | | --- | --- | --- | -| Deployment | `GET`, `POST`, `PUT /core/v1/sandbox/deployment` | Read the provider, Core origin, maintenance state, installation ID and specification; initialize the deployment with `resources` and the Docker or microsandbox `runtime` release, or with the E2B account and no `resources` (Core adopts the template build's CPU and memory); change its settings with the expected generation. E2B's `e2b.template_build` (status, CPU, memory, disk) shows on System, the Sandbox backend summary and Sandbox metrics, and sizes each sandbox when `specification.resources` is missing; microsandbox's `suspension` (idle and retention seconds) shows on System and the Nodes summary | +| Deployment | `GET`, `POST`, `PUT /core/v1/sandbox/deployment` | Read the provider, the read-only `core_url` (config.json's `public_url`, shown in the setup review and never sent), maintenance state, installation ID and specification; a 409 `sandbox_configuration_error` (such as E2B without an HTTPS `public_url`) shows Core's message in the setup wizard, with the installation's config file and apply command, and leaves nothing to confirm; initialize the deployment with `resources` and the Docker or microsandbox `runtime` release, or with the E2B account and no `resources` (Core adopts the template build's CPU and memory); change its settings with the expected generation. E2B's `e2b.template_build` (status, CPU, memory, disk) shows on System, the Sandbox backend summary and Sandbox metrics, and sizes each sandbox when `specification.resources` is missing; microsandbox's `suspension` (idle and retention seconds) shows on System and the Nodes summary | | Maintenance | `PATCH /core/v1/sandbox/deployment/maintenance` | Enter or leave maintenance to change the provider | | Nodes | `GET /core/v1/sandbox/nodes` | Nodes page; fleet on Overview; node capacity on Sandbox metrics. An online node's `diagnostic` (`docker_unavailable`, `docker_limits_unsupported`, `runtime_image_unavailable`, `kvm_unavailable`, `microsandbox_artifacts_unavailable`, `capacity_insufficient`, `provider_unavailable`; any other value reads as `provider_unavailable`) marks it degraded and names the reason and fix in the help tip beside its status on each of these and on the node's page | | Node detail | `GET /core/v1/sandbox/nodes/{node_id}?range=1h\|6h\|24h` | Sandbox metrics node dialog: the host's CPU busy share and memory from its last heartbeat, and their history over the page's range | From 76948f5b8f83cbafe2505b9547e0957f1b7eda6c Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:03:57 +0000 Subject: [PATCH 08/19] Match the Web to the backend's installation and node address client The real client reports each node's enrolled core_url and requires api_base_url to be public_url followed by /v1, so a loopback install has a local API base URL and local_only set. The how-to-call card then shows the samples under a note that only the Core machine reaches them, and System marks the API base URL the same way. Test node fixtures carry core_url. The e2e fixture uses one UUID installation ID, derives api_base_url and the stale-node count from the nodes' core_url, and the add-node E2B test no longer fills the removed address field. --- apps/web/DESIGN.md | 5 +++-- apps/web/e2e/console.ts | 5 +++-- apps/web/e2e/fixture-console.mjs | 19 +++++++++++++------ apps/web/e2e/nodes.spec.ts | 3 +-- apps/web/src/features/api-keys/HowToCall.tsx | 4 +++- .../src/features/overview/test-fixtures.ts | 2 +- apps/web/src/features/system/SystemPage.tsx | 5 ++++- apps/web/src/i18n/locales/en/keys.ts | 2 +- apps/web/src/i18n/locales/en/system.ts | 2 +- apps/web/src/i18n/locales/zh-CN/keys.ts | 2 +- apps/web/src/i18n/locales/zh-CN/system.ts | 2 +- docs/web/protocol-coverage.md | 2 +- 12 files changed, 33 insertions(+), 20 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index dffc3d1bb..361d322c5 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -508,8 +508,9 @@ request runs. copyable samples, each a Margin Gray block with a Hairline and its label and copy button in a header row: `OPENAI_BASE_URL` (the installation's API base URL) and `OPENAI_API_KEY` (the new key), a curl request and a Python example with the - pinned SDK. One Graphite line says execution needs a model. Without a public API - address the card only says the API is reachable on the Core machine alone. + pinned SDK. One Graphite line says execution needs a model. When the public + address is loopback, a note above the samples says the API is reachable only on + the Core machine; without an API address only that note shows. ### Navigation Sidebar groups Monitor, Resources and Platform with 12px Graphite group labels; diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index 7e56fa0cf..d4253641a 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -9,8 +9,9 @@ export const FIXTURE_CORE_KEY = "fixture-core-key-3f9a2c71"; /** * Fixture state options: `fresh` is a new install (no project, Session or Runtime), * `sandbox` the sandbox deployment, `nodes: "none"` a deployment no node has joined, and - * `installation` how config.json's public_url is set: "public" (HTTPS, the default), "local" (loopback, so the API has no - * public base URL and E2B is rejected) or "stale" (public, with a node enrolled with an earlier address). + * `installation` how config.json's public_url is set: "public" (HTTPS, the default), "local" + * (loopback: only the Core machine reaches the API, and E2B is rejected) or "stale" (public, + * with a node enrolled with an earlier address). */ export interface FixtureOptions { fresh?: boolean; sandbox?: "configured" | "none" | "e2b"; nodes?: "none"; installation?: "public" | "local" | "stale" } diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 2008d57b1..7432fcc62 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -32,16 +32,21 @@ const manifest = { platform: "linux/amd64", source_commit: release.source_commit */ const PUBLIC_URL = "https://core.example.com"; const LOCAL_URL = "http://127.0.0.1:8091"; +/** The address a node enrolled with before public_url last changed. */ +const OLD_URL = "https://core-old.example.com"; const publicUrl = () => (state.installation === "local" ? LOCAL_URL : PUBLIC_URL); +/** Core reports one installation ID, a canonical UUID, in the installation and the deployment. */ +const INSTALLATION_ID = "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f"; /** GET /core/v1/installation: the address, the startup settings from config.json and what is bound to the address. */ function installation() { const local = state.installation === "local"; const setting = (key, value, fallback, restarts, extra = {}) => ({ key, value, default: fallback, changeable: true, sensitive: false, restarts, ...extra }); const secret = (key, restarts) => ({ key, value: null, default: null, changeable: true, sensitive: true, restarts, configured: true }); - const nodes = state.deployment?.provider && state.deployment.provider !== "e2b" ? state.nodes.length : 0; + const nodes = state.deployment?.provider && state.deployment.provider !== "e2b" ? state.nodes : []; return { - object: "core.installation", installation_id: "7f3c2a90-fixture", public_url: publicUrl(), api_base_url: local ? null : `${PUBLIC_URL}/v1`, + // As Core: api_base_url is always public_url followed by /v1; local_only marks a loopback public_url. + object: "core.installation", installation_id: INSTALLATION_ID, public_url: publicUrl(), api_base_url: `${publicUrl()}/v1`, local_only: local, source_commit: release.source_commit, configuration: { path: "/opt/parsar/config.json", apply_command: "sudo parsar apply", applied_at: "2026-09-24T09:30:00Z", @@ -55,12 +60,12 @@ function installation() { secret("database_url", ["core"]), ], }, - address_bindings: { nodes, nodes_on_other_address: state.installation === "stale" ? Math.min(1, nodes) : 0, hosted_sandboxes: 0, self_hosted_executors: 0 }, + address_bindings: { nodes: nodes.length, nodes_on_other_address: nodes.filter((node) => node.core_url !== publicUrl()).length, hosted_sandboxes: 0, self_hosted_executors: 0 }, }; } function configuredDeployment() { - return { installation_id: "7f3c2a90-fixture", provider: "docker", core_url: publicUrl(), maintenance: false, owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 }, runtime: release }, specification_digest: "fixture", suspension: null }; + return { installation_id: INSTALLATION_ID, provider: "docker", core_url: publicUrl(), maintenance: false, owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 }, runtime: release }, specification_digest: "fixture", suspension: null }; } /** The E2B template build as Core read it when the selection was saved. */ const templateBuild = { status: "ready", resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 10240 } }; @@ -92,6 +97,8 @@ function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "d deployment: null, }; state.deployment = sandbox === "none" ? null : sandbox === "e2b" ? e2bDeployment() : configuredDeployment(); + // Each node reports the address it enrolled with; in "stale" mode the first one enrolled before public_url changed. + state.nodes.forEach((node, index) => { node.core_url = address === "stale" && index === 0 ? OLD_URL : publicUrl(); }); if (sandbox === "e2b") Object.assign(state, { nodes: [], allocations: [] }); } reset(); @@ -296,7 +303,7 @@ async function sandboxRoute(request, response, path) { return send(response, initialize ? 201 : 200, state.deployment); } if (path === "/deployment") { - return send(response, 200, state.deployment ?? { installation_id: "7f3c2a90-fixture", provider: "", core_url: publicUrl(), maintenance: false, owner_epoch: 3, generation: 0, mode: "", resources: { allocations: 0, pending: 0 }, suspension: null }); + return send(response, 200, state.deployment ?? { installation_id: INSTALLATION_ID, provider: "", core_url: publicUrl(), maintenance: false, owner_epoch: 3, generation: 0, mode: "", resources: { allocations: 0, pending: 0 }, suspension: null }); } if (path === "/nodes") return send(response, 200, { data: state.nodes }); if (path === "/enrollment-tokens" && request.method === "POST") { @@ -379,7 +386,7 @@ async function executorCredentialRoute(request, response, projectId, environment */ function registeredNode(nodeId) { const limits = state.enrollmentLimits ?? { max_active: 1, max_retained: 1 }; - return { id: nodeId, name: nodeId, provider: state.deployment?.provider ?? "docker", online: false, provider_ready: false, cpu_count: null, available_memory_bytes: null, available_disk_bytes: null, running: 0, snapshots: 0, last_seen_at: null, ...limits, active: 0, reserved: 0, retained: 0, cleanup_pending: 0, created_at: new Date().toISOString() }; + return { id: nodeId, name: nodeId, provider: state.deployment?.provider ?? "docker", core_url: publicUrl(), online: false, provider_ready: false, cpu_count: null, available_memory_bytes: null, available_disk_bytes: null, running: 0, snapshots: 0, last_seen_at: null, ...limits, active: 0, reserved: 0, retained: 0, cleanup_pending: 0, created_at: new Date().toISOString() }; } /** Test controls: reset state, inject one failure, register or change a node, and read what the browser sent. */ diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index b102f4cb3..234e73263 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -76,7 +76,7 @@ test("adds a node: host requirements, a countdown, the same command after closin await page.clock.fastForward("01:01"); const problem = add.getByRole("alert"); await expect(problem).toContainText("Not connected yet"); - await expect(problem).toContainText("journalctl --user -u parsar-node-7f3c2a90-fixture.service"); + await expect(problem).toContainText("journalctl --user -u parsar-node-7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f.service"); // Connected, it reports why Docker isn't ready; once ready, the node is connected. await setNode(request, { id: "node-new", online: true, diagnostic: "docker_limits_unsupported" }); await expect(problem).toContainText("Docker limits unsupported"); @@ -124,7 +124,6 @@ test("saves E2B without opening Add node, as it has no machines", async ({ page, await page.getByLabel("E2B API key").fill("fixture-private-key"); await page.getByLabel("Template build").fill("template:94be54a1-138c-4f30-bc87-b13686272dbe"); await page.getByRole("button", { name: "Next" }).click(); - await page.getByLabel("Core address").fill("https://core.example.com"); await page.getByRole("button", { name: "Save configuration" }).click(); await expect(page.getByRole("heading", { name: "Sandbox backend", level: 1 })).toBeVisible(); await expect(page.getByRole("dialog")).toHaveCount(0); diff --git a/apps/web/src/features/api-keys/HowToCall.tsx b/apps/web/src/features/api-keys/HowToCall.tsx index 8ba7168a4..48d47ff9c 100644 --- a/apps/web/src/features/api-keys/HowToCall.tsx +++ b/apps/web/src/features/api-keys/HowToCall.tsx @@ -34,7 +34,8 @@ export function callSamples(apiBaseUrl: string, apiKey: string) { /** * How to call Core with a key that was just issued. The key comes from the * issuing flow's memory and leaves with it; the console never sends these - * requests. Without a public API address it says so instead of guessing one. + * requests. When Core is reachable only on its own machine it says so, and + * without an API address it shows nothing to guess from. */ export function HowToCall({ apiKey }: { apiKey: string }) { const { t } = useTranslation("keys"); @@ -53,6 +54,7 @@ export function HowToCall({ apiKey }: { apiKey: string }) { } else { const samples = callSamples(base, apiKey); body = <> + {installation.data.local_only ?

{t("howToCall.localOnly")}

: null} diff --git a/apps/web/src/features/overview/test-fixtures.ts b/apps/web/src/features/overview/test-fixtures.ts index 161a4d61d..1b4b13270 100644 --- a/apps/web/src/features/overview/test-fixtures.ts +++ b/apps/web/src/features/overview/test-fixtures.ts @@ -39,7 +39,7 @@ export function node(id: string, overrides: Partial = {}): SandboxN cpu_count: 8, available_memory_bytes: 1024, available_disk_bytes: 2048, running: 1, snapshots: 0, last_seen_at: "2026-09-24T00:00:00Z", max_active: 4, max_retained: 8, active: 1, reserved: 0, retained: 0, cleanup_pending: 0, - created_at: "2026-09-01T00:00:00Z", + created_at: "2026-09-01T00:00:00Z", core_url: "https://core.example", ...overrides, }; } diff --git a/apps/web/src/features/system/SystemPage.tsx b/apps/web/src/features/system/SystemPage.tsx index 878fb9399..0174e3fb0 100644 --- a/apps/web/src/features/system/SystemPage.tsx +++ b/apps/web/src/features/system/SystemPage.tsx @@ -109,7 +109,10 @@ export function SystemPage() {
{about.public_url ? {about.public_url} : {t("installation.notSet")}} - {about.api_base_url ? : {t("installation.localOnly")}} + + {about.api_base_url ? : {t("installation.notSet")}} + {about.local_only ? {t("installation.localOnly")} : null} + {about.installation_id ? : {t("installation.unknown")}} {about.source_commit ? {about.source_commit.slice(0, 12)} : {t("installation.unknown")}}
diff --git a/apps/web/src/i18n/locales/en/keys.ts b/apps/web/src/i18n/locales/en/keys.ts index f0a663bca..b9f101db3 100644 --- a/apps/web/src/i18n/locales/en/keys.ts +++ b/apps/web/src/i18n/locales/en/keys.ts @@ -99,7 +99,7 @@ export const keys = { copy: "Copy {{label}}", loading: "Reading the API address…", failed: "The API address couldn't be read.", - localOnly: "Core's API is only reachable on this machine. To call it from elsewhere, set public_url in config.json and apply it.", + localOnly: "Core's API is only reachable on the Core machine. To call it from elsewhere, set public_url in config.json and apply it.", model: "Running an Agent needs a model: set a default model on the System page, or put the model fields in each request.", }, detail: { diff --git a/apps/web/src/i18n/locales/en/system.ts b/apps/web/src/i18n/locales/en/system.ts index 0af76c477..e944aafa0 100644 --- a/apps/web/src/i18n/locales/en/system.ts +++ b/apps/web/src/i18n/locales/en/system.ts @@ -10,7 +10,7 @@ export const system = { apiBaseUrl: "API base URL", apiBaseUrlHelp: "Applications use it as OPENAI_BASE_URL, with a Project API key as OPENAI_API_KEY.", copyApiBaseUrl: "Copy API base URL", - localOnly: "Only reachable on this machine", + localOnly: "Only reachable on the Core machine", notSet: "Not set", id: "Installation ID", sourceCommit: "Source commit", diff --git a/apps/web/src/i18n/locales/zh-CN/keys.ts b/apps/web/src/i18n/locales/zh-CN/keys.ts index 8b7040c56..4b56cfd33 100644 --- a/apps/web/src/i18n/locales/zh-CN/keys.ts +++ b/apps/web/src/i18n/locales/zh-CN/keys.ts @@ -101,7 +101,7 @@ export const keys: TranslationShape = { copy: "复制 {{label}}", loading: "正在读取 API 地址…", failed: "无法读取 API 地址。", - localOnly: "Core 的 API 只能在本机访问。要从其他机器调用,请在 config.json 中设置 public_url 并应用。", + localOnly: "Core 的 API 只能在 Core 所在的机器上访问。要从其他机器调用,请在 config.json 中设置 public_url 并应用。", model: "运行 Agent 需要模型:在“系统”页面设置默认模型,或在每个请求里填写模型字段。", }, detail: { diff --git a/apps/web/src/i18n/locales/zh-CN/system.ts b/apps/web/src/i18n/locales/zh-CN/system.ts index 60c305e47..bc7c9aa8b 100644 --- a/apps/web/src/i18n/locales/zh-CN/system.ts +++ b/apps/web/src/i18n/locales/zh-CN/system.ts @@ -12,7 +12,7 @@ export const system: TranslationShape = { apiBaseUrl: "API 基础地址", apiBaseUrlHelp: "应用把它设为 OPENAI_BASE_URL,并把项目 API key 设为 OPENAI_API_KEY。", copyApiBaseUrl: "复制 API 基础地址", - localOnly: "只能在本机访问", + localOnly: "只能在 Core 所在的机器上访问", notSet: "未设置", id: "安装 ID", sourceCommit: "源码提交", diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index e49b63f6c..de10a45ba 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -18,7 +18,7 @@ the administrator API are defined by the [administrator API contract](../../cont | Console server | `/console/auth`, `/console/auth/{login,logout}`, `/console/config` | Core key at sign-in, then the console session cookie | Sign-in with the Core key and sign-out; the node installer (`node_installer`, `node_installer_sha256`) | | Administrator API | `/core/v1/**` outside `/core/v1/sandbox` | Core key, added by the console server | Projects, keys, resource reads and deletion, executor credentials, provenance, summaries, Core metrics, the installation | | Sandbox administration | `/core/v1/sandbox/**` | Core key, added by the console server | Nodes page; fleet and capacity figures on Overview and Sandbox metrics; Runtime observations of every project | -| Agents API | `/v1/**` | Project API key | Not used. Wherever a new key is shown, the console gives `OPENAI_BASE_URL` (the installation's `api_base_url`) and `OPENAI_API_KEY` (the new key) with `curl` and Python examples for `/v1/agents`, and sends neither; without an `api_base_url` it says the API is reachable only on the Core machine | +| Agents API | `/v1/**` | Project API key | Not used. Wherever a new key is shown, the console gives `OPENAI_BASE_URL` (the installation's `api_base_url`) and `OPENAI_API_KEY` (the new key) with `curl` and Python examples for `/v1/agents`, and sends neither; when the installation is `local_only`, or has no `api_base_url`, it says the API is reachable only on the Core machine | Browser requests are same-origin and carry only the console session. The browser sends the Core key once, in the sign-in request body, and never stores it; it never From 11c16456e96978b7ccf0e3b5ab93dace2938e7a4 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:24:41 +0000 Subject: [PATCH 09/19] Name every model provider source on the how-to-call card The card no longer points at a System page setting that ships separately: a model provider comes with each request, from the saved Agent or from the deployment default. --- apps/web/DESIGN.md | 3 ++- apps/web/src/i18n/locales/en/keys.ts | 2 +- apps/web/src/i18n/locales/zh-CN/keys.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 361d322c5..a1f8f41ec 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -508,7 +508,8 @@ request runs. copyable samples, each a Margin Gray block with a Hairline and its label and copy button in a header row: `OPENAI_BASE_URL` (the installation's API base URL) and `OPENAI_API_KEY` (the new key), a curl request and a Python example with the - pinned SDK. One Graphite line says execution needs a model. When the public + pinned SDK. One Graphite line says running an Agent needs a model provider: in + each request, saved on the Agent, or the deployment default. When the public address is loopback, a note above the samples says the API is reachable only on the Core machine; without an API address only that note shows. diff --git a/apps/web/src/i18n/locales/en/keys.ts b/apps/web/src/i18n/locales/en/keys.ts index b9f101db3..0e4599d59 100644 --- a/apps/web/src/i18n/locales/en/keys.ts +++ b/apps/web/src/i18n/locales/en/keys.ts @@ -100,7 +100,7 @@ export const keys = { loading: "Reading the API address…", failed: "The API address couldn't be read.", localOnly: "Core's API is only reachable on the Core machine. To call it from elsewhere, set public_url in config.json and apply it.", - model: "Running an Agent needs a model: set a default model on the System page, or put the model fields in each request.", + model: "Running an Agent needs a model provider: pass one in each request, save one on the Agent, or rely on the deployment default.", }, detail: { back: "Back", diff --git a/apps/web/src/i18n/locales/zh-CN/keys.ts b/apps/web/src/i18n/locales/zh-CN/keys.ts index 4b56cfd33..4c9c25eea 100644 --- a/apps/web/src/i18n/locales/zh-CN/keys.ts +++ b/apps/web/src/i18n/locales/zh-CN/keys.ts @@ -102,7 +102,7 @@ export const keys: TranslationShape = { loading: "正在读取 API 地址…", failed: "无法读取 API 地址。", localOnly: "Core 的 API 只能在 Core 所在的机器上访问。要从其他机器调用,请在 config.json 中设置 public_url 并应用。", - model: "运行 Agent 需要模型:在“系统”页面设置默认模型,或在每个请求里填写模型字段。", + model: "运行 Agent 需要模型服务:在每个请求里传入、保存在 Agent 上,或使用部署默认值。", }, detail: { back: "返回", From 39400cd33ab4baf75d349a1aa8e5e03abb865da7 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:26:14 +0000 Subject: [PATCH 10/19] Name the nodes on an old Core address from the node list The Nodes warning now comes from the node snapshot the page already reads: a node whose core_url differs from the deployment's core_url (config.json's public_url). It updates with every refresh, removal and enrollment, names the nodes and says to remove and add them again, as a status rather than an alert. Refreshing the page also re-reads the installation the wizard uses. --- apps/web/DESIGN.md | 2 +- apps/web/e2e/public-url.spec.ts | 10 ++++++++-- .../web/src/features/sandbox/SandboxManagerView.tsx | 13 +++++++++---- apps/web/src/lib/locale-strings.ts | 4 ++-- docs/web/protocol-coverage.md | 4 ++-- 5 files changed, 22 insertions(+), 11 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index a1f8f41ec..a13973584 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -576,7 +576,7 @@ reported in an error toast with the reason. Only when a page or section has noth does an error state take the place of its content; errors inside a dialog or a form stay beside what they concern. Coverage notes (Margin Gray, Hairline, 12px corners, 12.5px Graphite) state bounded aggregation. A standing warning that needs action, -such as nodes still bound to an old Core address on the Nodes page, is an +such as the Nodes page naming nodes still bound to an old Core address, is an amber-tinted line at the top of the page body. Partial-data chips are amber-tinted pills with a help tip. Safety notices (a key shown once, a destructive consequence) stay visible in body text. diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts index e0100bb1c..25cb75903 100644 --- a/apps/web/e2e/public-url.spec.ts +++ b/apps/web/e2e/public-url.spec.ts @@ -50,7 +50,13 @@ test("lists the startup settings on System with where to change them", async ({ await expect(settings.getByRole("row", { name: /^public_url/ })).toContainText("core, web"); }); -test("warns on Nodes about nodes bound to an old Core address", async ({ page, request }) => { +test("warns on Nodes about a node bound to an old Core address until it is removed", async ({ page, request }) => { await openConsole(page, request, "nodes", { installation: "stale" }); - await expect(page.getByRole("alert").filter({ hasText: "old Core address" })).toHaveText("1 node is still bound to an old Core address. Add it again."); + const warning = page.getByRole("status").filter({ hasText: "old Core address" }); + await expect(warning).toHaveText("core-01 is still bound to an old Core address. Remove it and add it again."); + await page.getByRole("button", { name: "Remove core-01" }).click(); + await page.getByRole("dialog", { name: "Remove node" }).getByRole("button", { name: "Confirm removal" }).click(); + await page.getByRole("button", { name: "Refresh sandbox state" }).click(); + await expect(page.getByRole("table", { name: "Sandbox nodes" })).not.toContainText("core-01"); + await expect(warning).toHaveCount(0); }); diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index a10a0d609..2d9c99a5d 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -60,8 +60,6 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const client = sandboxAdmin; const queryClient = useQueryClient(); const query = useQuery(sandboxSnapshotQuery); - // Nodes enrolled with an address other than the current public_url must be added again. - const staleNodes = useQuery(installationQuery).data?.address_bindings.nodes_on_other_address ?? 0; const snapshot: SandboxSnapshot | null = query.data ?? null; const loading = query.isFetching; // As before a reload clears the last error, a running read hides it. @@ -87,14 +85,17 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const refresh = useCallback(() => { // Each refresh starts a new read (cancelling one in flight) and resets the forms, as a reload did. setRevision((value) => value + 1); + // The wizard reads the address and config file from the installation. + void queryClient.invalidateQueries({ queryKey: installationQuery.queryKey }); // Settles when the read does; the enrollment dialog waits for it before calling a command expired. return refetch(); - }, [refetch]); + }, [queryClient, refetch]); const toast = useToast(); // A refresh the administrator asks for reports its failure even while an earlier one is still unconfirmed; // the enrollment dialog's own repeated refreshes do not. const refreshByUser = () => { setRevision((value) => value + 1); + void queryClient.invalidateQueries({ queryKey: installationQuery.queryKey }); void refetch().then((result) => { if (result.isError && result.data) toast.show(t("Refresh failed; showing the last loaded state."), { tone: "error", detail: sandboxRequestError(result.error, locale), key: "sandbox-read" }); }); @@ -184,6 +185,8 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig : !snapshot ? (query.isError ? "unavailable" : "wait") : hostedNodes && fresh && !snapshot.deployment.maintenance ? "ready" : "unavailable"; useConsoleIntent("add-node", addNodeReadiness, () => setAdding(true)); + // A node enrolled with another address than Core's current one (config.json's public_url) gets no new sandboxes until it is added again. + const staleNodes = hostedNodes && snapshot ? nodes.filter((node) => node.core_url !== snapshot.deployment.core_url).map((node) => node.name || node.id) : []; const selected = params.id ? nodes.find((node) => node.id === params.id) : undefined; const refreshButton = ; const readFailure = error !== null ? sandboxRequestError(error, locale) : null; @@ -262,7 +265,9 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig {snapshot && !snapshot.deployment.provider ? : null} {snapshot?.deployment.provider ? <> {snapshot.deployment.maintenance ?

{t("Maintenance is enabled. New sandbox placement is paused.")}

: null} - {hostedNodes && staleNodes > 0 ?

{staleNodes === 1 ? t("1 node is still bound to an old Core address. Add it again.") : t("{{count}} nodes are still bound to an old Core address. Add them again.", { count: staleNodes })}

: null} + {staleNodes.length ?

{staleNodes.length === 1 + ? t("{{name}} is still bound to an old Core address. Remove it and add it again.", { name: staleNodes[0] }) + : t("{{count}} nodes are still bound to an old Core address: {{names}}. Remove them and add them again.", { count: staleNodes.length, names: staleNodes.join(", ") })}

: null} {hostedNodes ?
{nodes.length diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index 96f2ce617..e5aaa3f36 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -299,7 +299,7 @@ export const chinese = { "Config file": "配置文件", "Then run": "然后运行", "Copy path": "复制路径", - "1 node is still bound to an old Core address. Add it again.": "有 1 个节点仍绑定在旧的 Core 地址上,需要重新添加。", - "{{count}} nodes are still bound to an old Core address. Add them again.": "有 {{count}} 个节点仍绑定在旧的 Core 地址上,需要重新添加。", + "{{name}} is still bound to an old Core address. Remove it and add it again.": "{{name}} 仍绑定在旧的 Core 地址上,需要移除后重新添加。", + "{{count}} nodes are still bound to an old Core address: {{names}}. Remove them and add them again.": "有 {{count}} 个节点仍绑定在旧的 Core 地址上:{{names}}。需要移除后重新添加。", } as const; export type MessageKey = keyof typeof chinese; diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index de10a45ba..e988a4b08 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -106,7 +106,7 @@ allows. | Resource owners | `GET /core/v1/projects/{project_id}/resource-owners` | The Creator column of every resource list and the creator fact of detail pages, in batches of up to 100 IDs. An asset an administrator copied in an earlier release shows **Admin copy**; a resource without a record shows **Unknown** | | Write operations | `GET /core/v1/projects/{project_id}/write-operations` | A project's write history, newest first, filtered by key and resource type, 50 per page | | Summary | `GET /core/v1/summary` | Overview (per project), the Agents list (`group_by=agent`), a project's page (per project and `group_by=key`), Agent metrics (to skip idle projects, and usage by creating key since the start of the range), the Projects list (last activity) | -| Installation | `GET /core/v1/installation` | System's Installation facts (`public_url`, `api_base_url`, `installation_id`, `source_commit`) and read-only Startup settings (`configuration.settings` under its `path`, `apply_command` and `applied_at`; a sensitive setting shows only whether it is `configured`); `api_base_url` in the how-to-call samples under a new key; `path` and `apply_command` beside a sandbox configuration Core rejected; `address_bindings.nodes_on_other_address` for the Nodes warning about nodes bound to an old address. A sensitive setting with a value, or an unknown member, fails the read; `configuration: null` shows a note | +| Installation | `GET /core/v1/installation` | System's Installation facts (`public_url`, `api_base_url`, `installation_id`, `source_commit`) and read-only Startup settings (`configuration.settings` under its `path`, `apply_command` and `applied_at`; a sensitive setting shows only whether it is `configured`); `api_base_url` in the how-to-call samples under a new key; `path` and `apply_command` beside a sandbox configuration Core rejected. A sensitive setting with a value, or an unknown member, fails the read; `configuration: null` shows a note | | Core metrics | `GET /core/v1/metrics?range=` | Core metrics page; the Core popover on Overview. A Core without the route (404) is shown as not reporting; the popover then shows only Core's status. Measurements are defined in the [Core metrics contract](../../contracts/agents-api/core-metrics.md); the Process section's CPU and resident memory are a [requested extension](core-process-metrics-requirements.md) and show as missing until Core reports them | Summary figures are cumulative per Session and are not billing records. Sessions @@ -121,7 +121,7 @@ the sandbox deployment. | --- | --- | --- | | Deployment | `GET`, `POST`, `PUT /core/v1/sandbox/deployment` | Read the provider, the read-only `core_url` (config.json's `public_url`, shown in the setup review and never sent), maintenance state, installation ID and specification; a 409 `sandbox_configuration_error` (such as E2B without an HTTPS `public_url`) shows Core's message in the setup wizard, with the installation's config file and apply command, and leaves nothing to confirm; initialize the deployment with `resources` and the Docker or microsandbox `runtime` release, or with the E2B account and no `resources` (Core adopts the template build's CPU and memory); change its settings with the expected generation. E2B's `e2b.template_build` (status, CPU, memory, disk) shows on System, the Sandbox backend summary and Sandbox metrics, and sizes each sandbox when `specification.resources` is missing; microsandbox's `suspension` (idle and retention seconds) shows on System and the Nodes summary | | Maintenance | `PATCH /core/v1/sandbox/deployment/maintenance` | Enter or leave maintenance to change the provider | -| Nodes | `GET /core/v1/sandbox/nodes` | Nodes page; fleet on Overview; node capacity on Sandbox metrics. An online node's `diagnostic` (`docker_unavailable`, `docker_limits_unsupported`, `runtime_image_unavailable`, `kvm_unavailable`, `microsandbox_artifacts_unavailable`, `capacity_insufficient`, `provider_unavailable`; any other value reads as `provider_unavailable`) marks it degraded and names the reason and fix in the help tip beside its status on each of these and on the node's page | +| Nodes | `GET /core/v1/sandbox/nodes` | Nodes page; fleet on Overview; node capacity on Sandbox metrics. An online node's `diagnostic` (`docker_unavailable`, `docker_limits_unsupported`, `runtime_image_unavailable`, `kvm_unavailable`, `microsandbox_artifacts_unavailable`, `capacity_insufficient`, `provider_unavailable`; any other value reads as `provider_unavailable`) marks it degraded and names the reason and fix in the help tip beside its status on each of these and on the node's page. A node whose `core_url` (the address it enrolled with) differs from the deployment's `core_url` is named on the Nodes page as bound to an old address, to be removed and added again | | Node detail | `GET /core/v1/sandbox/nodes/{node_id}?range=1h\|6h\|24h` | Sandbox metrics node dialog: the host's CPU busy share and memory from its last heartbeat, and their history over the page's range | | Allocations | `GET /core/v1/sandbox/nodes/{node_id}/allocations` | Nodes page; Sandbox metrics. Under microsandbox, a node's page shows from `compute_phase_changed_at` how long each allocation has been in its compute phase and, while suspended, about when Core reclaims it (that time plus the deployment's `suspension.retention_seconds`); a null time shows a dash | | Enrollment | `POST /core/v1/sandbox/enrollment-tokens` | **Add node**: the administrator sets the node's sandbox limits (`max_active`; `max_retained` only for microsandbox, equal to `max_active` for Docker) before Core issues a single-use token inside a command that verifies the installer checksum | From d8b620e49094cb661185696b44922213b0afb2a8 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:27:45 +0000 Subject: [PATCH 11/19] Export the how-to-call variables together and report failed copies The Shell sample exports OPENAI_BASE_URL and OPENAI_API_KEY together, so a shell that already exports one of them cannot send the new key to another base URL. Each sample's copy button reports a refused clipboard and selects the sample for a manual copy, as the key's own button does. Without a public address the card says to set public_url, and a skeleton replaces the loading line. --- apps/web/DESIGN.md | 15 +++++--- apps/web/e2e/projects.spec.ts | 2 +- apps/web/src/features/api-keys/HowToCall.tsx | 38 ++++++++++++++----- .../web/src/features/api-keys/how-to-call.css | 23 +++++++++++ apps/web/src/i18n/locales/en/keys.ts | 6 ++- apps/web/src/i18n/locales/zh-CN/keys.ts | 6 ++- docs/web/protocol-coverage.md | 2 +- 7 files changed, 70 insertions(+), 22 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index a13973584..a7387ff7e 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -506,12 +506,15 @@ request runs. Closing the dialog moves the key into a pending notice card on the page. - **How to call**: wherever a new key is shown, a card under it gives three copyable samples, each a Margin Gray block with a Hairline and its label and copy - button in a header row: `OPENAI_BASE_URL` (the installation's API base URL) and - `OPENAI_API_KEY` (the new key), a curl request and a Python example with the - pinned SDK. One Graphite line says running an Agent needs a model provider: in - each request, saved on the Agent, or the deployment default. When the public - address is loopback, a note above the samples says the API is reachable only on - the Core machine; without an API address only that note shows. + button in a header row: a Shell block exporting `OPENAI_BASE_URL` (the + installation's API base URL) and `OPENAI_API_KEY` (the new key) together, a curl + request and a Python example with the pinned SDK. A copy the clipboard refuses + selects the sample and says so in red underneath. One Graphite line says running + an Agent needs a model provider: in each request, saved on the Agent, or the + deployment default. When the public address is loopback, a note above the + samples says the API is reachable only on the Core machine; without a public + address only a note to set one shows. Before the installation is read, a + skeleton holds the first sample's place. ### Navigation Sidebar groups Monitor, Resources and Platform with 12px Graphite group labels; diff --git a/apps/web/e2e/projects.spec.ts b/apps/web/e2e/projects.spec.ts index b12bde08f..ffd6986da 100644 --- a/apps/web/e2e/projects.spec.ts +++ b/apps/web/e2e/projects.spec.ts @@ -19,7 +19,7 @@ test("creates a project, shows a new key once, revokes it and archives the proje // How to call Core with it: the official SDK's variables, set to the API base URL and this key. const key = await issued.getByLabel("New key ci").inputValue(); const call = issued.getByRole("region", { name: "How to call" }); - await expect(call.getByLabel("Environment", { exact: true })).toHaveText(`OPENAI_BASE_URL=https://core.example.com/v1\nOPENAI_API_KEY=${key}`); + await expect(call.getByLabel("Shell", { exact: true })).toHaveText(`export OPENAI_BASE_URL=https://core.example.com/v1\nexport OPENAI_API_KEY=${key}`); await expect(call.getByLabel("curl", { exact: true })).toContainText('curl "$OPENAI_BASE_URL/agents"'); await expect(call.getByLabel("Python", { exact: true })).toContainText("pip install openai==3.13.0"); await issued.getByRole("button", { name: "I've saved this key" }).click(); diff --git a/apps/web/src/features/api-keys/HowToCall.tsx b/apps/web/src/features/api-keys/HowToCall.tsx index 48d47ff9c..0b6f128d4 100644 --- a/apps/web/src/features/api-keys/HowToCall.tsx +++ b/apps/web/src/features/api-keys/HowToCall.tsx @@ -1,9 +1,10 @@ import { useQuery } from "@tanstack/react-query"; -import { useId } from "react"; +import { Check, Copy } from "lucide-react"; +import { useEffect, useId, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { CopyIdButton } from "../../components/list-ui"; import { installationQuery } from "../../lib/installation"; +import { useCopy } from "./IssuedKey"; import "./how-to-call.css"; /** The official SDK release Core is pinned to (contracts/agents-api/upstream.json). */ @@ -11,11 +12,13 @@ const SDK_PIN = "openai==3.13.0"; /** * What an application needs to call Core with a Project API key: the official - * SDK's environment variables, a raw request and the SDK itself. + * SDK's environment variables, a raw request and the SDK itself. Both + * variables are exported together, so a shell that already exports one of + * them never sends the new key to another base URL. */ export function callSamples(apiBaseUrl: string, apiKey: string) { return { - env: `OPENAI_BASE_URL=${apiBaseUrl}\nOPENAI_API_KEY=${apiKey}`, + shell: `export OPENAI_BASE_URL=${apiBaseUrl}\nexport OPENAI_API_KEY=${apiKey}`, curl: [ 'curl "$OPENAI_BASE_URL/agents" \\', ' -H "Authorization: Bearer $OPENAI_API_KEY" \\', @@ -35,7 +38,7 @@ export function callSamples(apiBaseUrl: string, apiKey: string) { * How to call Core with a key that was just issued. The key comes from the * issuing flow's memory and leaves with it; the console never sends these * requests. When Core is reachable only on its own machine it says so, and - * without an API address it shows nothing to guess from. + * without a public address it says to set one instead of guessing. */ export function HowToCall({ apiKey }: { apiKey: string }) { const { t } = useTranslation("keys"); @@ -48,14 +51,15 @@ export function HowToCall({ apiKey }: { apiKey: string }) { if (installation.data === undefined) { body = installation.isError && !installation.isFetching ?

{t("howToCall.failed")}

- :

{t("howToCall.loading")}

; + // The first sample's place, as the console's other first reads hold theirs. + :
; } else if (base === null) { - body =

{t("howToCall.localOnly")}

; + body =

{t("howToCall.noAddress")}

; } else { const samples = callSamples(base, apiKey); body = <> {installation.data.local_only ?

{t("howToCall.localOnly")}

: null} - +

{t("howToCall.model")}

@@ -69,15 +73,29 @@ export function HowToCall({ apiKey }: { apiKey: string }) { ); } +/** A labelled sample with a copy button; when the clipboard refuses, the sample is selected to copy by hand. */ function CodeSample({ label, value }: { label: string; value: string }) { const { t } = useTranslation("keys"); + const code = useRef(null); + const { state, copy } = useCopy(value); + useEffect(() => { + if (state !== "failed" || !code.current) return; + const range = document.createRange(); + range.selectNodeContents(code.current); + window.getSelection()?.removeAllRanges(); + window.getSelection()?.addRange(range); + }, [state]); + const name = state === "copied" ? t("issued.copied") : t("howToCall.copy", { label }); return (
{label} - +
-
{value}
+
{value}
+ {state === "failed" ?

{t("howToCall.copyFailed")}

: null}
); } diff --git a/apps/web/src/features/api-keys/how-to-call.css b/apps/web/src/features/api-keys/how-to-call.css index 501219b54..95c780f23 100644 --- a/apps/web/src/features/api-keys/how-to-call.css +++ b/apps/web/src/features/api-keys/how-to-call.css @@ -54,3 +54,26 @@ font-size: 12.5px; line-height: 18px; } + +.how-to-call-error { + margin: 0; + padding: 0 12px 10px; + color: var(--danger); + font-size: 12.5px; + line-height: 18px; +} + +/* Before the installation is read: the first sample's place, two lines tall. */ +.how-to-call-skeleton { + display: grid; + gap: 10px; + padding: 14px 12px; +} + +.how-to-call-skeleton .skeleton-bar:first-child { + width: 62%; +} + +.how-to-call-skeleton .skeleton-bar:last-child { + width: 48%; +} diff --git a/apps/web/src/i18n/locales/en/keys.ts b/apps/web/src/i18n/locales/en/keys.ts index 0e4599d59..06dbe3b5a 100644 --- a/apps/web/src/i18n/locales/en/keys.ts +++ b/apps/web/src/i18n/locales/en/keys.ts @@ -95,11 +95,13 @@ export const keys = { }, howToCall: { title: "How to call", - env: "Environment", + shell: "Shell", copy: "Copy {{label}}", - loading: "Reading the API address…", + copyFailed: "Select the text and copy it manually.", + loading: "Reading the API address", failed: "The API address couldn't be read.", localOnly: "Core's API is only reachable on the Core machine. To call it from elsewhere, set public_url in config.json and apply it.", + noAddress: "Core has no public address yet. Set public_url in config.json and apply it.", model: "Running an Agent needs a model provider: pass one in each request, save one on the Agent, or rely on the deployment default.", }, detail: { diff --git a/apps/web/src/i18n/locales/zh-CN/keys.ts b/apps/web/src/i18n/locales/zh-CN/keys.ts index 4c9c25eea..336436313 100644 --- a/apps/web/src/i18n/locales/zh-CN/keys.ts +++ b/apps/web/src/i18n/locales/zh-CN/keys.ts @@ -97,11 +97,13 @@ export const keys: TranslationShape = { }, howToCall: { title: "如何调用", - env: "环境变量", + shell: "终端", copy: "复制 {{label}}", - loading: "正在读取 API 地址…", + copyFailed: "请选中文本后手动复制。", + loading: "正在读取 API 地址", failed: "无法读取 API 地址。", localOnly: "Core 的 API 只能在 Core 所在的机器上访问。要从其他机器调用,请在 config.json 中设置 public_url 并应用。", + noAddress: "Core 还没有公开地址。请在 config.json 中设置 public_url 并应用。", model: "运行 Agent 需要模型服务:在每个请求里传入、保存在 Agent 上,或使用部署默认值。", }, detail: { diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index e988a4b08..27275dfba 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -18,7 +18,7 @@ the administrator API are defined by the [administrator API contract](../../cont | Console server | `/console/auth`, `/console/auth/{login,logout}`, `/console/config` | Core key at sign-in, then the console session cookie | Sign-in with the Core key and sign-out; the node installer (`node_installer`, `node_installer_sha256`) | | Administrator API | `/core/v1/**` outside `/core/v1/sandbox` | Core key, added by the console server | Projects, keys, resource reads and deletion, executor credentials, provenance, summaries, Core metrics, the installation | | Sandbox administration | `/core/v1/sandbox/**` | Core key, added by the console server | Nodes page; fleet and capacity figures on Overview and Sandbox metrics; Runtime observations of every project | -| Agents API | `/v1/**` | Project API key | Not used. Wherever a new key is shown, the console gives `OPENAI_BASE_URL` (the installation's `api_base_url`) and `OPENAI_API_KEY` (the new key) with `curl` and Python examples for `/v1/agents`, and sends neither; when the installation is `local_only`, or has no `api_base_url`, it says the API is reachable only on the Core machine | +| Agents API | `/v1/**` | Project API key | Not used. Wherever a new key is shown, the console gives shell exports of `OPENAI_BASE_URL` (the installation's `api_base_url`) and `OPENAI_API_KEY` (the new key) with `curl` and Python examples for `/v1/agents`, and sends none of them; when the installation is `local_only` it says the API is reachable only on the Core machine, and without an `api_base_url` it says to set `public_url` | Browser requests are same-origin and carry only the console session. The browser sends the Core key once, in the sign-in request body, and never stores it; it never From e71c1dbc69328ae04ad11fc29e929afcaf17cab1 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:29:30 +0000 Subject: [PATCH 12/19] Say why Save waits and when the address is loopback in the wizard After a save attempt clears the E2B key, the review says to enter it again and links back to the E2B step instead of leaving Save silently disabled. A loopback public_url carries a caution that only the Core machine reaches it. The E2B rejection test also checks that the key is gone from the page. --- apps/web/DESIGN.md | 8 ++++--- apps/web/e2e/public-url.spec.ts | 7 +++++- .../features/sandbox/SandboxSetupWizard.tsx | 13 ++++++++++- .../src/features/sandbox/sandbox-wizard.css | 22 +++++++++++++++++++ apps/web/src/lib/locale-strings.ts | 3 +++ 5 files changed, 48 insertions(+), 5 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index a7387ff7e..7901c2d0f 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -631,9 +631,11 @@ Choices are large cards that advance on a click; short indigo dashes show the progress; pages slide and blur across. The review states where sandboxes run, the size, the Runtime (taken from this console's distribution manifest) and the Core address, read-only: it is config.json's `public_url`, and the console never -asks for it. When Core rejects the configuration for it (E2B without an HTTPS -`public_url`), a red-tinted block under the review keeps Core's message and adds -the config file and apply command as copyable values. +asks for it. A loopback address carries an amber line under it: only the Core +machine reaches it. When Core rejects the configuration for it (E2B without an +HTTPS `public_url`), a red-tinted block under the review keeps Core's message and +adds the config file and apply command as copyable values. A save attempt clears +the E2B key, so the review then says to enter it again, with a link to that step. Advanced settings, one link away, hold the complete form: resources (not for E2B), the Runtime release and the E2B template. A change keeps the saved size and Runtime while the backend stays the same (a saved size outside the presets is diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts index 25cb75903..44b893735 100644 --- a/apps/web/e2e/public-url.spec.ts +++ b/apps/web/e2e/public-url.spec.ts @@ -25,7 +25,8 @@ test("explains an E2B rejection in the wizard, with the file to edit and the com await page.getByLabel("E2B API key").fill("fixture-private-key"); await page.getByLabel("Template build").fill("template:94be54a1-138c-4f30-bc87-b13686272dbe"); await page.getByRole("button", { name: "Next" }).click(); - await expect(page.getByRole("definition").filter({ hasText: "http://127.0.0.1:8091" })).toBeVisible(); + 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"); await page.getByRole("button", { name: "Save configuration" }).click(); const rejection = page.locator(".wizard-rejection"); await expect(rejection).toContainText("E2B sandboxes need an HTTPS public_url."); @@ -34,6 +35,10 @@ test("explains an E2B rejection in the wizard, with the file to edit and the com // Nothing was saved and nothing is uncertain: no dialog, and the wizard stays on its review. await expect(page.getByRole("dialog")).toHaveCount(0); await expect(page.getByRole("heading", { name: "Review and save" })).toBeVisible(); + // The key is gone from the page; saving again asks for it on the E2B step. + expect(await page.content()).not.toContain("fixture-private-key"); + await page.getByRole("button", { name: "Enter the key" }).click(); + await expect(page.getByRole("heading", { name: "Connect E2B" })).toBeVisible(); }); test("lists the startup settings on System with where to change them", async ({ page, request }) => { diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index 2f9a64390..ab4152752 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -228,7 +228,11 @@ export function SandboxSetupWizard({ coreUrl, current, disabled, switching = fal ) : null}
{t("Core address")}{t("The address nodes and sandboxes use to reach Core.")}
-
{address ? {address} : "—"}{t("Set by public_url in config.json")}
+
+ {address ? {address} : "—"} + {t("Set by public_url in config.json")} + {installation.data?.local_only ? {t("Only the Core machine can reach this address: nodes on other machines and E2B sandboxes can't. Set an HTTPS public_url in config.json.")} : null} +
{rejection ? ( @@ -242,6 +246,13 @@ export function SandboxSetupWizard({ coreUrl, current, disabled, switching = fal ) : null} ) : null} + {/* Every save attempt clears the E2B key, so a second one needs it entered again. */} + {provider === "e2b" && !apiKey.trim() ? ( +

+ {t("Enter the E2B key again to save.")} + +

+ ) : null} diff --git a/apps/web/src/features/sandbox/sandbox-wizard.css b/apps/web/src/features/sandbox/sandbox-wizard.css index e57a77858..15988daed 100644 --- a/apps/web/src/features/sandbox/sandbox-wizard.css +++ b/apps/web/src/features/sandbox/sandbox-wizard.css @@ -302,3 +302,25 @@ .wizard-rejection code { font-size: 12.5px; } + +/* A loopback address only the Core machine reaches. */ +.wizard-review-caution { + color: var(--orange); + font-size: 13px; +} + +/* After a save attempt the E2B key is gone; this says so beside the way back to it. */ +.sandbox-wizard .wizard-key-again { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 2px; + margin: 0; + color: var(--ink-2); + font-size: 13px; +} + +.wizard-key-again .wizard-link { + color: var(--accent); +} diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index e5aaa3f36..686da8057 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -299,6 +299,9 @@ export const chinese = { "Config file": "配置文件", "Then run": "然后运行", "Copy path": "复制路径", + "Only the Core machine can reach this address: nodes on other machines and E2B sandboxes can't. Set an HTTPS public_url in config.json.": "只有 Core 所在的机器能访问这个地址,其他机器上的节点和 E2B 沙箱都无法访问。请在 config.json 中设置 HTTPS 的 public_url。", + "Enter the E2B key again to save.": "请重新输入 E2B key 后再保存。", + "Enter the key": "输入 key", "{{name}} is still bound to an old Core address. Remove it and add it again.": "{{name}} 仍绑定在旧的 Core 地址上,需要移除后重新添加。", "{{count}} nodes are still bound to an old Core address: {{names}}. Remove them and add them again.": "有 {{count}} 个节点仍绑定在旧的 Core 地址上:{{names}}。需要移除后重新添加。", } as const; From aed8f9900d01dfdf4bd731ea9ee752107f1c2133 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:30:28 +0000 Subject: [PATCH 13/19] Answer the fixture's address errors as Core does A write that sends core_url gets Core's 400 (invalid_request_error, param core_url, its message), and E2B on a loopback public_url gets ErrSandboxPublicURLUnreachable's message. The installation's binding counts follow the fixture's allocations and executor credentials. --- apps/web/e2e/fixture-console.mjs | 13 +++++++++---- apps/web/e2e/public-url.spec.ts | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 7432fcc62..af7c3c11c 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -60,7 +60,12 @@ function installation() { secret("database_url", ["core"]), ], }, - address_bindings: { nodes: nodes.length, nodes_on_other_address: nodes.filter((node) => node.core_url !== publicUrl()).length, hosted_sandboxes: 0, self_hosted_executors: 0 }, + // As Core counts them: the allocations held on the nodes, and the unrevoked executor credentials. + address_bindings: { + nodes: nodes.length, nodes_on_other_address: nodes.filter((node) => node.core_url !== publicUrl()).length, + hosted_sandboxes: nodes.length ? state.allocations.length : 0, + self_hosted_executors: [...state.executorCredentials.values()].flat().filter((credential) => credential.revoked_at === null).length, + }, }; } @@ -286,11 +291,11 @@ async function sandboxRoute(request, response, path) { if (initialize && state.deployment) return error(response, 409, "The sandbox deployment is already configured.", "sandbox_deployment_conflict"); if (!initialize && !state.deployment) return error(response, 409, "The sandbox deployment is not configured.", "sandbox_deployment_conflict"); // As Core: the address is config.json's public_url and read-only. - if ("core_url" in input) return error(response, 400, "core_url is read-only; set public_url in config.json.", "invalid_sandbox_configuration", "core_url"); + if ("core_url" in input) return error(response, 400, "core_url is derived from the installation public URL (public_url in config.json, AGENTS_API_PUBLIC_URL for Core) and cannot be set here. Remove it.", "invalid_request_error", "core_url"); const e2b = input.provider === "e2b"; if (!e2b && (!input.resources || !input.runtime)) return error(response, 400, "resources and runtime are required.", "invalid_sandbox_configuration"); - // E2B sandboxes reach Core over the internet, which a loopback or HTTP public_url cannot serve. - if (e2b && !publicUrl().startsWith("https://")) return error(response, 409, "E2B sandboxes need an HTTPS public_url. Set public_url in config.json, then run parsar apply.", "sandbox_configuration_error"); + // As Core (ErrSandboxPublicURLUnreachable): E2B sandboxes reach Core over the internet, which a loopback public_url cannot serve. + if (e2b && state.installation === "local") return error(response, 409, "E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback (public_url in config.json, AGENTS_API_PUBLIC_URL for Core), then save again.", "sandbox_configuration_error"); // As Core: E2B may omit resources and adopt its template build's CPU and memory; only microsandbox suspends. const resources = input.resources ?? { cpus: templateBuild.resources.cpus, memory_mib: templateBuild.resources.memory_mib }; state.deployment = { diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts index 44b893735..da4514963 100644 --- a/apps/web/e2e/public-url.spec.ts +++ b/apps/web/e2e/public-url.spec.ts @@ -29,7 +29,7 @@ test("explains an E2B rejection in the wizard, with the file to edit and the com await expect(address).toContainText("Only the Core machine can reach this address"); await page.getByRole("button", { name: "Save configuration" }).click(); const rejection = page.locator(".wizard-rejection"); - await expect(rejection).toContainText("E2B sandboxes need an HTTPS public_url."); + await expect(rejection).toContainText("E2B sandboxes reach Core over the internet."); await expect(rejection).toContainText("/opt/parsar/config.json"); await expect(rejection).toContainText("sudo parsar apply"); // Nothing was saved and nothing is uncertain: no dialog, and the wizard stays on its review. From 09acacdff4b98730c2b441de77d338cff99eb844 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:30:35 +0000 Subject: [PATCH 14/19] Describe the public address without assuming HTTPS on System A local install's public_url is a loopback HTTP address, so the help no longer calls it the one HTTPS address. --- apps/web/src/i18n/locales/en/system.ts | 2 +- apps/web/src/i18n/locales/zh-CN/system.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/i18n/locales/en/system.ts b/apps/web/src/i18n/locales/en/system.ts index e944aafa0..7a1e36df5 100644 --- a/apps/web/src/i18n/locales/en/system.ts +++ b/apps/web/src/i18n/locales/en/system.ts @@ -6,7 +6,7 @@ export const system = { title: "Installation", failed: "The installation could not be read.", publicUrl: "Public address", - publicUrlHelp: "The one HTTPS address apps, nodes and sandboxes use to reach Core: public_url in config.json.", + publicUrlHelp: "The address apps, nodes and sandboxes use to reach Core, set as public_url in config.json.", apiBaseUrl: "API base URL", apiBaseUrlHelp: "Applications use it as OPENAI_BASE_URL, with a Project API key as OPENAI_API_KEY.", copyApiBaseUrl: "Copy API base URL", diff --git a/apps/web/src/i18n/locales/zh-CN/system.ts b/apps/web/src/i18n/locales/zh-CN/system.ts index bc7c9aa8b..513cde74c 100644 --- a/apps/web/src/i18n/locales/zh-CN/system.ts +++ b/apps/web/src/i18n/locales/zh-CN/system.ts @@ -8,7 +8,7 @@ export const system: TranslationShape = { title: "安装", failed: "无法读取安装信息。", publicUrl: "公开地址", - publicUrlHelp: "应用、节点和沙箱访问 Core 使用的唯一 HTTPS 地址,即 config.json 中的 public_url。", + publicUrlHelp: "应用、节点和沙箱访问 Core 使用的地址,即 config.json 中的 public_url。", apiBaseUrl: "API 基础地址", apiBaseUrlHelp: "应用把它设为 OPENAI_BASE_URL,并把项目 API key 设为 OPENAI_API_KEY。", copyApiBaseUrl: "复制 API 基础地址", From 36f340a4e3fdda3e68f94040a962e73377b1035e Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:50:59 +0000 Subject: [PATCH 15/19] Describe the E2B address rule as loopback, not HTTPS Core rejects E2B whenever public_url is loopback, whatever its scheme, so the wizard's caution asks for a public_url other machines can reach, and comments and docs name the loopback rule. --- apps/web/DESIGN.md | 9 +++++---- apps/web/src/features/sandbox/SandboxSetupWizard.tsx | 4 ++-- apps/web/src/lib/locale-strings.ts | 2 +- apps/web/src/lib/sandbox-labels.ts | 2 +- docs/web/protocol-coverage.md | 2 +- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 7901c2d0f..f24d5aef9 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -632,10 +632,11 @@ progress; pages slide and blur across. The review states where sandboxes run, the size, the Runtime (taken from this console's distribution manifest) and the Core address, read-only: it is config.json's `public_url`, and the console never asks for it. A loopback address carries an amber line under it: only the Core -machine reaches it. When Core rejects the configuration for it (E2B without an -HTTPS `public_url`), a red-tinted block under the review keeps Core's message and -adds the config file and apply command as copyable values. A save attempt clears -the E2B key, so the review then says to enter it again, with a link to that step. +machine reaches it. When Core rejects the configuration for it (E2B with a +loopback `public_url`), a red-tinted block under the review keeps Core's message +and adds the config file and apply command as copyable values. A save attempt +clears the E2B key, so the review then says to enter it again, with a link to +that step. Advanced settings, one link away, hold the complete form: resources (not for E2B), the Runtime release and the E2B template. A change keeps the saved size and Runtime while the backend stays the same (a saved size outside the presets is diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index ab4152752..be3b3f1d1 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -92,7 +92,7 @@ export function SandboxSetupWizard({ coreUrl, current, disabled, switching = fal const [template, setTemplate] = useState(current?.e2bTemplate ?? ""); const [runtime, setRuntime] = useState>({}); const [busy, setBusy] = useState(false); - // Core's reason for rejecting the saved configuration, such as an E2B deployment without an HTTPS public_url. + // Core's reason for rejecting the saved configuration, such as E2B with a loopback public_url. const [rejection, setRejection] = useState(null); const installation = useQuery(installationQuery); const address = coreUrl || installation.data?.public_url || null; @@ -231,7 +231,7 @@ export function SandboxSetupWizard({ coreUrl, current, disabled, switching = fal
{address ? {address} : "—"} {t("Set by public_url in config.json")} - {installation.data?.local_only ? {t("Only the Core machine can reach this address: nodes on other machines and E2B sandboxes can't. Set an HTTPS public_url in config.json.")} : null} + {installation.data?.local_only ? {t("Only the Core machine can reach this address: nodes on other machines and E2B sandboxes can't. Set a public_url in config.json that other machines can reach (not loopback).")} : null}
diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index 686da8057..43cf070c5 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -299,7 +299,7 @@ export const chinese = { "Config file": "配置文件", "Then run": "然后运行", "Copy path": "复制路径", - "Only the Core machine can reach this address: nodes on other machines and E2B sandboxes can't. Set an HTTPS public_url in config.json.": "只有 Core 所在的机器能访问这个地址,其他机器上的节点和 E2B 沙箱都无法访问。请在 config.json 中设置 HTTPS 的 public_url。", + "Only the Core machine can reach this address: nodes on other machines and E2B sandboxes can't. Set a public_url in config.json that other machines can reach (not loopback).": "只有 Core 所在的机器能访问这个地址,其他机器上的节点和 E2B 沙箱都无法访问。请在 config.json 里设置一个其他机器能访问的 public_url(不能是回环地址)。", "Enter the E2B key again to save.": "请重新输入 E2B key 后再保存。", "Enter the key": "输入 key", "{{name}} is still bound to an old Core address. Remove it and add it again.": "{{name}} 仍绑定在旧的 Core 地址上,需要移除后重新添加。", diff --git a/apps/web/src/lib/sandbox-labels.ts b/apps/web/src/lib/sandbox-labels.ts index 078f279ba..8359449e9 100644 --- a/apps/web/src/lib/sandbox-labels.ts +++ b/apps/web/src/lib/sandbox-labels.ts @@ -27,7 +27,7 @@ export function sandboxRequestError(error: unknown, locale: Locale): string { /** * Core's own reason when it rejects a deployment configuration it cannot serve, - * such as E2B without an HTTPS public_url; null for any other failure. Nothing + * such as E2B with a loopback public_url; null for any other failure. Nothing * was saved, so the administrator corrects the cause and saves again. */ export function sandboxConfigurationRejection(error: unknown): string | null { diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index 27275dfba..4991ee5b5 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -119,7 +119,7 @@ the sandbox deployment. | Operation | Route | Console use | | --- | --- | --- | -| Deployment | `GET`, `POST`, `PUT /core/v1/sandbox/deployment` | Read the provider, the read-only `core_url` (config.json's `public_url`, shown in the setup review and never sent), maintenance state, installation ID and specification; a 409 `sandbox_configuration_error` (such as E2B without an HTTPS `public_url`) shows Core's message in the setup wizard, with the installation's config file and apply command, and leaves nothing to confirm; initialize the deployment with `resources` and the Docker or microsandbox `runtime` release, or with the E2B account and no `resources` (Core adopts the template build's CPU and memory); change its settings with the expected generation. E2B's `e2b.template_build` (status, CPU, memory, disk) shows on System, the Sandbox backend summary and Sandbox metrics, and sizes each sandbox when `specification.resources` is missing; microsandbox's `suspension` (idle and retention seconds) shows on System and the Nodes summary | +| Deployment | `GET`, `POST`, `PUT /core/v1/sandbox/deployment` | Read the provider, the read-only `core_url` (config.json's `public_url`, shown in the setup review and never sent), maintenance state, installation ID and specification; a 409 `sandbox_configuration_error` (E2B with a loopback `public_url`) shows Core's message in the setup wizard, with the installation's config file and apply command, and leaves nothing to confirm; initialize the deployment with `resources` and the Docker or microsandbox `runtime` release, or with the E2B account and no `resources` (Core adopts the template build's CPU and memory); change its settings with the expected generation. E2B's `e2b.template_build` (status, CPU, memory, disk) shows on System, the Sandbox backend summary and Sandbox metrics, and sizes each sandbox when `specification.resources` is missing; microsandbox's `suspension` (idle and retention seconds) shows on System and the Nodes summary | | Maintenance | `PATCH /core/v1/sandbox/deployment/maintenance` | Enter or leave maintenance to change the provider | | Nodes | `GET /core/v1/sandbox/nodes` | Nodes page; fleet on Overview; node capacity on Sandbox metrics. An online node's `diagnostic` (`docker_unavailable`, `docker_limits_unsupported`, `runtime_image_unavailable`, `kvm_unavailable`, `microsandbox_artifacts_unavailable`, `capacity_insufficient`, `provider_unavailable`; any other value reads as `provider_unavailable`) marks it degraded and names the reason and fix in the help tip beside its status on each of these and on the node's page. A node whose `core_url` (the address it enrolled with) differs from the deployment's `core_url` is named on the Nodes page as bound to an old address, to be removed and added again | | Node detail | `GET /core/v1/sandbox/nodes/{node_id}?range=1h\|6h\|24h` | Sandbox metrics node dialog: the host's CPU busy share and memory from its last heartbeat, and their history over the page's range | From b6aa8dea0482f804ebef207e4124cbfde0e4a247 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:52:10 +0000 Subject: [PATCH 16/19] Hold each System section's place while it loads, announced once Installation and Sandboxes show facts-only placeholders and Startup settings a table placeholder, all silent; one status says the page is loading while either first read is out. TableSkeleton stays silent without a label. --- apps/web/src/components/Skeleton.tsx | 6 +++-- apps/web/src/features/system/SystemPage.tsx | 25 +++++++++++++++++---- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/Skeleton.tsx b/apps/web/src/components/Skeleton.tsx index 3d1f57cb7..2b69e16ef 100644 --- a/apps/web/src/components/Skeleton.tsx +++ b/apps/web/src/components/Skeleton.tsx @@ -3,10 +3,12 @@ const widths = [38, 22, 18, 26, 14, 20, 16]; /** * A first-load placeholder in the shape of the table it stands for. It is shown * only when nothing is cached yet; refreshes keep the real rows on screen. + * Without a label it stays silent, for a page that announces its loading once. */ -export function TableSkeleton({ label, rows = 6, columns = 5 }: { label: string; rows?: number; columns?: number }) { +export function TableSkeleton({ label, rows = 6, columns = 5 }: { label?: string; rows?: number; columns?: number }) { + const announce = label ? { role: "status", "aria-label": label, "aria-busy": true } : { "aria-hidden": true }; return ( -
+
); diff --git a/apps/web/src/features/system/SystemPage.tsx b/apps/web/src/features/system/SystemPage.tsx index 0174e3fb0..dcff96a80 100644 --- a/apps/web/src/features/system/SystemPage.tsx +++ b/apps/web/src/features/system/SystemPage.tsx @@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next"; import { EmptyState, HelpTip, PageBody, PageHeader, RefreshButton, Section, StatusDot } from "../../components/console-ui"; import { ErrorState } from "../../components/ErrorState"; import { CopyableId } from "../../components/list-ui"; -import { DetailSkeleton } from "../../components/Skeleton"; +import { TableSkeleton } from "../../components/Skeleton"; import { useConsoleNavigation } from "../../lib/console-navigation"; import { formatBytes, formatPeriod } from "../../lib/format"; import { installationQuery } from "../../lib/installation"; @@ -19,6 +19,20 @@ import "./system.css"; const MIB = 2 ** 20; const providerNames: Record = { docker: "Docker", microsandbox: "microsandbox" }; +/** A facts card before its first read; the page announces its loading once. */ +function FactsSkeleton({ facts }: { facts: number }) { + return ( + + ); +} + function Fact({ label, help, children }: { label: string; help?: string; children: ReactNode }) { return (
@@ -59,7 +73,7 @@ export function SystemPage() { if (!data) { body = deployment.isError && !deployment.isFetching ? - : ; + : ; } else if (!data.provider) { body = ( ) : installation.isError && !installation.isFetching ? void installation.refetch()} /> - : ; + : ; + // One announcement while either first read is still out; each slot shows only its own placeholder. + const reading = (!data && !(deployment.isError && !deployment.isFetching)) || (!about && !(installation.isError && !installation.isFetching)); return (
@@ -130,9 +146,10 @@ export function SystemPage() { actions={} /> + {reading ?

{t("loading")}

: null} {facts} {body} - {about ? : null} + {about ? : installation.isError ? null : }
); From 3e9217dd24f0c4d9ddad8f9a2b43d88a5be9c5dd Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:52:41 +0000 Subject: [PATCH 17/19] Follow Core's deployment write order and counts in the fixture Setup answers 200, a core_url member is rejected before any state check, and hosted_sandboxes is the deployment's allocations plus pending for every provider. The setup test waits for Add node and the recorded write instead of the Runtime text, and the stale-node test proves removal alone clears the warning. --- apps/web/e2e/fixture-console.mjs | 10 +++++----- apps/web/e2e/public-url.spec.ts | 7 ++++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index af7c3c11c..b36b6b7aa 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -60,10 +60,10 @@ function installation() { secret("database_url", ["core"]), ], }, - // As Core counts them: the allocations held on the nodes, and the unrevoked executor credentials. + // As Core counts them: the deployment's retained and pending hosted sandboxes, and the unrevoked executor credentials. address_bindings: { nodes: nodes.length, nodes_on_other_address: nodes.filter((node) => node.core_url !== publicUrl()).length, - hosted_sandboxes: nodes.length ? state.allocations.length : 0, + hosted_sandboxes: (state.deployment?.resources.allocations ?? 0) + (state.deployment?.resources.pending ?? 0), self_hosted_executors: [...state.executorCredentials.values()].flat().filter((credential) => credential.revoked_at === null).length, }, }; @@ -288,10 +288,10 @@ async function sandboxRoute(request, response, path) { if (path === "/deployment" && (request.method === "POST" || request.method === "PUT")) { const input = await body(request); const initialize = request.method === "POST"; + // As Core, before any state check: the address is config.json's public_url and read-only. + if ("core_url" in input) return error(response, 400, "core_url is derived from the installation public URL (public_url in config.json, AGENTS_API_PUBLIC_URL for Core) and cannot be set here. Remove it.", "invalid_request_error", "core_url"); if (initialize && state.deployment) return error(response, 409, "The sandbox deployment is already configured.", "sandbox_deployment_conflict"); if (!initialize && !state.deployment) return error(response, 409, "The sandbox deployment is not configured.", "sandbox_deployment_conflict"); - // As Core: the address is config.json's public_url and read-only. - if ("core_url" in input) return error(response, 400, "core_url is derived from the installation public URL (public_url in config.json, AGENTS_API_PUBLIC_URL for Core) and cannot be set here. Remove it.", "invalid_request_error", "core_url"); const e2b = input.provider === "e2b"; if (!e2b && (!input.resources || !input.runtime)) return error(response, 400, "resources and runtime are required.", "invalid_sandbox_configuration"); // As Core (ErrSandboxPublicURLUnreachable): E2B sandboxes reach Core over the internet, which a loopback public_url cannot serve. @@ -305,7 +305,7 @@ async function sandboxRoute(request, response, path) { ...(e2b ? { e2b: { template: input.e2b?.template ?? "", credential_configured: true, template_build: templateBuild } } : {}), suspension: input.provider === "microsandbox" ? { idle_seconds: 300, retention_seconds: 86400 } : null, }; - return send(response, initialize ? 201 : 200, state.deployment); + return send(response, 200, state.deployment); } if (path === "/deployment") { return send(response, 200, state.deployment ?? { installation_id: INSTALLATION_ID, provider: "", core_url: publicUrl(), maintenance: false, owner_epoch: 3, generation: 0, mode: "", resources: { allocations: 0, pending: 0 }, suspension: null }); diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts index da4514963..7ef359f2d 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 } from "./console"; +import { expectManagementBoundary, openConsole, writes } from "./console"; test.afterEach(async ({ request }) => expectManagementBoundary(request)); @@ -15,7 +15,9 @@ test("sets up sandboxes with Core's address read-only, never sending it", async await expect(review).toContainText("Set by public_url in config.json"); await expect(page.getByRole("textbox", { name: "Core address" })).toHaveCount(0); await page.getByRole("button", { name: "Save configuration" }).click(); - await expect(page.getByText("c0ffee000000")).toBeVisible(); + // Saved: own machines continue straight to Add node. + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); + expect(await writes(request)).toEqual(["POST /core/v1/sandbox/deployment"]); expect(bodies.some((entry) => entry.includes("core_url"))).toBe(false); }); @@ -61,7 +63,6 @@ test("warns on Nodes about a node bound to an old Core address until it is remov await expect(warning).toHaveText("core-01 is still bound to an old Core address. Remove it and add it again."); await page.getByRole("button", { name: "Remove core-01" }).click(); await page.getByRole("dialog", { name: "Remove node" }).getByRole("button", { name: "Confirm removal" }).click(); - await page.getByRole("button", { name: "Refresh sandbox state" }).click(); await expect(page.getByRole("table", { name: "Sandbox nodes" })).not.toContainText("core-01"); await expect(warning).toHaveCount(0); }); From 72b47437c9376ae8db6028f42f3e0cc7c8df1b2f Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:54:05 +0000 Subject: [PATCH 18/19] Polish the stale-node list, copy labels and SDK pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stale node names join as a list in the current language. The samples' copy buttons read "Copy the Shell sample" / "复制示例:终端". A unit test ties the Python sample's openai pin to contracts/agents-api/upstream.json, and PRODUCT.md names the how-to-call card on the first-run screen. --- apps/web/src/features/api-keys/HowToCall.test.ts | 10 ++++++++++ apps/web/src/features/sandbox/SandboxManagerView.tsx | 2 +- apps/web/src/i18n/locales/en/keys.ts | 2 +- apps/web/src/i18n/locales/zh-CN/keys.ts | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/features/api-keys/HowToCall.test.ts diff --git a/apps/web/src/features/api-keys/HowToCall.test.ts b/apps/web/src/features/api-keys/HowToCall.test.ts new file mode 100644 index 000000000..cde476131 --- /dev/null +++ b/apps/web/src/features/api-keys/HowToCall.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; + +import upstream from "../../../../../contracts/agents-api/upstream.json"; +import { callSamples } from "./HowToCall"; + +describe("how-to-call samples", () => { + it("install the SDK release Core's contract is pinned to", () => { + expect(callSamples("https://core.example/v1", "key").python).toContain(`pip install openai==${upstream.sdk_version}\n`); + }); +}); diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index 2d9c99a5d..10a99f8ff 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -267,7 +267,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig {snapshot.deployment.maintenance ?

{t("Maintenance is enabled. New sandbox placement is paused.")}

: null} {staleNodes.length ?

{staleNodes.length === 1 ? t("{{name}} is still bound to an old Core address. Remove it and add it again.", { name: staleNodes[0] }) - : t("{{count}} nodes are still bound to an old Core address: {{names}}. Remove them and add them again.", { count: staleNodes.length, names: staleNodes.join(", ") })}

: null} + : t("{{count}} nodes are still bound to an old Core address: {{names}}. Remove them and add them again.", { count: staleNodes.length, names: new Intl.ListFormat(i18n.resolvedLanguage, { type: "conjunction" }).format(staleNodes) })}

: null} {hostedNodes ?
{nodes.length diff --git a/apps/web/src/i18n/locales/en/keys.ts b/apps/web/src/i18n/locales/en/keys.ts index 06dbe3b5a..92350442c 100644 --- a/apps/web/src/i18n/locales/en/keys.ts +++ b/apps/web/src/i18n/locales/en/keys.ts @@ -96,7 +96,7 @@ export const keys = { howToCall: { title: "How to call", shell: "Shell", - copy: "Copy {{label}}", + copy: "Copy the {{label}} sample", copyFailed: "Select the text and copy it manually.", loading: "Reading the API address", failed: "The API address couldn't be read.", diff --git a/apps/web/src/i18n/locales/zh-CN/keys.ts b/apps/web/src/i18n/locales/zh-CN/keys.ts index 336436313..7ec8ccd9c 100644 --- a/apps/web/src/i18n/locales/zh-CN/keys.ts +++ b/apps/web/src/i18n/locales/zh-CN/keys.ts @@ -98,7 +98,7 @@ export const keys: TranslationShape = { howToCall: { title: "如何调用", shell: "终端", - copy: "复制 {{label}}", + copy: "复制示例:{{label}}", copyFailed: "请选中文本后手动复制。", loading: "正在读取 API 地址", failed: "无法读取 API 地址。", From 30cbf3850dfd0b8be0a189221fddc5dd4990bd56 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 20:13:35 +0000 Subject: [PATCH 19/19] Match Core's current E2B public URL message in the fixture ErrSandboxPublicURLUnreachable no longer ends with "then save again". --- apps/web/e2e/fixture-console.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index b36b6b7aa..fef5dcd09 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -295,7 +295,7 @@ async function sandboxRoute(request, response, path) { const e2b = input.provider === "e2b"; if (!e2b && (!input.resources || !input.runtime)) return error(response, 400, "resources and runtime are required.", "invalid_sandbox_configuration"); // As Core (ErrSandboxPublicURLUnreachable): E2B sandboxes reach Core over the internet, which a loopback public_url cannot serve. - if (e2b && state.installation === "local") return error(response, 409, "E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback (public_url in config.json, AGENTS_API_PUBLIC_URL for Core), then save again.", "sandbox_configuration_error"); + if (e2b && state.installation === "local") return error(response, 409, "E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback (public_url in config.json, AGENTS_API_PUBLIC_URL for Core).", "sandbox_configuration_error"); // As Core: E2B may omit resources and adopt its template build's CPU and memory; only microsandbox suspends. const resources = input.resources ?? { cpus: templateBuild.resources.cpus, memory_mib: templateBuild.resources.memory_mib }; state.deployment = {