From 485e0f54bd1dd9fa00677117761e7d03f9300ecb Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 12:46:13 +0000 Subject: [PATCH 1/2] Move node transport to /api/v1 and stop forwarding machine traffic through Web Sandbox node enrollment, configuration, identity and the node WebSocket move from /core/v1/sandbox/{enroll,node/*} to /api/v1/sandbox-node/*, beside the existing /api/v1/agent-daemon routes. Authentication is unchanged: an enrollment token or the node credential. The old paths are removed. The console no longer forwards node or daemon transport and returns 404 for /v1 and /api/v1 whatever the credential; the TLS reverse proxy sends both directly to Core. make openapi now writes /api/v1 routes to contracts/agents-api/runtime.openapi.yaml; the public openapi.yaml is unchanged. Docs describe the routing table and that older nodes cannot connect to this Core, so upgrades follow the drain and re-enrollment workflow. --- CONTRIBUTING.md | 47 +-- Makefile | 2 +- .../environment-executor-credentials.md | 3 +- contracts/agents-api/runtime.openapi.yaml | 295 ++++++++++++++++++ contracts/agents-api/sandbox-deployment.md | 19 +- .../agents-api/sandbox-manager.openapi.yaml | 209 +------------ deploy/install/node_install.py | 2 +- deploy/install/node_spec.py | 6 +- deploy/install/test_node_spec.py | 2 +- docs/api/README.md | 10 +- docs/api/web-management.md | 11 +- docs/getting-started/install.md | 21 +- docs/getting-started/operations.md | 36 ++- docs/web/architecture.md | 9 +- docs/web/core-connection.md | 7 +- scripts/openapi-split/main.go | 62 ++-- scripts/openapi-split/main_test.go | 22 +- services/agents-api/HOSTED-SANDBOX-MANAGER.md | 21 +- services/agents-api/cmd/server/http_routes.go | 2 +- .../agents-api/cmd/server/http_routes_test.go | 11 +- .../agents-api/deploy/microsandbox/README.md | 2 +- .../agents-api/internal/api/routing_test.go | 4 +- .../internal/api/sandbox_manager.go | 20 +- .../internal/api/sandbox_manager_test.go | 14 +- .../api/sandbox_node_configuration.go | 4 +- .../agents-api/internal/sandbox/node/agent.go | 2 +- .../internal/sandbox/node/capacity_test.go | 2 +- .../internal/sandbox/node/enrollment.go | 6 +- .../internal/sandbox/node/node_test.go | 8 +- services/core-console/auth_test.go | 24 +- services/core-console/distribution_test.go | 53 ---- services/core-console/node_installation.go | 14 - .../core-console/node_installation_test.go | 67 +--- services/core-console/project_proxy_test.go | 32 +- services/core-console/sandbox_admin.go | 20 +- services/core-console/server.go | 21 +- 36 files changed, 565 insertions(+), 525 deletions(-) create mode 100644 contracts/agents-api/runtime.openapi.yaml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fefcb808e..d857b4e33 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -191,8 +191,9 @@ there is no product swaggo contract in this repository. Preserve its pinned type coverage ledgers and official SDK/raw HTTP tests when changing API behavior. Run `make openapi` after handler annotation changes. It reuses the original Core-only swaggo v1.16.4 generator, then separates project paths under `/v1` from -`/core/v1/sandbox` administration in `sandbox-manager.openapi.yaml` (base path `/`). -Both generated schemas remain free of product routes. +`/core/v1` administration in `sandbox-manager.openapi.yaml` and `/api/v1` machine +connections in `runtime.openapi.yaml` (both base path `/`). All generated schemas +remain free of product routes. Core changes must retain the independent build and official-client workflow. Native adapter changes require their applicable build/check targets and live provider @@ -727,8 +728,9 @@ Default installation includes Core, Web and PostgreSQL but no execution node. It always creates a separate deployment administrator credential. Core receives only its digest; the paired console server receives the private token and injects it only on approved management routes after console login and same-origin checks. -The browser never receives that token. Node/daemon transport routes instead -forward their own credentials unchanged to Core. Zero-node Core receives neither the Docker socket nor KVM. +The browser never receives that token. 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, generation-tagged selection under the existing execution lease and deployment lock. @@ -805,7 +807,7 @@ deployment. Harness selection and public/self-hosted contracts remain unchanged. The paired console serves only matched, non-secret distribution artifacts for node installation. Never serve private installation files or arbitrary paths. Installation -reads `GET /core/v1/sandbox/node/configuration` using an unconsumed enrollment token, +reads `GET /api/v1/sandbox-node/configuration` using an unconsumed enrollment token, or a retained node credential with `X-Parsar-Node-ID`. Reads never consume enrollment; registered nodes can read their matching configuration during maintenance. Validate installation, generation, specification digest and release before writing node files, @@ -1761,18 +1763,17 @@ credentials to installed node/Runtime configuration to bypass download access. Project-authenticated executor-credential extensions remain outside the upstream API namespace and reuse the existing restricted issuer. They require the exact creator of a live self-hosted Environment; deployment administrator authority and -shared Session read access do not grant credential issuance. The console preserves -explicit caller credentials on these routes and never substitutes its administrator -key. Self-hosted installation reuses Docker Runtime isolation, owns no sandbox -node or Core allocation, and retains user-owned native history after uncertain -launches. Report started, connected and real execution success separately. +shared Session read access do not grant credential issuance. The console does not +serve these routes; callers reach Core directly. Self-hosted installation reuses +Docker Runtime isolation, owns no sandbox node or Core allocation, and retains +user-owned native history after uncertain launches. Report started, connected and real execution success separately. Self-hosted installation confirms connection through the private daemon transport using only its restricted executor credential. The read checks the exact live Environment/key binding and current authenticated connection; it never enrolls, -allocates, wakes a sandbox or grants project resource access. Console forwarding -preserves this credential without replacing it with an administrator or project -key. Bounded polling and reruns retain the original container and history; -timeout is a diagnostic failure, not permission to relaunch. An explicit installer +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 @@ -1834,11 +1835,15 @@ must not change on a repeated install. `services/core-console` serves the production Web build and proxies only finite administrator and sandbox-management routes after console login. It requires a private `CORE_CONSOLE_ADMIN_TOKEN_FILE` and holds no project caller credential. -Every `/v1` request returns 404, including explicit Bearer requests. The installer +Every `/v1` and `/api/v1` request returns 404, including explicit Bearer and +WebSocket requests; Web forwards no node or daemon transport. The installer mounts only the administrator key into Web and only its digest into Core. The -browser receives safe capability flags, never that key. Node/daemon transport -keeps its own authenticated finite routes and credentials. External API clients -reach Core directly through the deployment's TLS routing. +browser receives safe capability flags, never that key. The deployment's TLS +reverse proxy routes `/v1` (applications) and `/api/v1` (nodes and Runtime +daemons, with their own credentials) directly to Core and everything else to Web. +Nodes and Core come from one distribution; older nodes using the removed +`/core/v1/sandbox` node paths cannot connect and are replaced through the drained +upgrade and re-enrollment workflow. The Web manager offers no manual administrator-key fallback. A console without paired management configuration shows setup guidance; direct remote project API @@ -1877,9 +1882,9 @@ account record stores a password hash; corruption or missing registered account state must never reopen registration. Account creation is race-safe. Cookie sessions are bounded, HttpOnly, SameSite Strict and Secure for HTTPS origins; a restart requires sign-in again, without deleting the account. Unauthenticated access is limited to -the static login UI, finite console authentication routes and the existing -independently authenticated node/project transports. Authentication requests use -same-origin JSON POSTs with bounded bodies and bounded password-hash work. +the static login UI, finite console authentication routes and the static node +installation payload. Authentication requests use same-origin JSON POSTs with +bounded bodies and bounded password-hash work. Projects and application API keys live in Core PostgreSQL. Project creation owns its scope and shared principal; key issuance, revocation and Project archive share diff --git a/Makefile b/Makefile index f3a5cf108..db71a446c 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ openapi: --output "$$output" \ --outputTypes yaml --parseInternal; \ python3 scripts/patch-agents-openapi.py "$$output/swagger.yaml"; \ - go run ./scripts/openapi-split "$$output/swagger.yaml" contracts/agents-api/openapi.yaml contracts/agents-api/sandbox-manager.openapi.yaml + go run ./scripts/openapi-split "$$output/swagger.yaml" contracts/agents-api/openapi.yaml contracts/agents-api/sandbox-manager.openapi.yaml contracts/agents-api/runtime.openapi.yaml check-sqlc: python3 scripts/check-sqlc.py diff --git a/contracts/agents-api/environment-executor-credentials.md b/contracts/agents-api/environment-executor-credentials.md index 484a2e297..e58a4572f 100644 --- a/contracts/agents-api/environment-executor-credentials.md +++ b/contracts/agents-api/environment-executor-credentials.md @@ -62,7 +62,8 @@ with the exact distribution digest and platform skips image download and import. ## Private connection confirmation `GET /api/v1/agent-daemon/connection?environment_id=UUID` uses the existing -executor bearer, passed unchanged through the console. It is part of the private +executor bearer, sent directly to Core (the reverse proxy routes `/api/v1` to +Core; the console does not serve it). It is part of the private daemon transport, not the public Agents API. It reads existing authorization and binding only; it never enrolls a device, starts execution or changes resources. The no-store response contains only the requested `environment_id` and `status` diff --git a/contracts/agents-api/runtime.openapi.yaml b/contracts/agents-api/runtime.openapi.yaml new file mode 100644 index 000000000..e4432f4ff --- /dev/null +++ b/contracts/agents-api/runtime.openapi.yaml @@ -0,0 +1,295 @@ +basePath: / +definitions: + sandbox.DeploymentSpec: + properties: + resources: + $ref: '#/definitions/sandbox.Resources' + runtime: + $ref: '#/definitions/sandbox.RuntimeRelease' + type: object + sandbox.Resources: + properties: + cpus: + type: integer + environment_disk_mib: + type: integer + memory_mib: + type: integer + root_disk_mib: + type: integer + type: object + sandbox.RuntimeRelease: + properties: + firmware_sha256: + type: string + image_id: + type: string + image_manifest_digest: + type: string + microsandbox_ref: + type: string + runtime_sha256: + type: string + source_commit: + type: string + type: object + store.RuntimeNodeConfiguration: + properties: + core_url: + type: string + generation: + type: integer + installation_id: + type: string + max_active: + type: integer + max_retained: + type: integer + provider: + type: string + specification: + $ref: '#/definitions/sandbox.DeploymentSpec' + specification_digest: + type: string + type: object + store.RuntimeNodeEnrollment: + properties: + backend_fingerprint: + type: string + credential: + type: string + deployment_generation: + type: integer + name: + type: string + node_id: + type: string + provider: + type: string + specification_digest: + type: string + type: object + store.RuntimeNodeIdentity: + properties: + deployment_generation: + type: integer + installation_id: + type: string + max_active: + type: integer + max_retained: + type: integer + node_id: + type: string + provider: + type: string + specification_digest: + type: string + type: object + store.RuntimeNodeStatus: + properties: + connected: + type: boolean + deployment_generation: + type: integer + installation_id: + type: string + max_active: + type: integer + max_retained: + type: integer + node_id: + type: string + provider: + type: string + provider_ready: + type: boolean + specification_digest: + type: string + type: object + v1.APIError: + properties: + code: + type: string + x-nullable: true + message: + type: string + param: + type: string + x-nullable: true + type: + type: string + required: + - message + - type + type: object + v1.ErrorResponse: + properties: + error: + $ref: '#/definitions/v1.APIError' + required: + - error + type: object +info: + contact: {} + description: Machine connection routes under /api/v1. Sandbox nodes authenticate with a one-use enrollment token or their node credential; Project API keys and the deployment administrator credential are not accepted. See each operation's security requirements. + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + title: Core Machine Connections + version: "1" +paths: + /api/v1/sandbox-node/configuration: + get: + description: Authenticates with an unconsumed enrollment token, or a retained node credential with X-Parsar-Node-ID. Does not consume the token or expose E2B credentials. Node files cannot override this specification. + parameters: + - description: Retained node UUID + in: header + name: X-Parsar-Node-ID + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/store.RuntimeNodeConfiguration' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "409": + description: Conflict + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - NodeEnrollmentAuth: [] + summary: Read the active configuration for node installation + tags: + - Sandbox Node + /api/v1/sandbox-node/enroll: + post: + consumes: + - application/json + description: Node machine connection. Consumes a one-use enrollment token; grants no project or administrator access. Responses contain only explicit safe fields. + parameters: + - description: Request + in: body + name: body + required: true + schema: + $ref: '#/definitions/store.RuntimeNodeEnrollment' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/store.RuntimeNodeIdentity' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "409": + description: Conflict + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - NodeEnrollmentAuth: [] + summary: Enroll a sandbox node + tags: + - Sandbox Node + /api/v1/sandbox-node/identity: + get: + description: Node machine connection. Authenticates with the retained node credential; grants no project or administrator access. Responses contain only explicit safe fields. + parameters: + - description: Sandbox node UUID + in: query + name: node_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/store.RuntimeNodeStatus' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "409": + description: Conflict + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - NodeAuth: [] + summary: Recover an enrolled sandbox node identity and observe its readiness + tags: + - Sandbox Node +schemes: + - http + - https +securityDefinitions: + BearerAuth: + in: header + name: Authorization + type: apiKey + DeploymentAdminAuth: + in: header + name: Authorization + type: apiKey + NodeAuth: + in: header + name: Authorization + type: apiKey + NodeEnrollmentAuth: + in: header + name: Authorization + type: apiKey +swagger: "2.0" diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 062309863..916dd06d9 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -11,8 +11,9 @@ provisioning are unchanged. A provider selection is independent of the harness. A deployment can remain unconfigured, with no execution nodes or hosted admission. See [Hosted Sandbox Manager](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md) -for the operator workflow and [generated OpenAPI](sandbox-manager.openapi.yaml) -for request and response schemas. +for the operator workflow. Generated schemas cover the +[administrator routes](sandbox-manager.openapi.yaml) and the +[node machine connection routes](runtime.openapi.yaml). ## Authority and routes @@ -22,12 +23,14 @@ for request and response schemas. | `POST /core/v1/sandbox/deployment` | Deployment administrator | Select the initial provider, resources, Runtime and Core origin | | `PUT /core/v1/sandbox/deployment` | Deployment administrator | Replace a fully drained selection while maintenance is enabled | | `PATCH /core/v1/sandbox/deployment/maintenance` | Deployment administrator | Pause or resume fresh hosted admission at the expected generation | -| `GET /core/v1/sandbox/node/configuration` | Enrollment token or retained node credential | Read the active node installation configuration without consuming enrollment | +| `GET /api/v1/sandbox-node/configuration` | Enrollment token or retained node credential | Read the active node installation configuration without consuming enrollment | The paired console injects its administrator Bearer credential server-side for management routes. The browser never receives that credential. Node configuration -uses its own Bearer credential, forwarded unchanged by the console; a console -login or Project key does not grant node enrollment authority. +and enrollment are machine connection routes under `/api/v1`, which the reverse +proxy sends directly to Core; the console does not serve them. They use their own +Bearer credential; a console login, the administrator credential or a Project key +does not grant node enrollment authority. Node capacity is separate from the deployment specification. The administrator's `POST /core/v1/sandbox/enrollment-tokens` accepts optional `max_active` and @@ -206,7 +209,7 @@ or E2B credential. It is available only for node-backed providers. The installer reads this configuration before preparing local assets. Its provider file records `generation` and `specification`, alongside the host's socket, paths -and network policy. Enrollment at `POST /core/v1/sandbox/enroll` includes +and network policy. Enrollment at `POST /api/v1/sandbox-node/enroll` includes `deployment_generation` and `specification_digest`. A mismatch rejects before token consumption. Retained node authentication checks the same generation and digest. A changed local resource setting, Runtime or generation must fail rather @@ -238,7 +241,9 @@ sandboxes are refused with the same error as during maintenance. Enrollment tokens return 409 `sandbox_deployment_conflict`; node configuration reads and enrollment are refused. The ordinary maintenance, archive and PUT sequence records a specification and -retires those nodes. See the [operator upgrade notes](../../docs/getting-started/operations.md#data-and-upgrades). +retires those nodes. Those nodes use the removed `/core/v1/sandbox` node paths, so +their drain needs a Core release that still serves them. See the +[operator upgrade notes](../../docs/getting-started/operations.md#data-and-upgrades). Unit tests, database tests and provider inspection are separate from live execution acceptance. This contract does not assert that every resource profile, diff --git a/contracts/agents-api/sandbox-manager.openapi.yaml b/contracts/agents-api/sandbox-manager.openapi.yaml index 60e3574ab..b419d1cfc 100644 --- a/contracts/agents-api/sandbox-manager.openapi.yaml +++ b/contracts/agents-api/sandbox-manager.openapi.yaml @@ -712,25 +712,6 @@ definitions: tenant_id: type: string type: object - store.RuntimeNodeConfiguration: - properties: - core_url: - type: string - generation: - type: integer - installation_id: - type: string - max_active: - type: integer - max_retained: - type: integer - provider: - type: string - specification: - $ref: '#/definitions/sandbox.DeploymentSpec' - specification_digest: - type: string - type: object store.RuntimeNodeDetail: properties: active: @@ -776,23 +757,6 @@ definitions: snapshots: type: integer type: object - store.RuntimeNodeEnrollment: - properties: - backend_fingerprint: - type: string - credential: - type: string - deployment_generation: - type: integer - name: - type: string - node_id: - type: string - provider: - type: string - specification_digest: - type: string - type: object store.RuntimeNodeHost: properties: available_disk_bytes: @@ -814,44 +778,6 @@ definitions: type: integer x-nullable: true type: object - store.RuntimeNodeIdentity: - properties: - deployment_generation: - type: integer - installation_id: - type: string - max_active: - type: integer - max_retained: - type: integer - node_id: - type: string - provider: - type: string - specification_digest: - type: string - type: object - store.RuntimeNodeStatus: - properties: - connected: - type: boolean - deployment_generation: - type: integer - installation_id: - type: string - max_active: - type: integer - max_retained: - type: integer - node_id: - type: string - provider: - type: string - provider_ready: - type: boolean - specification_digest: - type: string - type: object store.RuntimeNodeUpdate: properties: max_active: @@ -2896,7 +2822,7 @@ definitions: type: object info: contact: {} - description: Core extensions outside the upstream Agents API. Environment executor credential operations use project caller authentication. Sandbox administration and node enrollment use separate administrator and node credentials. See each operation's security requirements. + description: Core extensions outside the upstream Agents API. Environment executor credential operations use project caller authentication. Sandbox administration uses the deployment administrator credential. See each operation's security requirements. license: name: Apache 2.0 url: https://www.apache.org/licenses/LICENSE-2.0.html @@ -5710,54 +5636,6 @@ paths: summary: Pause or resume new hosted allocations tags: - Sandbox Manager - /core/v1/sandbox/enroll: - post: - consumes: - - application/json - description: Core deployment extension. Does not grant project resource access. Responses contain only explicit safe fields. - parameters: - - description: Request - in: body - name: body - required: true - schema: - $ref: '#/definitions/store.RuntimeNodeEnrollment' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/store.RuntimeNodeIdentity' - "400": - description: Bad Request - schema: - $ref: '#/definitions/v1.ErrorResponse' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/v1.ErrorResponse' - "404": - description: Not Found - schema: - $ref: '#/definitions/v1.ErrorResponse' - "409": - description: Conflict - schema: - $ref: '#/definitions/v1.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/v1.ErrorResponse' - "503": - description: Service Unavailable - schema: - $ref: '#/definitions/v1.ErrorResponse' - security: - - NodeEnrollmentAuth: [] - summary: Enroll a sandbox node - tags: - - Sandbox Manager /core/v1/sandbox/enrollment-tokens: post: consumes: @@ -5806,91 +5684,6 @@ paths: summary: Create a ten-minute one-use node enrollment token tags: - Sandbox Manager - /core/v1/sandbox/node/configuration: - get: - description: Authenticates with an unconsumed enrollment token, or a retained node credential with X-Parsar-Node-ID. Does not consume the token or expose E2B credentials. Node files cannot override this specification. - parameters: - - description: Retained node UUID - in: header - name: X-Parsar-Node-ID - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/store.RuntimeNodeConfiguration' - "400": - description: Bad Request - schema: - $ref: '#/definitions/v1.ErrorResponse' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/v1.ErrorResponse' - "409": - description: Conflict - schema: - $ref: '#/definitions/v1.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/v1.ErrorResponse' - "503": - description: Service Unavailable - schema: - $ref: '#/definitions/v1.ErrorResponse' - security: - - NodeEnrollmentAuth: [] - summary: Read the active configuration for node installation - tags: - - Sandbox Manager - /core/v1/sandbox/node/identity: - get: - description: Core deployment extension. Does not grant project resource access. Responses contain only explicit safe fields. - parameters: - - description: Sandbox node UUID - in: query - name: node_id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/store.RuntimeNodeStatus' - "400": - description: Bad Request - schema: - $ref: '#/definitions/v1.ErrorResponse' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/v1.ErrorResponse' - "404": - description: Not Found - schema: - $ref: '#/definitions/v1.ErrorResponse' - "409": - description: Conflict - schema: - $ref: '#/definitions/v1.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/v1.ErrorResponse' - "503": - description: Service Unavailable - schema: - $ref: '#/definitions/v1.ErrorResponse' - security: - - NodeAuth: [] - summary: Recover an enrolled sandbox node identity and observe its readiness - tags: - - Sandbox Manager /core/v1/sandbox/nodes: get: description: Core deployment extension. Does not grant project resource access. Responses contain only explicit safe fields. diff --git a/deploy/install/node_install.py b/deploy/install/node_install.py index 31d5d89b4..b49259f6d 100644 --- a/deploy/install/node_install.py +++ b/deploy/install/node_install.py @@ -382,7 +382,7 @@ def wait_ready(root, args, timeout=60): raise ValueError() except (ValueError, KeyError, TypeError, AttributeError): raise InstallError("Retained node identity differs or is invalid; preserve state and inspect enrollment") from None - request = urllib.request.Request(args.core_url + "/core/v1/sandbox/node/identity?" + urlencode({"node_id": identity["node_id"]}), + request = urllib.request.Request(args.core_url + "/api/v1/sandbox-node/identity?" + urlencode({"node_id": identity["node_id"]}), headers={"Authorization": "Bearer " + credential}) deadline = time.monotonic() + timeout detail = "Core has not confirmed the node connection" diff --git a/deploy/install/node_spec.py b/deploy/install/node_spec.py index 7d2472867..7f8c0ce58 100644 --- a/deploy/install/node_spec.py +++ b/deploy/install/node_spec.py @@ -86,7 +86,7 @@ def fetch(args, token, retained, open_request, allow_enrollment=False): raise SpecificationError("Retained node identity differs or is invalid; preserve its state") from None elif not token: raise SpecificationError("A one-time enrollment credential is required for a new node") - request = urllib.request.Request(args.core_url + "/core/v1/sandbox/node/configuration", headers=headers) + request = urllib.request.Request(args.core_url + "/api/v1/sandbox-node/configuration", headers=headers) try: try: response = open_request(request) @@ -95,7 +95,7 @@ def fetch(args, token, retained, open_request, allow_enrollment=False): raise # Identity is persisted before enrollment. A failed first registration # may therefore have no durable credential on Core yet. - request = urllib.request.Request(args.core_url + "/core/v1/sandbox/node/configuration", + request = urllib.request.Request(args.core_url + "/api/v1/sandbox-node/configuration", headers={"Authorization": "Bearer " + token}) response = open_request(request) with response: @@ -104,7 +104,7 @@ def fetch(args, token, retained, open_request, allow_enrollment=False): raise ValueError() data = validate(json.loads(raw), args) except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.IncompleteRead): - raise SpecificationError("Cannot read Core node configuration; verify the Core origin and retained or enrollment credential") from None + raise SpecificationError("Cannot read Core node configuration; verify the Core origin, that the reverse proxy routes /api/v1 to Core, and the retained or enrollment credential") from None except (ValueError, AttributeError): raise SpecificationError("Core returned invalid node configuration") from None if retained is not None: diff --git a/deploy/install/test_node_spec.py b/deploy/install/test_node_spec.py index 8af94e6c1..1fb150840 100644 --- a/deploy/install/test_node_spec.py +++ b/deploy/install/test_node_spec.py @@ -32,7 +32,7 @@ def test_new_node_uses_enrollment_bearer_and_no_local_provider_default(self): opener = mock.Mock(return_value=self.response()) self.assertEqual(node_spec.fetch(self.args, "once", None, opener), self.data) req = opener.call_args.args[0] - self.assertEqual(req.full_url, self.args.core_url + "/core/v1/sandbox/node/configuration") + self.assertEqual(req.full_url, self.args.core_url + "/api/v1/sandbox-node/configuration") self.assertEqual(dict(req.header_items()), {"Authorization": "Bearer once"}) self.assertEqual(req.get_method(), "GET") diff --git a/docs/api/README.md b/docs/api/README.md index eaa5c9821..ec84800d3 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -10,7 +10,7 @@ OpenAI compatibility: Core also has explicitly documented extensions under `/v1` | Administrator resources | Web's server or administrative automation; deployment credential | Core `/core/v1/admin` | [Management contract](../../contracts/agents-api/admin-api.md) | | Hosted sandbox administration | Web's server or administrative automation; deployment credential | Core `/core/v1/sandbox` management routes | [Web API](web-management.md#sandbox-administration) | | Console authentication | Browser; local console sign-in and session cookie | Console `/console/auth` | [Web API](web-management.md#browser-to-console) | -| Node and daemon transport | Installed node/Runtime; its own enrollment or connection credential | Dedicated enrollment and WebSocket paths | [Runtime credentials](../../contracts/agents-api/environment-executor-credentials.md), [node operations](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md) | +| Node and daemon transport | Installed node/Runtime; its own enrollment or connection credential | Direct Core `/api/v1`: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets | [Runtime credentials](../../contracts/agents-api/environment-executor-credentials.md), [node operations](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [machine OpenAPI](../../contracts/agents-api/runtime.openapi.yaml) | Projects own assets. Multiple equally privileged keys share the Project's assets and execution principal; provenance retains the key that performed each write. @@ -19,8 +19,9 @@ Administrator credentials cannot authenticate public Agent API operations, and Project keys cannot authenticate administrator operations. The console server stores its deployment credential privately and proxies only -allowlisted management operations. Browsers do not receive it. Applications call -Core directly; the console rejects `/v1` even when given an application key. +allowlisted management operations. Browsers do not receive it. Applications and +machines call Core directly; the console returns 404 for `/v1` and `/api/v1`, even +when given an application key or machine credential. ## Contract sources @@ -31,6 +32,9 @@ Core directly; the console rejects `/v1` even when given an application key. - [Core extension OpenAPI](../../contracts/agents-api/sandbox-manager.openapi.yaml): generated management and executor-credential routes. The filename does not mean that all its routes are sandbox-administrator operations; use the authority table above. +- [Machine connection OpenAPI](../../contracts/agents-api/runtime.openapi.yaml): + generated `/api/v1` sandbox node routes. The node WebSocket and the private + daemon transport are described in the node and Runtime credential guides. - [Administrator contract](../../contracts/agents-api/admin-api.md): Project/key lifecycle, resources, explicit hosted Session archive, copying, summary, errors/deletion preconditions and audit. diff --git a/docs/api/web-management.md b/docs/api/web-management.md index bd0f2e7f4..32f79e8ed 100644 --- a/docs/api/web-management.md +++ b/docs/api/web-management.md @@ -88,17 +88,18 @@ original Session cannot resume. This API does not add a console archive control. Public Environment Templates, the `/v1` contract and caller-owned `self_hosted` provisioning remain unchanged. -`GET /core/v1/sandbox/node/configuration` uses an enrollment Bearer token, or a +`GET /api/v1/sandbox-node/configuration` uses an enrollment Bearer token, or a retained node Bearer credential with `X-Parsar-Node-ID`. This read does not consume enrollment. Retained matching nodes can read their configuration during maintenance. Installers must verify the returned generation, specification digest and Runtime before registration; local files cannot override the saved limits. A mismatch returns `sandbox_specification_mismatch` without replacing node state. -Node configuration, enrollment, identity and daemon WebSocket routes retain their -own credentials through the paired console's fixed transport routes. The console -must not substitute its administrator credential on them. They do not inherit a -browser login or gain general management authority. E2B credentials are absent +Node configuration, enrollment, identity and connection routes live under +`/api/v1/sandbox-node`, beside the daemon's `/api/v1/agent-daemon`. The reverse +proxy sends `/api/v1` directly to Core; the console returns 404 for it and never +forwards machine traffic. These routes use their own credentials, do not inherit a +browser login and gain no management authority. E2B credentials are absent from node configuration and safe deployment views. ## Frontend handoff and errors diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index 651ed6d34..75d3d69db 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -321,13 +321,20 @@ For nodes added through Web, install with the intended shared HTTPS endpoint: The installer also uses this origin for the `wss` connection URL returned by self-hosted Sessions, so remote Runtime hosts never receive a Compose-only -hostname. Configure your TLS reverse proxy to route `/v1` and the project -executor-credential API to the loopback Core port (8091), with all console pages, -management paths and fixed node/daemon transport routes going to Web (8080). -Preserve Host and support WebSocket upgrades. This keeps application API access -separate from console authentication. The bundled Web forwards the fixed -node and daemon transport routes to Core using their own credentials. Both the -node host and its sandbox guests must reach this address. Installation does not +hostname. Configure your TLS reverse proxy with these routes: + +| Path | Destination | Callers | +| --- | --- | --- | +| `/v1`, `/v1/*` | Core (loopback 8091) | Applications, with a Project API key | +| `/api/v1/*` | Core (loopback 8091) | Nodes and Runtime daemons, with their own machine credentials | +| Everything else, including `/core/v1/*` and `/node-install/*` | Web (loopback 8080) | Browsers and the console | + +The project executor-credential API (`/core/v1/environments/{id}/executor-credentials`) +is the one exception under `/core`: route it to Core as well. Preserve Host and +support WebSocket upgrades. Web answers 404 on `/v1` and `/api/v1` and never +forwards them, so a proxy that sends those paths to Web breaks application calls +and node enrollment. Both the node host and its sandbox guests must reach this +address. Installation does not create DNS records or certificates, nor expose a host port publicly. Without this option, the console uses its loopback address for local access. Do not copy a localhost download command to a different machine. Running plain `./install.sh` diff --git a/docs/getting-started/operations.md b/docs/getting-started/operations.md index 100478438..8024c6d88 100644 --- a/docs/getting-started/operations.md +++ b/docs/getting-started/operations.md @@ -122,6 +122,13 @@ apply the existing Core migration workflow and replace matched service/Runtime artifacts while retaining identities and backend paths. Qualify recovery before claiming the upgrade complete; there is no downgrade or history migration promise. +Core and its nodes must come from the same distribution; the node installer refuses +a mismatched release. Nodes from releases that used the removed +`/core/v1/sandbox/enroll` and `/core/v1/sandbox/node/*` paths cannot connect to a +Core that serves `/api/v1/sandbox-node/*`. Drain such a deployment with its previous +release while its nodes are still connected (steps 1–2 below). Then upgrade Core and +complete steps 3–4 with the new distribution's Runtime, enrolling its nodes. + The installer refuses component/packaging flag changes on an existing installation. Rerunning it does not resize sandboxes or replace the database selection. Use Web or the administrator API for the initial selection and all later provider, @@ -157,12 +164,15 @@ state. There is no automatic old-database conversion, force reset or resource de The supported current path uses a database-managed deployment. A Web-selected Docker or microsandbox deployment saved before deployment -specifications existed has an empty specification after migration. Current Core -loads it only to drain: nodes enrolled under the previous release reconnect with -their existing node service, and their sandboxes stay reachable for archive and -cleanup. Fresh sandbox creation, node configuration reads and enrollment are -refused. Back up as above, replace Core and Web, then use steps 1–4 with a Runtime -release from the new distribution. The replacement retires the old nodes. Stop each +specifications existed has an empty specification after migration. Its nodes use +the removed `/core/v1/sandbox` node paths, so drain it with the last Core release +that still serves them. That Core loads the deployment only to drain: nodes +enrolled under the previous release reconnect with their existing node service, +and their sandboxes stay reachable for archive and cleanup. Fresh sandbox +creation, node configuration reads and enrollment are refused. Back up as above, +replace Core and Web with that release and complete steps 1–2. Then upgrade to +the current distribution and use steps 3–4 with its Runtime release. The +replacement retires the old nodes. Stop each retired node service, move its identity directory aside as a backup, and add the host again with a new command from Web. Node IDs change; Session history and persisted Files/Artifacts remain. E2B deployments from that period are not covered. @@ -173,12 +183,14 @@ API and console bind to host loopback. With native Core, PostgreSQL publishes an installation-specific loopback port; with container Core it has no published port. The production Web proxy forwards only allowlisted administrator and sandbox management routes after console login. Its deployment credential stays on the -server. All `/v1` requests, including requests with an explicit API key, return 404 -at the console. Route public `/v1` and project executor-credential requests directly -to Core through the TLS reverse proxy. Route console pages, authentication, -administration and the existing node/daemon transport paths to Web. Those fixed -transport paths retain their own authentication. `/node-install/` serves only the -matched non-secret node payload. Web has no Docker or KVM authority. +server. The TLS reverse proxy routes `/v1` (applications) and `/api/v1` (node and +Runtime daemon connections) directly to Core, together with the project +executor-credential API under `/core/v1/environments/`. Everything else, including +console pages, authentication and administration, goes to Web. Web returns 404 for +`/v1` and `/api/v1`, whatever credential a request carries, and forwards no node or +daemon traffic. Machine routes keep their own enrollment and connection credentials. +`/node-install/` serves only the matched non-secret node payload. Web has no Docker +or KVM authority. It requires an independent administrator login and rejects untrusted browser origins. New installations store the administrator password hash in private console state; legacy installations retain Basic authentication. There is only one Web role, diff --git a/docs/web/architecture.md b/docs/web/architecture.md index 47adec926..1459f8a2b 100644 --- a/docs/web/architecture.md +++ b/docs/web/architecture.md @@ -28,8 +28,8 @@ flowchart LR React management code must use `AdminClient` from `packages/agents-client`, plus the existing sandbox management client for `/core/v1/sandbox`. The console service -returns 404 for `/v1`, including requests with an explicit Bearer token. It has no -application key and does not impersonate the selected Project. +returns 404 for `/v1` and `/api/v1`, including requests with an explicit Bearer +token. It has no application key and does not impersonate the selected Project. The console authenticates the browser, checks the request origin and forwards only allowed management routes. It replaces browser authorization and actor headers, @@ -37,8 +37,9 @@ strips browser cookies, and supplies its server-side deployment credential. Core rejects application keys on management routes and deployment credentials on `/v1`. The forwarded console account name is an audit label, not Core authorization. -Fixed node enrollment and daemon transport routes retain their own credentials and -existing transport behavior. They do not grant a browser execution authority. +Node and daemon connections use `/api/v1` with their own credentials. The reverse +proxy sends them directly to Core; the console never forwards them, and they do not +grant a browser execution authority. ## Ownership diff --git a/docs/web/core-connection.md b/docs/web/core-connection.md index 5323be9e4..53ee27355 100644 --- a/docs/web/core-connection.md +++ b/docs/web/core-connection.md @@ -12,8 +12,9 @@ supplies the deployment administrator credential to its configured Core upstream Browser code must never receive that credential. Applications call Core's `/v1` directly with their own Project API keys and the -public API's route-specific headers. The console returns 404 for `/v1`, even with -an explicit Bearer token. Deployment routing must send application traffic to Core. +public API's route-specific headers. Nodes and Runtime daemons call Core's `/api/v1` +directly with their own machine credentials. The console returns 404 for `/v1` and +`/api/v1`, even with an explicit Bearer token. Deployment routing must send both to Core. The console endpoint and the public application endpoint serve different purposes, even if they share a host. @@ -78,4 +79,4 @@ work. Ordinary metadata reads cannot recover plaintext. A console login failure belongs to console authentication. An upstream 401 on a management request points to the deployment credential or Core connection. A resource deletion conflict must remain visible; it does not authorize an execution call. -Fixed node transports and native Runtime interfaces retain their own authentication. +Node and daemon `/api/v1` routes and native Runtime interfaces retain their own authentication. diff --git a/scripts/openapi-split/main.go b/scripts/openapi-split/main.go index cdcb5db03..d308fbee0 100644 --- a/scripts/openapi-split/main.go +++ b/scripts/openapi-split/main.go @@ -1,4 +1,5 @@ -// Command openapi-split separates Core administration from the project API. +// Command openapi-split separates the project API (/v1), Core administration +// (/core/v1) and machine connections (/api/v1) into their own documents. package main import ( @@ -10,51 +11,61 @@ import ( "gopkg.in/yaml.v3" ) +const ( + projectSurface = iota + managerSurface + runtimeSurface +) + func main() { - if len(os.Args) != 4 { - fmt.Fprintln(os.Stderr, "usage: openapi-split INPUT PROJECT_OUTPUT MANAGER_OUTPUT") + if len(os.Args) != 5 { + fmt.Fprintln(os.Stderr, "usage: openapi-split INPUT PROJECT_OUTPUT MANAGER_OUTPUT RUNTIME_OUTPUT") os.Exit(1) } - if err := run(os.Args[1], os.Args[2], os.Args[3]); err != nil { + if err := run(os.Args[1], os.Args[2], os.Args[3], os.Args[4]); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } -func run(input, projectOutput, managerOutput string) error { +func run(input, projectOutput, managerOutput, runtimeOutput string) error { raw, err := os.ReadFile(input) if err != nil { return err } - var project, manager yaml.Node - if err = yaml.Unmarshal(raw, &project); err != nil { - return err - } - if err = yaml.Unmarshal(raw, &manager); err != nil { - return err + var project, manager, runtime yaml.Node + for _, doc := range []*yaml.Node{&project, &manager, &runtime} { + if err = yaml.Unmarshal(raw, doc); err != nil { + return err + } } - p, m := project.Content[0], manager.Content[0] - filterPaths(field(p, "paths"), false) - filterPaths(field(m, "paths"), true) + p, m, rt := project.Content[0], manager.Content[0], runtime.Content[0] + filterPaths(field(p, "paths"), projectSurface) + filterPaths(field(m, "paths"), managerSurface) + filterPaths(field(rt, "paths"), runtimeSurface) field(m, "basePath").Value = "/" field(field(m, "info"), "title").Value = "Core Extensions" - field(field(m, "info"), "description").Value = "Core extensions outside the upstream Agents API. Environment executor credential operations use project caller authentication. Sandbox administration and node enrollment use separate administrator and node credentials. See each operation's security requirements." + field(field(m, "info"), "description").Value = "Core extensions outside the upstream Agents API. Environment executor credential operations use project caller authentication. Sandbox administration uses the deployment administrator credential. See each operation's security requirements." + field(rt, "basePath").Value = "/" + field(field(rt, "info"), "title").Value = "Core Machine Connections" + field(field(rt, "info"), "description").Value = "Machine connection routes under /api/v1. Sandbox nodes authenticate with a one-use enrollment token or their node credential; Project API keys and the deployment administrator credential are not accepted. See each operation's security requirements." // Retain exactly the definitions referenced by each surface, including shared // error DTOs. Follow nested references instead of duplicating the project schema. pruneDefinitions(p) pruneDefinitions(m) + pruneDefinitions(rt) // Keep unrelated existing project definitions and the generator's formatting. - // Only definitions exclusive to the management surface are removed. + // Only definitions exclusive to the management or machine surfaces are removed. var original yaml.Node if err := yaml.Unmarshal(raw, &original); err != nil { return err } originalDefinitions := field(original.Content[0], "definitions") - projectDefinitions, managerDefinitions := field(p, "definitions"), field(m, "definitions") + projectDefinitions := field(p, "definitions") if originalDefinitions != nil { kept := make([]*yaml.Node, 0, len(originalDefinitions.Content)) for i := 0; i < len(originalDefinitions.Content); i += 2 { key := originalDefinitions.Content[i] - if field(projectDefinitions, key.Value) != nil || field(managerDefinitions, key.Value) == nil { + if field(projectDefinitions, key.Value) != nil || field(field(m, "definitions"), key.Value) == nil && field(field(rt, "definitions"), key.Value) == nil { kept = append(kept, key, originalDefinitions.Content[i+1]) } } @@ -63,7 +74,7 @@ func run(input, projectOutput, managerOutput string) error { for _, out := range []struct { path string doc *yaml.Node - }{{projectOutput, &project}, {managerOutput, &manager}} { + }{{projectOutput, &project}, {managerOutput, &manager}, {runtimeOutput, &runtime}} { if out.path == projectOutput { if err := os.WriteFile(out.path, preserveProjectFormatting(raw, original.Content[0], p), 0644); err != nil { return err @@ -95,10 +106,19 @@ func field(n *yaml.Node, key string) *yaml.Node { } return nil } -func filterPaths(n *yaml.Node, manager bool) { +func surface(path string) int { + switch { + case strings.HasPrefix(path, "/core/v1/"): + return managerSurface + case strings.HasPrefix(path, "/api/v1/"): + return runtimeSurface + } + return projectSurface +} +func filterPaths(n *yaml.Node, want int) { kept := make([]*yaml.Node, 0, len(n.Content)) for i := 0; i < len(n.Content); i += 2 { - if strings.HasPrefix(n.Content[i].Value, "/core/v1/") == manager { + if surface(n.Content[i].Value) == want { kept = append(kept, n.Content[i], n.Content[i+1]) } } diff --git a/scripts/openapi-split/main_test.go b/scripts/openapi-split/main_test.go index 60705f981..2e3586ef2 100644 --- a/scripts/openapi-split/main_test.go +++ b/scripts/openapi-split/main_test.go @@ -13,6 +13,7 @@ func TestSeparatePublicAndAdministrationRoots(t *testing.T) { input := filepath.Join(d, "combined.yaml") project := filepath.Join(d, "project.yaml") manager := filepath.Join(d, "manager.yaml") + machine := filepath.Join(d, "runtime.yaml") raw := `swagger: "2.0" basePath: /v1 info: @@ -31,6 +32,12 @@ paths: "200": schema: $ref: '#/definitions/Node' + /api/v1/sandbox-node/identity: + get: + responses: + "200": + schema: + $ref: '#/definitions/NodeIdentity' definitions: Session: properties: @@ -40,16 +47,27 @@ definitions: properties: error: $ref: '#/definitions/Error' + NodeIdentity: + properties: + error: + $ref: '#/definitions/Error' Error: type: object ` if err := os.WriteFile(input, []byte(raw), 0600); err != nil { t.Fatal(err) } - if err := run(input, project, manager); err != nil { + if err := run(input, project, manager, machine); err != nil { t.Fatal(err) } - for _, check := range []struct{ path, base, own, other, definition, absent string }{{project, "/v1", "/agents/sessions", "/core/v1/sandbox/nodes", "Session", "Node"}, {manager, "/", "/core/v1/sandbox/nodes", "/agents/sessions", "Node", "Session"}} { + for _, check := range []struct{ path, base, own, other, definition, absent string }{ + {project, "/v1", "/agents/sessions", "/core/v1/sandbox/nodes", "Session", "Node"}, + {project, "/v1", "/agents/sessions", "/api/v1/sandbox-node/identity", "Session", "NodeIdentity"}, + {manager, "/", "/core/v1/sandbox/nodes", "/agents/sessions", "Node", "Session"}, + {manager, "/", "/core/v1/sandbox/nodes", "/api/v1/sandbox-node/identity", "Node", "NodeIdentity"}, + {machine, "/", "/api/v1/sandbox-node/identity", "/core/v1/sandbox/nodes", "NodeIdentity", "Node"}, + {machine, "/", "/api/v1/sandbox-node/identity", "/agents/sessions", "NodeIdentity", "Session"}, + } { raw, err := os.ReadFile(check.path) if err != nil { t.Fatal(err) diff --git a/services/agents-api/HOSTED-SANDBOX-MANAGER.md b/services/agents-api/HOSTED-SANDBOX-MANAGER.md index 2c8b6f6d2..20b5620e2 100644 --- a/services/agents-api/HOSTED-SANDBOX-MANAGER.md +++ b/services/agents-api/HOSTED-SANDBOX-MANAGER.md @@ -154,13 +154,26 @@ without rewriting state. Use a newly generated token if an unconsumed one expire For a public paired endpoint, use `install.sh --public-url https://core.example` and an operator-managed TLS reverse proxy preserving Host and WebSocket Upgrade. -The console passes node and daemon credentials unchanged on a fixed route list; -Core authenticates them. No administrator credential is used on these routes. +The proxy routes `/api/v1` directly to Core; see +[Expose Core and Web](../../docs/getting-started/install.md#expose-core-and-web). +Nodes use only these machine connection routes, authenticated by their own +credentials; Web does not forward them and no administrator credential applies: + +| Route | Credential | +| --- | --- | +| `GET /api/v1/sandbox-node/configuration` | Enrollment token, or node credential with `X-Parsar-Node-ID` | +| `POST /api/v1/sandbox-node/enroll` | One-use enrollment token | +| `GET /api/v1/sandbox-node/identity?node_id=` | Node credential | +| WebSocket `GET /api/v1/sandbox-node/connect?node_id=` | Node credential | + +A node and its Core must come from the same distribution. Nodes from releases +that used the removed `/core/v1/sandbox` node paths cannot connect to this Core; +upgrade through the drained procedure in +[Removal and maintenance](#removal-and-maintenance) and enroll new nodes. Manual registration remains available for operator-managed payloads: - Build/install `parsar-sandbox-node` from the same Core release. On the host, -first read `GET /core/v1/sandbox/node/configuration` with the enrollment Bearer +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 schema for the host's socket, runtime paths and network policy. The Runtime image diff --git a/services/agents-api/cmd/server/http_routes.go b/services/agents-api/cmd/server/http_routes.go index 92cd0806f..84fefe2de 100644 --- a/services/agents-api/cmd/server/http_routes.go +++ b/services/agents-api/cmd/server/http_routes.go @@ -27,7 +27,7 @@ func serverHandler(apiHandler http.Handler, daemon *daemonRoutes) http.Handler { mux.Handle("/api/v1/agent-daemon/enroll", daemon.enrollment) mux.Handle("/api/v1/agent-daemon/connection", daemon.connection) if daemon.nodeConnect != nil { - mux.Handle("/core/v1/sandbox/node/connect", daemon.nodeConnect) + mux.Handle("/api/v1/sandbox-node/connect", daemon.nodeConnect) } mux.Handle("/", apiHandler) return api.CanonicalPaths(mux) diff --git a/services/agents-api/cmd/server/http_routes_test.go b/services/agents-api/cmd/server/http_routes_test.go index e4a50ce80..8d0d608e1 100644 --- a/services/agents-api/cmd/server/http_routes_test.go +++ b/services/agents-api/cmd/server/http_routes_test.go @@ -50,8 +50,9 @@ func TestServerHandlerRoutesCanonicalPaths(t *testing.T) { {"/v1/../api/v1/agent-daemon/enroll", "enrollment", "/api/v1/agent-daemon/enroll", ""}, {"/v1/%2E%2E/api/v1/agent-daemon/connection", "connection", "/api/v1/agent-daemon/connection", ""}, {"/api/v1/agent-daemon/%63onnection", "connection", "/api/v1/agent-daemon/connection", ""}, - {"/core/v1/sandbox/node//connect", "node", "/core/v1/sandbox/node/connect", ""}, - {"/core/v1/sandbox/nodes/%2E%2E/node/connect", "node", "/core/v1/sandbox/node/connect", ""}, + {"/api/v1/sandbox-node//connect", "node", "/api/v1/sandbox-node/connect", ""}, + {"/api/v1/agent-daemon/%2E%2E/sandbox-node/connect", "node", "/api/v1/sandbox-node/connect", ""}, + {"/core/v1/sandbox/node/connect", "api", "/core/v1/sandbox/node/connect", ""}, } { seen = nil request := httptest.NewRequest(http.MethodPost, test.target, strings.NewReader(`{"model":"x"}`)) @@ -132,14 +133,14 @@ func TestServerHandlerRawPathsKeepEncodedSeparators(t *testing.T) { for _, test := range []struct{ target, want string }{ {"/v1/x{/..%2F..%2Fapi/v1/agent-daemon/enroll", "400"}, {"/v1/x\"/..%2f..%2fapi/v1/agent-daemon/connection", "400"}, - {"/v1/\xc3\xa9/..%2F..%2Fcore/v1/sandbox/node/connect", "400"}, + {"/v1/\xc3\xa9/..%2F..%2Fapi/v1/sandbox-node/connect", "400"}, {"/v1/x{/..%2F..%2Fcore/v1/sandbox/nodes", "400"}, {"/v1/x{/..%2F..%2Fcore/v1/project-api-keys/x", "400"}, {"/v1/x{/../../core/v1/admin/api-keys/x", "401"}, {"/v1/x\\/..%5C..%5Capi/v1/agent-daemon/ws", "400"}, {"http://example.test/v1/x{/..%252F..%252Fapi/v1/agent-daemon/enroll", "400"}, {"/v1/x{/../../api/v1/agent-daemon/enroll", "204"}, - {"/v1/x{/%2E%2E/%2E%2E/core/v1/sandbox/node/connect", "204"}, + {"/v1/x{/%2E%2E/%2E%2E/api/v1/sandbox-node/connect", "204"}, } { request, err := parseRaw(test.target) if err != nil { @@ -167,7 +168,7 @@ func TestServerHandlerRawPathsKeepEncodedSeparators(t *testing.T) { // reaches the same handler, with the same path, as its canonical form. func FuzzServerHandlerRoutesLikeCanonicalForm(f *testing.F) { for _, seed := range []string{"v1//agents", "v1/x{/..%2F..%2Fapi/v1/agent-daemon/enroll", "api/v1/agent-daemon%2Fenroll", - "v1/\xc3\xa9/../../api/v1/agent-daemon/ws", "core/v1/sandbox/node/%2E%2E/node/connect", "0\"%2F", "api/v1/agent-daemon", + "v1/\xc3\xa9/../../api/v1/agent-daemon/ws", "api/v1/sandbox-node/%2E%2E/sandbox-node/connect", "0\"%2F", "api/v1/agent-daemon", "v1/x\\/..%5C..%5Capi/v1/agent-daemon/connection", "v1/agents/%252F%2e%2E/x", "v1/x{/..%2F..%2Fcore/v1/project-api-keys/x", "core/v1/admin/api-keys/x/%2E%2E/%2E%2E/%2E%2E/%2E%2E/api/v1/agent-daemon/enroll"} { f.Add(seed) diff --git a/services/agents-api/deploy/microsandbox/README.md b/services/agents-api/deploy/microsandbox/README.md index 8402745a9..d4fc5d2ea 100644 --- a/services/agents-api/deploy/microsandbox/README.md +++ b/services/agents-api/deploy/microsandbox/README.md @@ -106,7 +106,7 @@ commands or initial file writes. Initialize microsandbox through Web or `POST /core/v1/sandbox/deployment`, including its required `resources` and immutable `runtime` fields. The standalone node -installer reads `GET /core/v1/sandbox/node/configuration` using an enrollment token, +installer reads `GET /api/v1/sandbox-node/configuration` using an enrollment token, or its retained node credential on a registered reinstall. It verifies the saved release and resources before registration. Local opt-in uses this same path and requires a guest-reachable, non-loopback HTTPS `--public-url`. diff --git a/services/agents-api/internal/api/routing_test.go b/services/agents-api/internal/api/routing_test.go index 85b9cfe27..05a162b1c 100644 --- a/services/agents-api/internal/api/routing_test.go +++ b/services/agents-api/internal/api/routing_test.go @@ -302,7 +302,7 @@ func dirtyVariants(clean string) []string { // credential, so it can never reach another route group or skip its checks. func TestEveryRouteAuthenticatesItsCanonicalPath(t *testing.T) { handler, router, s := routingFixture(t) - selfAuthenticated := map[string]bool{"GET /healthz": false, "POST /core/v1/sandbox/enroll": false, "GET /core/v1/sandbox/node/identity": false, "GET /core/v1/sandbox/node/configuration": false} + selfAuthenticated := map[string]bool{"GET /healthz": false, "POST /api/v1/sandbox-node/enroll": false, "GET /api/v1/sandbox-node/identity": false, "GET /api/v1/sandbox-node/configuration": false} credentials := []http.Header{{}, withHeaders(beta), withHeaders([]string{"Authorization", "Bearer " + routingAdminKey}, beta), withHeaders([]string{"Authorization", "Basic " + routingKey}, beta), withHeaders([]string{"Authorization", "Bearer wrong"}), withHeaders(project), withHeaders(project, []string{"OpenAI-Beta", "agents=v0"}), @@ -757,7 +757,7 @@ var canonicalPathSeeds = []string{ "v1/agents", "v1//agents/a", "v1/x{/..%2F..%2Fcore/v1/sandbox/nodes", "v1/x\"/..%2f..%2fcore/v1/sandbox/deployment", "v1/x\\/..%5C..%5Ccore/v1/sandbox/nodes", "v1/\xc3\xa9/../../core/v1/sandbox/nodes", "v1/%2E%2E/core/v1/sandbox/nodes", "v1/files/..%2F..%2Fv1/skills", "0\"%2F", "core/v1/sandbox/nodes{%2F..%2F..%2F..%2Fv1/agents", "v1/agents/%252F..", - "api/v1/agent-daemon/%2E%2E/%2E%2E/%2E%2E/v1/agents", "v1/x{/%2e./core/v1/sandbox/node/identity", "v1/agents/%7E%5F%2D%41", + "api/v1/agent-daemon/%2E%2E/%2E%2E/%2E%2E/v1/agents", "v1/x{/%2e./api/v1/sandbox-node/identity", "v1/agents/%7E%5F%2D%41", "core/v1/environments/x/executor-credentials/%2E%2E/%2E%2E/%2E%2E/%2E%2E/core/v1/sandbox/nodes", "v1/x{/..%2F..%2Fcore/v1/project-api-keys/x", "core/v1/project-api-keys/x/%2E%2E/%2E%2E/%2E%2E/%2E%2E/v1/agents", "v1/%2E%2E/core/v1/admin/projects/x/", "core/v1/project-api-keys//x/y", diff --git a/services/agents-api/internal/api/sandbox_manager.go b/services/agents-api/internal/api/sandbox_manager.go index 9c87c6875..d5416d532 100644 --- a/services/agents-api/internal/api/sandbox_manager.go +++ b/services/agents-api/internal/api/sandbox_manager.go @@ -35,9 +35,11 @@ func (h *Handler) registerSandboxManagerRoutes(r chi.Router) { if h.sandboxStore == nil { return } - r.Post("/core/v1/sandbox/enroll", h.enrollSandboxNode) - r.Get("/core/v1/sandbox/node/identity", h.sandboxNodeIdentity) - r.Get("/core/v1/sandbox/node/configuration", h.sandboxNodeConfiguration) + // Machine connections authenticate with an enrollment token or node + // credential, never the Core key. + r.Post("/api/v1/sandbox-node/enroll", h.enrollSandboxNode) + r.Get("/api/v1/sandbox-node/identity", h.sandboxNodeIdentity) + r.Get("/api/v1/sandbox-node/configuration", h.sandboxNodeConfiguration) if h.deploymentAuth == nil { return } @@ -191,15 +193,15 @@ func (h *Handler) createSandboxEnrollment(w http.ResponseWriter, r *http.Request } // @Summary Enroll a sandbox node -// @Description Core deployment extension. Does not grant project resource access. Responses contain only explicit safe fields. -// @Tags Sandbox Manager +// @Description Node machine connection. Consumes a one-use enrollment token; grants no project or administrator access. Responses contain only explicit safe fields. +// @Tags Sandbox Node // @Produce json // @Security NodeEnrollmentAuth // @Accept json // @Param body body store.RuntimeNodeEnrollment true "Request" // @Success 201 {object} store.RuntimeNodeIdentity // @Failure 400,401,404,409,500,503 {object} v1.ErrorResponse -// @Router /core/v1/sandbox/enroll [post] +// @Router /api/v1/sandbox-node/enroll [post] func (h *Handler) enrollSandboxNode(w http.ResponseWriter, r *http.Request) { token, ok := sandboxBearer(r) if !ok { @@ -224,14 +226,14 @@ func (h *Handler) enrollSandboxNode(w http.ResponseWriter, r *http.Request) { } // @Summary Recover an enrolled sandbox node identity and observe its readiness -// @Description Core deployment extension. Does not grant project resource access. Responses contain only explicit safe fields. -// @Tags Sandbox Manager +// @Description Node machine connection. Authenticates with the retained node credential; grants no project or administrator access. Responses contain only explicit safe fields. +// @Tags Sandbox Node // @Produce json // @Security NodeAuth // @Param node_id query string true "Sandbox node UUID" // @Success 200 {object} store.RuntimeNodeStatus // @Failure 400,401,404,409,500,503 {object} v1.ErrorResponse -// @Router /core/v1/sandbox/node/identity [get] +// @Router /api/v1/sandbox-node/identity [get] func (h *Handler) sandboxNodeIdentity(w http.ResponseWriter, r *http.Request) { token, ok := sandboxBearer(r) if !ok { diff --git a/services/agents-api/internal/api/sandbox_manager_test.go b/services/agents-api/internal/api/sandbox_manager_test.go index 73412b29e..69126a6b3 100644 --- a/services/agents-api/internal/api/sandbox_manager_test.go +++ b/services/agents-api/internal/api/sandbox_manager_test.go @@ -90,8 +90,8 @@ func TestSandboxEnrollmentCapacityIsAdministratorOnly(t *testing.T) { {"/core/v1/sandbox/enrollment-tokens", "administrator", `{"max_active":0}`}, {"/core/v1/sandbox/enrollment-tokens", "administrator", `{"max_active":9,"max_retained":8}`}, {"/core/v1/sandbox/enrollment-tokens", "administrator", `{"max_retained":1000001}`}, - {"/core/v1/sandbox/enroll", "one-use", `{"max_active":100}`}, - {"/core/v1/sandbox/enroll", "one-use", `{"max_retained":100}`}, + {"/api/v1/sandbox-node/enroll", "one-use", `{"max_active":100}`}, + {"/api/v1/sandbox-node/enroll", "one-use", `{"max_retained":100}`}, } { request := httptest.NewRequest(http.MethodPost, test.path, strings.NewReader(test.body)) request.Header.Set("Authorization", "Bearer "+test.token) @@ -101,4 +101,14 @@ func TestSandboxEnrollmentCapacityIsAdministratorOnly(t *testing.T) { t.Fatal(test.path, response.Code, response.Body.String()) } } + // Node routes moved to /api/v1/sandbox-node; the old /core paths are gone. + for _, test := range []struct{ method, path string }{{"POST", "/core/v1/sandbox/enroll"}, {"GET", "/core/v1/sandbox/node/identity"}, {"GET", "/core/v1/sandbox/node/configuration"}} { + request := httptest.NewRequest(test.method, test.path, nil) + request.Header.Set("Authorization", "Bearer administrator") + response := httptest.NewRecorder() + h.ServeHTTP(response, request) + if response.Code != http.StatusNotFound { + t.Fatal(test.path, response.Code) + } + } } diff --git a/services/agents-api/internal/api/sandbox_node_configuration.go b/services/agents-api/internal/api/sandbox_node_configuration.go index ccda0e1f9..e8a973a68 100644 --- a/services/agents-api/internal/api/sandbox_node_configuration.go +++ b/services/agents-api/internal/api/sandbox_node_configuration.go @@ -7,13 +7,13 @@ import ( // @Summary Read the active configuration for node installation // @Description Authenticates with an unconsumed enrollment token, or a retained node credential with X-Parsar-Node-ID. Does not consume the token or expose E2B credentials. Node files cannot override this specification. -// @Tags Sandbox Manager +// @Tags Sandbox Node // @Produce json // @Security NodeEnrollmentAuth // @Param X-Parsar-Node-ID header string false "Retained node UUID" // @Success 200 {object} store.RuntimeNodeConfiguration // @Failure 400,401,409,500,503 {object} v1.ErrorResponse -// @Router /core/v1/sandbox/node/configuration [get] +// @Router /api/v1/sandbox-node/configuration [get] func (h *Handler) sandboxNodeConfiguration(w http.ResponseWriter, r *http.Request) { token, ok := sandboxBearer(r) if !ok || len(r.Header.Values("X-Parsar-Node-ID")) > 1 { diff --git a/services/agents-api/internal/sandbox/node/agent.go b/services/agents-api/internal/sandbox/node/agent.go index 142f0903d..341fbeb44 100644 --- a/services/agents-api/internal/sandbox/node/agent.go +++ b/services/agents-api/internal/sandbox/node/agent.go @@ -159,7 +159,7 @@ func (a *agent) health(ctx context.Context, host *hostHealthSampler) (Health, er return h, e } func (a *agent) connect(ctx context.Context) error { - endpointURL, err := endpoint(a.config.CoreURL, "/core/v1/sandbox/node/connect") + endpointURL, err := endpoint(a.config.CoreURL, "/api/v1/sandbox-node/connect") if err != nil { return err } diff --git a/services/agents-api/internal/sandbox/node/capacity_test.go b/services/agents-api/internal/sandbox/node/capacity_test.go index b1b113449..4a21dd357 100644 --- a/services/agents-api/internal/sandbox/node/capacity_test.go +++ b/services/agents-api/internal/sandbox/node/capacity_test.go @@ -16,7 +16,7 @@ func TestCapacityRefreshUsesAuthenticatedCoreIdentity(t *testing.T) { var stored StoredIdentity approved := EnrollmentResponse{NodeID: id.NodeID, InstallationID: id.InstallationID, Provider: id.Provider, MaxActive: 2, MaxRetained: 8} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/core/v1/sandbox/node/identity" || r.URL.Query().Get("node_id") != id.NodeID || r.Header.Get("Authorization") != "Bearer "+stored.Credential { + if r.URL.Path != "/api/v1/sandbox-node/identity" || r.URL.Query().Get("node_id") != id.NodeID || r.Header.Get("Authorization") != "Bearer "+stored.Credential { t.Error("identity refresh lacked retained authority") w.WriteHeader(401) return diff --git a/services/agents-api/internal/sandbox/node/enrollment.go b/services/agents-api/internal/sandbox/node/enrollment.go index 0b5cfaef3..43be11b9a 100644 --- a/services/agents-api/internal/sandbox/node/enrollment.go +++ b/services/agents-api/internal/sandbox/node/enrollment.go @@ -31,7 +31,7 @@ func Enroll(ctx context.Context, coreURL, dir, token string, input EnrollmentReq input.Provider, input.BackendFingerprint = stored.Identity.Provider, stored.Identity.BackendFingerprint client := &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} // This also recovers a consumed registration whose success response was lost. - target, err := endpoint(coreURL, "/core/v1/sandbox/node/identity") + target, err := endpoint(coreURL, "/api/v1/sandbox-node/identity") if err != nil { return StoredIdentity{}, err } @@ -46,7 +46,7 @@ func Enroll(ctx context.Context, coreURL, dir, token string, input EnrollmentReq return retainEnrollment(dir, stored, out) } } - target, err = endpoint(coreURL, "/core/v1/sandbox/enroll") + target, err = endpoint(coreURL, "/api/v1/sandbox-node/enroll") if err != nil { return StoredIdentity{}, err } @@ -117,7 +117,7 @@ func RefreshIdentity(ctx context.Context, dir string) (StoredIdentity, error) { if err != nil { return StoredIdentity{}, err } - target, err := endpoint(stored.CoreURL, "/core/v1/sandbox/node/identity") + target, err := endpoint(stored.CoreURL, "/api/v1/sandbox-node/identity") if err != nil { return StoredIdentity{}, err } diff --git a/services/agents-api/internal/sandbox/node/node_test.go b/services/agents-api/internal/sandbox/node/node_test.go index f4b2ca911..aa586e606 100644 --- a/services/agents-api/internal/sandbox/node/node_test.go +++ b/services/agents-api/internal/sandbox/node/node_test.go @@ -227,7 +227,7 @@ func TestEnrollmentLostResponseRecoversWithPersistedCredential(t *testing.T) { enrollments := 0 registered := false server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/core/v1/sandbox/node/identity" { + if r.URL.Path == "/api/v1/sandbox-node/identity" { if !registered || r.Header.Get("Authorization") != "Bearer "+stored.Credential { w.WriteHeader(http.StatusUnauthorized) return @@ -235,7 +235,7 @@ func TestEnrollmentLostResponseRecoversWithPersistedCredential(t *testing.T) { _ = json.NewEncoder(w).Encode(EnrollmentResponse{NodeID: id.NodeID, InstallationID: id.InstallationID, Provider: id.Provider, MaxActive: 2, MaxRetained: 8}) return } - if r.URL.Path != "/core/v1/sandbox/enroll" || r.Header.Get("Authorization") != "Bearer enrollment" { + if r.URL.Path != "/api/v1/sandbox-node/enroll" || r.Header.Get("Authorization") != "Bearer enrollment" { w.WriteHeader(http.StatusUnauthorized) return } @@ -279,12 +279,12 @@ func TestEnrollmentLostResponseRecoversWithPersistedCredential(t *testing.T) { func TestCoreURLRejectsRemotePlaintextAndCredentials(t *testing.T) { for _, raw := range []string{"http://example.com", "https://user:pass@example.com", "https://example.com/?token=x", "https://example.com/path"} { - if _, err := endpoint(raw, "/core/v1/sandbox/enroll"); err == nil { + if _, err := endpoint(raw, "/api/v1/sandbox-node/enroll"); err == nil { t.Fatalf("accepted %q", raw) } } for _, raw := range []string{"https://core.example.test:9443", "http://127.0.0.1:8080", "http://[::1]:8080"} { - if _, err := endpoint(raw, "/core/v1/sandbox/enroll"); err != nil { + if _, err := endpoint(raw, "/api/v1/sandbox-node/enroll"); err != nil { t.Fatalf("rejected %q: %v", raw, err) } } diff --git a/services/core-console/auth_test.go b/services/core-console/auth_test.go index ca868dde7..3888aa2b9 100644 --- a/services/core-console/auth_test.go +++ b/services/core-console/auth_test.go @@ -248,34 +248,14 @@ func TestConcurrentAccountSetupAcrossInstances(t *testing.T) { } } -func TestAccountSecureCookieExpiryAndExplicitCredentials(t *testing.T) { - var calls atomic.Int32 - c := accountConsoleConfig(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - calls.Add(1) - if r.Header.Get("Authorization") != "Bearer transport-credential" { - t.Error("explicit credential was replaced") - } - w.WriteHeader(200) - })) +func TestAccountSecureCookieExpiry(t *testing.T) { + c := accountConsoleConfig(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("expired session reached Core") })) c.origin = "https://127.0.0.1:8080" h := accountConsole(t, c) cookie := setupAccount(t, h) if !cookie.Secure { t.Fatal("HTTPS session cookie omitted Secure") } - for _, path := range []string{"/core/v1/sandbox/enroll", "/api/v1/agent-daemon/enroll"} { - r := httptest.NewRequest("POST", path, nil) - r.Host = h.host - r.Header.Set("Authorization", "Bearer transport-credential") - w := httptest.NewRecorder() - h.ServeHTTP(w, r) - if w.Code != 200 { - t.Errorf("explicit credential transport blocked: %d", w.Code) - } - } - if calls.Load() != 2 { - t.Fatal("explicit transport requests were not forwarded") - } h.auth.mu.Lock() for key, session := range h.auth.sessions { session.expires = time.Now().Add(-time.Second) diff --git a/services/core-console/distribution_test.go b/services/core-console/distribution_test.go index 541305a66..f355ec246 100644 --- a/services/core-console/distribution_test.go +++ b/services/core-console/distribution_test.go @@ -2,8 +2,6 @@ package main import ( "encoding/json" - "io" - "net/http" "net/http/httptest" "net/url" "os" @@ -12,57 +10,6 @@ import ( "testing" ) -func TestEnvironmentConnectionCredentialsStayScoped(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - want := "Bearer project-token" - if r.URL.Path == "/api/v1/agent-daemon/enroll" || r.URL.Path == "/api/v1/agent-daemon/connection" { - want = "Bearer executor-key" - } - if r.Header.Get("Authorization") != want { - t.Errorf("incorrect authority on %s", r.URL.Path) - } - _, _ = io.WriteString(w, "{}") - })) - defer upstream.Close() - u, _ := url.Parse(upstream.URL) - dist := t.TempDir() - if err := os.WriteFile(filepath.Join(dist, "index.html"), []byte("console"), 0600); err != nil { - t.Fatal(err) - } - h, err := newConsole(config{origin: testOrigin, upstream: u, dist: dist, password: "private-console-password", adminToken: "server-admin"}) - if err != nil { - t.Fatal(err) - } - defer h.Close() - server := httptest.NewServer(h) - defer server.Close() - for _, tc := range []struct { - method, path, bearer string - status int - }{ - {"POST", "/core/v1/environments/env/executor-credentials", "", 404}, - {"POST", "/core/v1/environments/env/executor-credentials", "project-token", 404}, - {"DELETE", "/core/v1/environments/env/executor-credentials/key", "project-token", 404}, - {"GET", "/core/v1/environments/env/executor-credentials", "", 404}, - {"POST", "/api/v1/agent-daemon/enroll", "executor-key", 200}, - {"POST", "/api/v1/agent-daemon/enroll", "", 403}, - {"GET", "/api/v1/agent-daemon/connection?environment_id=env", "executor-key", 200}, - {"GET", "/api/v1/agent-daemon/connection?environment_id=env", "", 403}, - {"POST", "/api/v1/agent-daemon/connection", "executor-key", 403}, - } { - req := consoleRequest(t, server, tc.method, tc.path) - if tc.bearer != "" { - req.Header.Set("Authorization", "Bearer "+tc.bearer) - req.Header.Del("Origin") - req.Header.Del("Sec-Fetch-Site") - } - response, _ := responseBody(t, server, req) - if response.StatusCode != tc.status { - t.Errorf("%s %s: %d", tc.method, tc.path, response.StatusCode) - } - } -} - func TestOfflineArtifactsAreManifestAllowlisted(t *testing.T) { dist, payload := t.TempDir(), t.TempDir() for _, item := range []struct{ root, name, body string }{{dist, "index.html", "console"}, {payload, "node-install.pyz", "bootstrap"}} { diff --git a/services/core-console/node_installation.go b/services/core-console/node_installation.go index c2f03405b..2ab02e3a3 100644 --- a/services/core-console/node_installation.go +++ b/services/core-console/node_installation.go @@ -106,17 +106,3 @@ func (h *console) serveConsoleConfiguration(w http.ResponseWriter, _ *http.Reque NodeInstallerSHA256 string `json:"node_installer_sha256"` }{h.adminToken != "", h.adminToken != "", h.nodePayload != nil, h.nodeInstallerDigest}) } - -// Node and Runtime credentials pass through unchanged to Core authentication. -// Administrator credentials are never substituted on these transport routes. -func nodeTransportRequest(r *http.Request) bool { - switch r.URL.Path { - case "/core/v1/sandbox/enroll", "/api/v1/agent-daemon/enroll", "/api/v1/agent-daemon/bootstrap": - return r.Method == http.MethodPost && r.Header.Get("Upgrade") == "" - case "/core/v1/sandbox/node/configuration", "/core/v1/sandbox/node/identity", "/api/v1/agent-daemon/device-status", "/api/v1/agent-daemon/connection": - return r.Method == http.MethodGet && r.Header.Get("Upgrade") == "" - case "/core/v1/sandbox/node/connect", "/api/v1/agent-daemon/ws": - return r.Method == http.MethodGet && strings.EqualFold(r.Header.Get("Upgrade"), "websocket") - } - return false -} diff --git a/services/core-console/node_installation_test.go b/services/core-console/node_installation_test.go index d0eaed0a3..5d7b58954 100644 --- a/services/core-console/node_installation_test.go +++ b/services/core-console/node_installation_test.go @@ -1,7 +1,6 @@ package main import ( - "github.com/gorilla/websocket" "io" "net/http" "net/http/httptest" @@ -13,18 +12,11 @@ import ( "testing" ) -func TestPairedConsoleKeepsAdminAndNodeCredentialsSeparated(t *testing.T) { +func TestPairedConsoleProxiesOnlyAdministration(t *testing.T) { var calls atomic.Int32 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) - want := "Bearer server-admin" - if nodeTransportRequest(r) { - if r.Header.Get("X-Core-Console-Actor") != "" { - t.Error("transport retained untrusted administrator actor") - } - want = "Bearer node-token" - } - if r.Header.Get("Authorization") != want { + if r.Header.Get("Authorization") != "Bearer server-admin" { t.Errorf("incorrect upstream authority for %s", r.URL.Path) } _, _ = io.WriteString(w, "{}") @@ -56,10 +48,8 @@ func TestPairedConsoleKeepsAdminAndNodeCredentialsSeparated(t *testing.T) { {"PATCH", "/core/v1/sandbox/deployment/maintenance", "none", 401}, {"GET", "/core/v1/sandbox/nodes", "node", 401}, {"GET", "/core/v1/admin/projects", "basic", 200}, - {"POST", "/core/v1/sandbox/enroll", "node", 200}, - {"POST", "/core/v1/sandbox/enroll", "basic", 403}, - {"POST", "/api/v1/agent-daemon/bootstrap", "node", 200}, - {"POST", "/api/v1/agent-daemon/unknown", "node", 403}, + {"POST", "/core/v1/sandbox/enroll", "basic", 404}, + {"POST", "/api/v1/sandbox-node/enroll", "node", 404}, {"GET", "/console/config", "basic", 200}, {"GET", "/console/config", "none", 401}, {"GET", "/node-install/node-install.pyz", "none", 200}, @@ -87,7 +77,7 @@ func TestPairedConsoleKeepsAdminAndNodeCredentialsSeparated(t *testing.T) { t.Fatal("paired mode missing") } } - if calls.Load() != 6 { + if calls.Load() != 4 { t.Fatalf("unexpected upstream requests: %d", calls.Load()) } r := consoleRequest(t, server, "POST", "/core/v1/sandbox/deployment") @@ -97,50 +87,3 @@ func TestPairedConsoleKeepsAdminAndNodeCredentialsSeparated(t *testing.T) { t.Fatal("cross-origin setup reached Core") } } - -func TestPairedConsoleProxiesAuthenticatedNodeWebSockets(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") != "Bearer node-token" { - t.Error("node authority replaced") - http.Error(w, "unauthorized", 401) - return - } - upgrader := websocket.Upgrader{} - ws, err := upgrader.Upgrade(w, r, nil) - if err != nil { - t.Error(err) - return - } - defer ws.Close() - if err := ws.WriteMessage(websocket.TextMessage, []byte("node-connected")); err != nil { - t.Error(err) - } - })) - defer upstream.Close() - u, _ := url.Parse(upstream.URL) - dist := t.TempDir() - if err := os.WriteFile(filepath.Join(dist, "index.html"), []byte("console"), 0600); err != nil { - t.Fatal(err) - } - h, err := newConsole(config{origin: testOrigin, upstream: u, dist: dist, password: "console-password", adminToken: "server-admin"}) - if err != nil { - t.Fatal(err) - } - defer h.Close() - server := httptest.NewServer(h) - defer server.Close() - for _, path := range []string{"/core/v1/sandbox/node/connect", "/api/v1/agent-daemon/ws"} { - ws, response, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http")+path, http.Header{"Host": {"127.0.0.1:8080"}, "Authorization": {"Bearer node-token"}}) - if err != nil { - if response != nil { - t.Fatalf("proxy handshake %d: %v", response.StatusCode, err) - } - t.Fatal(err) - } - _, data, err := ws.ReadMessage() - ws.Close() - if err != nil || string(data) != "node-connected" { - t.Fatalf("proxy message=%q error=%v", data, err) - } - } -} diff --git a/services/core-console/project_proxy_test.go b/services/core-console/project_proxy_test.go index 344ead2b6..45edcb741 100644 --- a/services/core-console/project_proxy_test.go +++ b/services/core-console/project_proxy_test.go @@ -7,22 +7,28 @@ import ( "testing" ) -func TestPublicAPINeverPassesThroughConsole(t *testing.T) { - c := accountConsoleConfig(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("public API reached Core through console") })) +// Applications (/v1) and machines (/api/v1) reach Core directly through the +// reverse proxy; the console forwards neither, whatever credential is presented. +func TestCoreDirectRoutesNeverPassThroughConsole(t *testing.T) { + c := accountConsoleConfig(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("direct Core route reached Core through console") })) h := accountConsole(t, c) cookie := setupAccount(t, h) - for _, path := range []string{"/v1", "/v1/agents", "/v1/agents/sessions", "/v1/files/file/content", "/core/v1/environments/env/executor-credentials", "/console/api-keys"} { + for _, path := range []string{"/v1", "/v1/agents", "/v1/agents/sessions", "/v1/files/file/content", "/core/v1/environments/env/executor-credentials", "/console/api-keys", + "/api/v1", "/api/v1/sandbox-node/enroll", "/api/v1/sandbox-node/configuration", "/api/v1/sandbox-node/connect", "/api/v1/agent-daemon/enroll", "/api/v1/agent-daemon/ws"} { for _, method := range []string{"GET", "POST", "DELETE"} { - for _, authorization := range []string{"", "Bearer project-key", "Bearer deployment-token", "Basic YWRtaW46cGFzc3dvcmQ="} { - r := httptest.NewRequest(method, path, strings.NewReader(`{}`)) - r.Host = h.host - r.Header.Set("Origin", h.origin) - r.Header.Set("Authorization", authorization) - r.AddCookie(cookie) - w := httptest.NewRecorder() - h.ServeHTTP(w, r) - if w.Code != 404 { - t.Errorf("%s %s = %d", method, path, w.Code) + for _, authorization := range []string{"", "Bearer project-key", "Bearer node-token", "Basic YWRtaW46cGFzc3dvcmQ="} { + for _, upgrade := range []string{"", "websocket"} { + r := httptest.NewRequest(method, path, strings.NewReader(`{}`)) + r.Host = h.host + r.Header.Set("Origin", h.origin) + r.Header.Set("Authorization", authorization) + r.Header.Set("Upgrade", upgrade) + r.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 404 { + t.Errorf("%s %s upgrade=%q = %d", method, path, upgrade, w.Code) + } } } } diff --git a/services/core-console/sandbox_admin.go b/services/core-console/sandbox_admin.go index d0ccff43a..b012f5940 100644 --- a/services/core-console/sandbox_admin.go +++ b/services/core-console/sandbox_admin.go @@ -5,12 +5,19 @@ import ( "strings" ) -func publicAPIRequest(r *http.Request) bool { - return r.URL.Path == "/v1" || strings.HasPrefix(r.URL.Path, "/v1/") +// coreDirectRequest reports the application (/v1) and machine connection +// (/api/v1) namespaces. The reverse proxy routes them straight to Core; the +// console never forwards them, whatever credential they carry. +func coreDirectRequest(r *http.Request) bool { + for _, prefix := range []string{"/v1", "/api/v1"} { + if r.URL.Path == prefix || strings.HasPrefix(r.URL.Path, prefix+"/") { + return true + } + } + return false } -// Only deployment administration is exposed through the console. Enrollment and -// node identity/transport calls use the separate pass-through with their own credentials. +// Only deployment administration is exposed through the console. func sandboxAdminRequest(r *http.Request) bool { const base = "/core/v1/sandbox/" if !strings.HasPrefix(r.URL.Path, base) { @@ -38,8 +45,3 @@ func sandboxAdminRequest(r *http.Request) bool { } return false } - -func explicitBearer(r *http.Request) bool { - parts := strings.Fields(r.Header.Get("Authorization")) - return len(r.Header.Values("Authorization")) == 1 && len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") -} diff --git a/services/core-console/server.go b/services/core-console/server.go index f717f41f6..921123311 100644 --- a/services/core-console/server.go +++ b/services/core-console/server.go @@ -78,14 +78,9 @@ func newConsole(c config) (*console, error) { r.Out.Header.Del("Cookie") r.Out.Header.Del("Origin") r.Out.Header.Del("Referer") - r.Out.Header.Del("X-Core-Console-Actor") - if nodeTransportRequest(r.In) { - r.Out.Header.Set("Authorization", r.In.Header.Get("Authorization")) - } else { - r.Out.Header.Set("Authorization", "Bearer "+c.adminToken) - actor, _ := r.In.Context().Value(consoleActorContextKey{}).(string) - r.Out.Header.Set("X-Core-Console-Actor", actor) - } + r.Out.Header.Set("Authorization", "Bearer "+c.adminToken) + actor, _ := r.In.Context().Value(consoleActorContextKey{}).(string) + r.Out.Header.Set("X-Core-Console-Actor", actor) }, ModifyResponse: func(r *http.Response) error { // Never send a browser to a different origin with its cached login. @@ -128,18 +123,10 @@ func (h *console) ServeHTTP(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, "ok\n") return } - if publicAPIRequest(r) || r.URL.Path == "/console/api-keys" || strings.HasPrefix(r.URL.Path, "/console/api-keys/") || strings.HasPrefix(r.URL.Path, "/core/v1/environments/") { + if coreDirectRequest(r) || r.URL.Path == "/console/api-keys" || strings.HasPrefix(r.URL.Path, "/console/api-keys/") || strings.HasPrefix(r.URL.Path, "/core/v1/environments/") { http.NotFound(w, r) return } - if nodeTransportRequest(r) { - if r.Host != h.host || !safePath(r.URL.Path) || r.URL.IsAbs() || !explicitBearer(r) || !h.validOriginHeaders(r) { - http.Error(w, "Invalid node transport request", http.StatusForbidden) - return - } - h.proxy.ServeHTTP(w, r) - return - } if h.nodePayload != nil && strings.HasPrefix(r.URL.Path, "/node-install/") { if r.Host != h.host || !safePath(r.URL.Path) || r.URL.IsAbs() { http.NotFound(w, r) From dfbe7d7f7756c4195b099b60ae8ab496603aa71e Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:19:33 +0000 Subject: [PATCH 2/2] Document the proxy upgrade step and name failures on the machine routes Address the blind review of the /api/v1 node transport move: - The upgrade steps now say to route /api/v1/*, including WebSocket upgrades, directly to Core when the release goes live; the old routing breaks every Runtime daemon connection, not only node enrollment. - The pre-specification drain needs a Core built from a commit with #114 that still serves the old node paths (for example 7b66be23); drain there, then upgrade. Re-enrollment stops the old node service and moves its identity directory aside first. - CONTRIBUTING notes the executor-credential routes that still go to Core. - The node installer reports 404 as a routing problem, 409 as maintenance or a deployment conflict and keeps the proxy hint for connection errors; the self-hosted connection check reports 404 as a routing problem. - openapi-split keeps only the security schemes each non-public document uses; the public openapi.yaml is unchanged. - Console tests cover encoded and doubled separators toward /api/v1. --- CONTRIBUTING.md | 9 ++-- contracts/agents-api/runtime.openapi.yaml | 8 ---- contracts/agents-api/sandbox-deployment.md | 21 +++++---- .../agents-api/sandbox-manager.openapi.yaml | 8 ---- deploy/install/node_spec.py | 8 +++- deploy/install/self_hosted_install.py | 3 ++ deploy/install/test_node_spec.py | 8 ++++ deploy/install/test_self_hosted_connection.py | 1 + docs/getting-started/install.md | 13 +++--- docs/getting-started/operations.md | 43 ++++++++++++------- scripts/openapi-split/main.go | 30 +++++++++++++ scripts/openapi-split/main_test.go | 32 ++++++++++++++ services/agents-api/HOSTED-SANDBOX-MANAGER.md | 6 +-- services/core-console/project_proxy_test.go | 20 +++++++++ 14 files changed, 158 insertions(+), 52 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d857b4e33..ab7b78ded 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -192,8 +192,9 @@ coverage ledgers and official SDK/raw HTTP tests when changing API behavior. Run `make openapi` after handler annotation changes. It reuses the original Core-only swaggo v1.16.4 generator, then separates project paths under `/v1` from `/core/v1` administration in `sandbox-manager.openapi.yaml` and `/api/v1` machine -connections in `runtime.openapi.yaml` (both base path `/`). All generated schemas -remain free of product routes. +connections in `runtime.openapi.yaml` (both base path `/`), each keeping only the +security schemes its operations use. All generated schemas remain free of product +routes. Core changes must retain the independent build and official-client workflow. Native adapter changes require their applicable build/check targets and live provider @@ -1840,7 +1841,9 @@ WebSocket requests; Web forwards no node or daemon transport. The installer mounts only the administrator key into Web and only its digest into Core. The browser receives safe capability flags, never that key. The deployment's TLS reverse proxy routes `/v1` (applications) and `/api/v1` (nodes and Runtime -daemons, with their own credentials) directly to Core and everything else to Web. +daemons, with their own credentials) directly to Core and everything else to Web, +except the Project-authenticated `/core/v1/environments/*/executor-credentials` +routes, which also go straight to Core. Nodes and Core come from one distribution; older nodes using the removed `/core/v1/sandbox` node paths cannot connect and are replaced through the drained upgrade and re-enrollment workflow. diff --git a/contracts/agents-api/runtime.openapi.yaml b/contracts/agents-api/runtime.openapi.yaml index e4432f4ff..08d0e56ea 100644 --- a/contracts/agents-api/runtime.openapi.yaml +++ b/contracts/agents-api/runtime.openapi.yaml @@ -276,14 +276,6 @@ schemes: - http - https securityDefinitions: - BearerAuth: - in: header - name: Authorization - type: apiKey - DeploymentAdminAuth: - in: header - name: Authorization - type: apiKey NodeAuth: in: header name: Authorization diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 916dd06d9..404a8fb24 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -234,15 +234,18 @@ automatically adopt the old database or delete its resources; removing an environment variable alone does not complete that migration. A Web-managed Docker or microsandbox selection saved before specifications has -the empty migration default. Core loads it for draining only. Retained nodes -without a recorded digest or generation authenticate while the deployment is in -this state. GET omits `specification` and `specification_digest`. Fresh hosted -sandboxes are refused with the same error as during maintenance. Enrollment -tokens return 409 `sandbox_deployment_conflict`; node configuration reads and -enrollment are refused. -The ordinary maintenance, archive and PUT sequence records a specification and -retires those nodes. Those nodes use the removed `/core/v1/sandbox` node paths, so -their drain needs a Core release that still serves them. See the +the empty migration default. Core loads it for draining only. GET omits +`specification` and `specification_digest`. Fresh hosted sandboxes are refused +with the same error as during maintenance. Enrollment tokens return 409 +`sandbox_deployment_conflict`; node configuration reads and enrollment are refused. +Retained nodes without a recorded digest or generation authenticate while the +deployment is in this state, but those nodes use the removed `/core/v1/sandbox` +node paths. They can reach only a Core that has this drain mode (pull request +#114) and still serves those paths. No such release is published: build one from +main commit `7b66be236a627246c85658722314285e6b39d9b8`, or another commit with +#114 but without `/api/v1/sandbox-node`. Run the maintenance and archive steps there. On this +release, the drain mode matters only for the PUT that records a specification after +draining elsewhere; that PUT retires those nodes. See the [operator upgrade notes](../../docs/getting-started/operations.md#data-and-upgrades). Unit tests, database tests and provider inspection are separate from live diff --git a/contracts/agents-api/sandbox-manager.openapi.yaml b/contracts/agents-api/sandbox-manager.openapi.yaml index b419d1cfc..0e2eaa265 100644 --- a/contracts/agents-api/sandbox-manager.openapi.yaml +++ b/contracts/agents-api/sandbox-manager.openapi.yaml @@ -5925,12 +5925,4 @@ securityDefinitions: in: header name: Authorization type: apiKey - NodeAuth: - in: header - name: Authorization - type: apiKey - NodeEnrollmentAuth: - in: header - name: Authorization - type: apiKey swagger: "2.0" diff --git a/deploy/install/node_spec.py b/deploy/install/node_spec.py index 7f8c0ce58..fffb44877 100644 --- a/deploy/install/node_spec.py +++ b/deploy/install/node_spec.py @@ -103,8 +103,14 @@ def fetch(args, token, retained, open_request, allow_enrollment=False): if len(raw) > 16384: raise ValueError() data = validate(json.loads(raw), args) + except urllib.error.HTTPError as error: + if error.code == 404: + raise SpecificationError("Core node configuration was not found (HTTP 404); route /api/v1 on the Core origin directly to Core, not to Web") from None + if error.code == 409: + raise SpecificationError("Core refused node configuration (HTTP 409): the deployment is in maintenance or conflicts with this node's retained specification; inspect the deployment before retrying") from None + raise SpecificationError("Core rejected the node configuration read (HTTP " + str(error.code) + "); verify the retained or enrollment credential") from None except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.IncompleteRead): - raise SpecificationError("Cannot read Core node configuration; verify the Core origin, that the reverse proxy routes /api/v1 to Core, and the retained or enrollment credential") from None + raise SpecificationError("Cannot reach Core node configuration; verify the Core origin and that the reverse proxy routes /api/v1 to Core") from None except (ValueError, AttributeError): raise SpecificationError("Core returned invalid node configuration") from None if retained is not None: diff --git a/deploy/install/self_hosted_install.py b/deploy/install/self_hosted_install.py index 599454e6a..88fc83864 100644 --- a/deploy/install/self_hosted_install.py +++ b/deploy/install/self_hosted_install.py @@ -162,6 +162,9 @@ def deadline_expired(_signal, _frame): return detail = 'Core reports this Environment disconnected' except urllib.error.HTTPError as error: + if error.code == 404: + raise InstallError('Core connection check was not found (HTTP 404); route /api/v1 on the Core origin' + ' directly to Core, not to Web.' + guidance) from None if error.code not in (408, 429, 500, 502, 503, 504): raise InstallError('Core connection check rejected (HTTP ' + str(error.code) + '); verify the exact Environment and active executor key.' + guidance) from None diff --git a/deploy/install/test_node_spec.py b/deploy/install/test_node_spec.py index 1fb150840..2c0a30e63 100644 --- a/deploy/install/test_node_spec.py +++ b/deploy/install/test_node_spec.py @@ -55,6 +55,14 @@ def test_partial_registration_can_use_enrollment_after_unauthenticated_identity( node_spec.fetch(self.args, token, self.retained, opener, allow_enrollment=allowed) self.assertEqual(opener.call_count, 1) + def test_failures_name_their_cause(self): + for failure, message in ((urllib.error.HTTPError("https://core.example", 404, "", {}, None), "route /api/v1"), + (urllib.error.HTTPError("https://core.example", 409, "", {}, None), "maintenance"), + (urllib.error.HTTPError("https://core.example", 401, "", {}, None), "credential"), + (urllib.error.URLError("refused"), "reverse proxy routes /api/v1")): + with self.assertRaisesRegex(node_spec.SpecificationError, message): + node_spec.fetch(self.args, "once", None, mock.Mock(side_effect=failure)) + def test_changed_generation_or_specification_rejects_retained_node(self): for change in (lambda: self.data.update(generation=4), lambda: self.spec["resources"].update(cpus=8)): original = copy.deepcopy(self.data) diff --git a/deploy/install/test_self_hosted_connection.py b/deploy/install/test_self_hosted_connection.py index 9fba7e140..8107aee69 100644 --- a/deploy/install/test_self_hosted_connection.py +++ b/deploy/install/test_self_hosted_connection.py @@ -47,6 +47,7 @@ def test_permanent_denial_does_not_retry_or_echo_server_body(self): self.assertEqual(request.call_count, 1) self.assertIn('logs --tail 100 ' + self.container, str(failure.exception)) self.assertNotIn('private body', str(failure.exception)) + self.assertEqual('route /api/v1' in str(failure.exception), status == 404) def test_deadline_retains_container_and_has_retry_guidance(self): with patch.object(installer, 'open_connection', return_value=self.response('disconnected')), \ diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index 75d3d69db..6188b2bb7 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -332,11 +332,14 @@ hostname. Configure your TLS reverse proxy with these routes: The project executor-credential API (`/core/v1/environments/{id}/executor-credentials`) is the one exception under `/core`: route it to Core as well. Preserve Host and support WebSocket upgrades. Web answers 404 on `/v1` and `/api/v1` and never -forwards them, so a proxy that sends those paths to Web breaks application calls -and node enrollment. Both the node host and its sandbox guests must reach this -address. Installation does not -create DNS records or certificates, nor expose a host port publicly. Without this -option, the console uses its loopback address for local access. Do not copy a +forwards them. A proxy that sends those paths to Web breaks application calls, +node enrollment and every Runtime daemon connection (`/api/v1/agent-daemon/ws`) +for Docker, microsandbox, self-hosted and E2B sandboxes alike. When upgrading from +a release whose Web forwarded node and daemon traffic, change this routing as the +new release goes live; see [Data and upgrades](operations.md#data-and-upgrades). +Both the node host and its sandbox guests must reach this address. Installation +does not create DNS records or certificates, nor expose a host port publicly. +Without this option, the console uses its loopback address for local access. Do not copy a localhost download command to a different machine. Running plain `./install.sh` is suitable for local console/API inspection; prepare the shared endpoint before installing a deployment that will enroll nodes. diff --git a/docs/getting-started/operations.md b/docs/getting-started/operations.md index 8024c6d88..c5513a2a2 100644 --- a/docs/getting-started/operations.md +++ b/docs/getting-started/operations.md @@ -122,12 +122,22 @@ apply the existing Core migration workflow and replace matched service/Runtime artifacts while retaining identities and backend paths. Qualify recovery before claiming the upgrade complete; there is no downgrade or history migration promise. -Core and its nodes must come from the same distribution; the node installer refuses -a mismatched release. Nodes from releases that used the removed -`/core/v1/sandbox/enroll` and `/core/v1/sandbox/node/*` paths cannot connect to a -Core that serves `/api/v1/sandbox-node/*`. Drain such a deployment with its previous -release while its nodes are still connected (steps 1–2 below). Then upgrade Core and -complete steps 3–4 with the new distribution's Runtime, enrolling its nodes. +Upgrading to a release that serves node connections at `/api/v1/sandbox-node/*`: + +1. Core and its nodes must come from the same distribution; the node installer + refuses a mismatched release. Nodes from releases that used the removed + `/core/v1/sandbox/enroll` and `/core/v1/sandbox/node/*` paths cannot connect to + the new Core. For a Docker or microsandbox deployment, drain with the previous + release while its nodes are still connected (steps 1–2 below). +2. When the new Core and Web go live, change the reverse proxy so that `/api/v1/*`, + including WebSocket upgrades, goes directly to Core instead of Web (see + [Expose Core and Web](install.md#expose-core-and-web)). The new Web returns 404 + for `/api/v1`. With the old routing, node enrollment fails and so does every + Runtime daemon connection (`/api/v1/agent-daemon/ws`), for Docker, microsandbox, + self-hosted and E2B sandboxes alike. +3. Complete steps 3–4 below with the new distribution's Runtime release. On each + node host, stop the old node service and move `~/.parsar/nodes//` + aside as a backup before running the new command from Web. The installer refuses component/packaging flag changes on an existing installation. Rerunning it does not resize sandboxes or replace the database selection. Use Web @@ -165,16 +175,19 @@ The supported current path uses a database-managed deployment. A Web-selected Docker or microsandbox deployment saved before deployment specifications existed has an empty specification after migration. Its nodes use -the removed `/core/v1/sandbox` node paths, so drain it with the last Core release -that still serves them. That Core loads the deployment only to drain: nodes -enrolled under the previous release reconnect with their existing node service, -and their sandboxes stay reachable for archive and cleanup. Fresh sandbox +the removed `/core/v1/sandbox` node paths, so draining it needs a Core that has the +pre-specification drain mode (pull request #114) but still serves those paths. No such release +has been published: build a distribution from main commit +`7b66be236a627246c85658722314285e6b39d9b8`, or any commit that contains #114 but not +the move to `/api/v1/sandbox-node`. That Core loads the deployment only to drain: +nodes enrolled under the previous release reconnect with their existing node +service, and their sandboxes stay reachable for archive and cleanup. Fresh sandbox creation, node configuration reads and enrollment are refused. Back up as above, -replace Core and Web with that release and complete steps 1–2. Then upgrade to -the current distribution and use steps 3–4 with its Runtime release. The -replacement retires the old nodes. Stop each -retired node service, move its identity directory aside as a backup, and add the -host again with a new command from Web. Node IDs change; Session history and +replace Core and Web with that build and complete steps 1–2. Only then upgrade to +this release, change the reverse proxy as described above and use steps 3–4 with +its Runtime release. The replacement retires the old nodes. Stop each retired node +service, move its identity directory aside as a backup, and add the host again +with a new command from Web. Node IDs change; Session history and persisted Files/Artifacts remain. E2B deployments from that period are not covered. ## Exposure and network policy diff --git a/scripts/openapi-split/main.go b/scripts/openapi-split/main.go index d308fbee0..c032e3546 100644 --- a/scripts/openapi-split/main.go +++ b/scripts/openapi-split/main.go @@ -53,6 +53,9 @@ func run(input, projectOutput, managerOutput, runtimeOutput string) error { pruneDefinitions(p) pruneDefinitions(m) pruneDefinitions(rt) + // The project document keeps the generator's complete scheme list unchanged. + pruneSecurityDefinitions(m) + pruneSecurityDefinitions(rt) // Keep unrelated existing project definitions and the generator's formatting. // Only definitions exclusive to the management or machine surfaces are removed. var original yaml.Node @@ -161,6 +164,33 @@ func pruneDefinitions(root *yaml.Node) { definitions.Content = kept } +// pruneSecurityDefinitions keeps only the schemes that the document's operations require. +func pruneSecurityDefinitions(root *yaml.Node) { + schemes, paths := field(root, "securityDefinitions"), field(root, "paths") + if schemes == nil || paths == nil { + return + } + used := map[string]bool{} + for i := 1; i < len(paths.Content); i += 2 { + for j := 1; j < len(paths.Content[i].Content); j += 2 { + if requirements := field(paths.Content[i].Content[j], "security"); requirements != nil { + for _, requirement := range requirements.Content { + for k := 0; k < len(requirement.Content); k += 2 { + used[requirement.Content[k].Value] = true + } + } + } + } + } + kept := make([]*yaml.Node, 0, len(schemes.Content)) + for i := 0; i+1 < len(schemes.Content); i += 2 { + if used[schemes.Content[i].Value] { + kept = append(kept, schemes.Content[i], schemes.Content[i+1]) + } + } + schemes.Content = kept +} + // Preserve the generated project's lexical form while removing whole mappings. // YAML node line locations avoid interpreting indentation or quoted path names. func preserveProjectFormatting(raw []byte, original, filtered *yaml.Node) []byte { diff --git a/scripts/openapi-split/main_test.go b/scripts/openapi-split/main_test.go index 2e3586ef2..68db025b2 100644 --- a/scripts/openapi-split/main_test.go +++ b/scripts/openapi-split/main_test.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "strings" "testing" "gopkg.in/yaml.v3" @@ -28,12 +29,16 @@ paths: $ref: '#/definitions/Session' /core/v1/sandbox/nodes: get: + security: + - DeploymentAdminAuth: [] responses: "200": schema: $ref: '#/definitions/Node' /api/v1/sandbox-node/identity: get: + security: + - NodeAuth: [] responses: "200": schema: @@ -53,6 +58,13 @@ definitions: $ref: '#/definitions/Error' Error: type: object +securityDefinitions: + BearerAuth: + type: apiKey + DeploymentAdminAuth: + type: apiKey + NodeAuth: + type: apiKey ` if err := os.WriteFile(input, []byte(raw), 0600); err != nil { t.Fatal(err) @@ -85,4 +97,24 @@ definitions: t.Fatal("reference closure was not preserved") } } + // Generated surfaces keep only the schemes their operations use; the project + // document is left as generated. + for path, want := range map[string]string{project: "BearerAuth DeploymentAdminAuth NodeAuth", manager: "DeploymentAdminAuth", machine: "NodeAuth"} { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var doc yaml.Node + if err := yaml.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + var names []string + schemes := field(doc.Content[0], "securityDefinitions") + for i := 0; i < len(schemes.Content); i += 2 { + names = append(names, schemes.Content[i].Value) + } + if got := strings.Join(names, " "); got != want { + t.Fatalf("%s security definitions = %q, want %q", filepath.Base(path), got, want) + } + } } diff --git a/services/agents-api/HOSTED-SANDBOX-MANAGER.md b/services/agents-api/HOSTED-SANDBOX-MANAGER.md index 20b5620e2..6816290ae 100644 --- a/services/agents-api/HOSTED-SANDBOX-MANAGER.md +++ b/services/agents-api/HOSTED-SANDBOX-MANAGER.md @@ -167,9 +167,9 @@ credentials; Web does not forward them and no administrator credential applies: | WebSocket `GET /api/v1/sandbox-node/connect?node_id=` | Node credential | A node and its Core must come from the same distribution. Nodes from releases -that used the removed `/core/v1/sandbox` node paths cannot connect to this Core; -upgrade through the drained procedure in -[Removal and maintenance](#removal-and-maintenance) and enroll new nodes. +that used the removed `/core/v1/sandbox` node paths cannot connect to this Core: +drain with the previous release, then upgrade and enroll new nodes, as described +in [Data and upgrades](../../docs/getting-started/operations.md#data-and-upgrades). Manual registration remains available for operator-managed payloads: Build/install `parsar-sandbox-node` from the same Core release. On the host, diff --git a/services/core-console/project_proxy_test.go b/services/core-console/project_proxy_test.go index 45edcb741..44370116c 100644 --- a/services/core-console/project_proxy_test.go +++ b/services/core-console/project_proxy_test.go @@ -52,3 +52,23 @@ func TestManagedSessionArchiveAllowlist(t *testing.T) { } } } + +// Encoded or doubled separators cannot turn a /core request into a forwarded +// /api/v1 request. +func TestConsoleRejectsSmuggledMachinePaths(t *testing.T) { + c := accountConsoleConfig(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("smuggled path reached Core") })) + h := accountConsole(t, c) + cookie := setupAccount(t, h) + for _, target := range []string{"/core/v1/sandbox/..%2F..%2F..%2Fapi/v1/sandbox-node/enroll", "//api/v1/sandbox-node/enroll", + "/core/v1/sandbox/nodes/%2E%2E/%2E%2E/%2E%2E/%2E%2E/api/v1/agent-daemon/ws"} { + r := httptest.NewRequest("POST", target, strings.NewReader(`{}`)) + r.Host = h.host + r.Header.Set("Origin", h.origin) + r.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 400 && w.Code != 404 { + t.Errorf("%s = %d", target, w.Code) + } + } +}