From 589ec5eeb0ac4094c39344f407da2c29962e70d9 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 8 Oct 2026 14:42:26 +0000 Subject: [PATCH 1/4] Register machine routes in one place --- Makefile | 4 +- .../internal/transport/bootstrap_test.go | 2 +- apps/daemon/internal/transport/ws_test.go | 5 +- .../internal/wireconformance/wire_test.go | 9 +- contracts/agents-api/machine-api.md | 16 +- contracts/agents-api/runtime.openapi.yaml | 452 ++++++++++++++++-- contracts/agents-api/v1/errors.go | 40 ++ contracts/agents-api/zh/machine-api.md | 18 +- deploy/node/node_spec.py | 2 +- internal/sandboxbootstrap/bootstrap.go | 10 +- internal/sandboxlink/relay/relay.go | 4 +- internal/sandboxlink/transport.go | 4 +- services/core/IMPLEMENTATION.md | 2 + services/core/cmd/server/http_routes.go | 32 -- services/core/cmd/server/http_routes_test.go | 328 ------------- services/core/cmd/server/main.go | 13 +- .../core/internal/api/contract_routes_test.go | 21 +- services/core/internal/api/dependencies.go | 6 + .../core/internal/api/dependencies_test.go | 8 + .../internal/api/environment_installation.go | 16 +- services/core/internal/api/errors.go | 21 +- services/core/internal/api/handler.go | 5 +- .../core/internal/api/machine_paths_test.go | 210 ++++++++ services/core/internal/api/machine_routes.go | 115 +++++ .../core/internal/api/machine_routes_test.go | 185 +++++++ services/core/internal/api/routing.go | 3 +- services/core/internal/api/routing_test.go | 8 +- services/core/internal/api/sandbox_link.go | 14 - services/core/internal/api/sandbox_manager.go | 8 - .../core/internal/nativeinstaller/catalog.go | 41 +- .../internal/nativeinstaller/catalog_test.go | 57 +++ .../internal/runtimeenrollment/connection.go | 13 +- .../internal/runtimeenrollment/enrollment.go | 27 +- .../runtimeenrollment/enrollment_test.go | 2 +- .../core/internal/runtimegateway/handler.go | 55 +-- .../core/internal/runtimegateway/routes.go | 23 - .../core/internal/runtimegateway/wire_test.go | 12 +- services/core/internal/sandbox/node/hub.go | 19 +- .../providers/configuration_flow_test.go | 3 +- .../core/tests/integration/dispatch_test.go | 13 +- .../public_handler_fixture_test.go | 5 +- .../self_hosted_initial_public_test.go | 1 + 42 files changed, 1225 insertions(+), 607 deletions(-) create mode 100644 contracts/agents-api/v1/errors.go delete mode 100644 services/core/cmd/server/http_routes.go delete mode 100644 services/core/cmd/server/http_routes_test.go create mode 100644 services/core/internal/api/machine_paths_test.go create mode 100644 services/core/internal/api/machine_routes.go create mode 100644 services/core/internal/api/machine_routes_test.go delete mode 100644 services/core/internal/api/sandbox_link.go delete mode 100644 services/core/internal/runtimegateway/routes.go diff --git a/Makefile b/Makefile index d1a2bbeef..dca675b6c 100644 --- a/Makefile +++ b/Makefile @@ -43,9 +43,9 @@ openapi: output=$$(mktemp -d "$$root/core-openapi.XXXXXX"); trap 'rm -rf "$$output"' EXIT; \ python3 scripts/generate-public-api.py $(OPENAPI_FLAGS) --swag-roots "$$output/roots.go"; \ $(SWAG) init \ - -g cmd/server/main.go --dir "./services/core,./contracts/agents-api/v1,./internal/modelprovider,$$output" \ + -g cmd/server/main.go --dir "./services/core,./contracts/agents-api/v1,./internal/modelprovider,./internal/sandboxbootstrap,$$output" \ --output "$$output" \ - --outputTypes yaml --parseInternal; \ + --outputTypes yaml --parseInternal --parseFuncBody; \ python3 scripts/patch-agents-openapi.py "$$output/swagger.yaml"; \ go run ./scripts/openapi-split $(OPENAPI_FLAGS) "$$output/swagger.yaml" "$$output/extensions.json" "$$output/core.json" contracts/agents-api/core.openapi.yaml contracts/agents-api/runtime.openapi.yaml; \ python3 scripts/generate-public-api.py $(OPENAPI_FLAGS) --extensions "$$output/extensions.json" --core "$$output/core.json" diff --git a/apps/daemon/internal/transport/bootstrap_test.go b/apps/daemon/internal/transport/bootstrap_test.go index 58440d46d..fcfa3f1fe 100644 --- a/apps/daemon/internal/transport/bootstrap_test.go +++ b/apps/daemon/internal/transport/bootstrap_test.go @@ -88,7 +88,7 @@ func TestBootstrapNonSuccessSurfacesBody(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte(`{"error":"bad_credential","detail":"wrong key"}`)) + _, _ = w.Write([]byte(`{"error":{"code":"bad_credential","message":"wrong key","type":"invalid_request_error","param":null}}`)) })) defer srv.Close() diff --git a/apps/daemon/internal/transport/ws_test.go b/apps/daemon/internal/transport/ws_test.go index f8e511a17..1815c2822 100644 --- a/apps/daemon/internal/transport/ws_test.go +++ b/apps/daemon/internal/transport/ws_test.go @@ -12,10 +12,9 @@ import ( "testing" "time" - "github.com/gorilla/websocket" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/gorilla/websocket" ) // fakeGateway is a minimal stand-in for the server-side @@ -364,7 +363,7 @@ func TestDialMarksOperatorFixableUpgradeRejectionsAsPermanent(t *testing.T) { t.Run(tc.name, func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(tc.code) - _, _ = w.Write([]byte(`{"error":"x","detail":"y"}`)) + _, _ = w.Write([]byte(`{"error":{"code":"x","message":"y","type":"invalid_request_error","param":null}}`)) })) defer srv.Close() _, err := transport.Dial(context.Background(), transport.DialOptions{ diff --git a/apps/daemon/internal/wireconformance/wire_test.go b/apps/daemon/internal/wireconformance/wire_test.go index bffdfa63e..fcb61f904 100644 --- a/apps/daemon/internal/wireconformance/wire_test.go +++ b/apps/daemon/internal/wireconformance/wire_test.go @@ -5,7 +5,6 @@ package wireconformance import ( "context" - "encoding/json" "errors" "net/http" "net/http/httptest" @@ -15,13 +14,13 @@ import ( "testing" "time" - "github.com/gorilla/websocket" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/gorilla/websocket" ) const wait = 3 * time.Second @@ -48,9 +47,7 @@ func newCorePeer(t *testing.T) *corePeer { return } if query.Get("version") != proto.Version { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(prototest.IncompatibleVersionStatus) - _ = json.NewEncoder(w).Encode(map[string]string{"error": prototest.IncompatibleVersionCode}) + v1.WriteHTTPError(w, prototest.IncompatibleVersionStatus, prototest.IncompatibleVersionCode, "incompatible Runtime version") return } ws, err := upgrader.Upgrade(w, r, nil) diff --git a/contracts/agents-api/machine-api.md b/contracts/agents-api/machine-api.md index 7c69dc02a..d23318601 100644 --- a/contracts/agents-api/machine-api.md +++ b/contracts/agents-api/machine-api.md @@ -2,7 +2,7 @@ title: "Machine connection API" --- -Machines call Core under `/api/v1`: sandbox nodes, Runtime daemons, the Sandbox I/O service and the self-hosted installer. Each route accepts only the credential listed for it, never the Core key or a Project API key, and a console sign-in grants nothing here. The reverse proxy sends `/api/v1` directly to Core; Web never serves these routes. +Machines call Core under `/api/v1`: sandbox nodes, Runtime daemons, the Sandbox I/O service and the self-hosted installer. Each route accepts only the credential listed for it, never the Core key or a Project API key, and a console sign-in grants nothing here. The public origin forwards `/api/v1` to Core, either directly or through Web's unchanged HTTP and WebSocket proxy. Web adds no console authority to machine requests. ## Routes @@ -12,7 +12,7 @@ Machines call Core under `/api/v1`: sandbox nodes, Runtime daemons, the Sandbox | `POST sandbox-node/enroll` | Node installer | Enrollment token | [Enroll a node](#enroll-a-node) | | `GET sandbox-node/identity?node_id=` | Node | Node credential | [Recover a node's identity](#recover-a-nodes-identity) | | WebSocket `GET sandbox-node/connect?node_id=` | Node | Node credential | [Node generation protocol](./node-generation-protocol.md) | -| `GET agent-daemon/install/{version}/…` | Self-hosted installer | None | [Installation grant](./environment-executor-credentials.md#installation-grant) | +| `GET` / `HEAD agent-daemon/install/{version}/…` | Self-hosted installer | None | [Installation grant](./environment-executor-credentials.md#installation-grant) | | `POST agent-daemon/installation`, `POST agent-daemon/installation/claim` | Self-hosted installer | Installation grant | [Installation grant](./environment-executor-credentials.md#installation-grant) | | `POST agent-daemon/enroll` | Self-hosted daemon | Executor credential | [Enroll a self-hosted daemon](#enroll-a-self-hosted-daemon) | | `GET agent-daemon/connection?environment_id=` | Self-hosted installer | Executor credential | [Private connection confirmation](./environment-executor-credentials.md#private-connection-confirmation) | @@ -22,7 +22,13 @@ Machines call Core under `/api/v1`: sandbox nodes, Runtime daemons, the Sandbox Every credential travels in an `Authorization: Bearer` header, except on `sandbox-link`, where each peer sends it in its Link Hello after the upgrade. No credential travels in a URL. Core derives the [Link URL](../../docs/configuration.md#changing-the-public-url) from `OAC_PUBLIC_URL`. -The generated [`runtime.openapi.yaml`](./runtime.openapi.yaml) describes only the sandbox-node configuration, enroll and identity routes, the two installation routes and the `sandbox-link` upgrade, whose messages the Sandbox link protocol defines. The `sandbox-node/connect` and `agent-daemon/ws` WebSockets and the daemon bootstrap, enroll and connection routes are served outside the API router and have no generated schema; this document and the linked contracts are their only definition. +The generated [`runtime.openapi.yaml`](./runtime.openapi.yaml) describes every machine HTTP operation, including public downloads and WebSocket handshakes. WebSocket messages remain owned by their linked wire protocols. + +### HTTP errors and methods + +Every HTTP error uses `{"error":{"message":"…","type":"invalid_request_error","code":null,"param":null}}`. `code` carries a route's defined reason when present; `param` identifies a rejected field when present. A 409 has type `conflict_error`, and a 5xx has type `server_error`. Handshake failures use the same envelope before upgrade; errors after upgrade belong to the wire protocol. Consumers use HTTP status for retry and permanent-rejection decisions and may display `error.message`. + +`HEAD` never opens a connection or queries a Runtime: daemon WebSocket, Link and connection observation routes reject it with 405; the node connection rejects every non-GET method with 503. Node configuration and identity reads support HEAD with the same credential checks as GET. Installer downloads support GET and HEAD, including archive conditional and range responses; download errors also use the shared envelope. Enrollment accepts POST only. Unsupported methods retain the route's status and `Allow` header. The bare `/api/v1/agent-daemon` and `/api/v1/agent-daemon/install` prefixes redirect with 301 to their trailing-slash forms, preserving their queries. Unknown machine routes return 404 in the shared envelope. ## Credentials @@ -88,7 +94,7 @@ The credential is checked before any deployment state, so a rejected credential, `POST /api/v1/agent-daemon/bootstrap` with the agent-host credential and `{"device_id": "…"}` returns `device_id`, `workspace_id` (an empty string for the deployment-scoped host), `ws_url` (derived from `OAC_PUBLIC_URL`, never from request headers), `heartbeat_seconds` and `protocol_version`. The daemon then dials `ws_url` as the [Core–Runtime protocol](../../docs/runtime-protocol.md#ownership-and-connection) describes. -The bootstrap and WebSocket routes share one error body, `{"error": code, "detail": text}`: 400 `missing_params`, `missing_device_id` or `bad_json`; 401 `missing_bearer`, `unknown_device` or `bad_credential`; 403 `wrong_runtime_type`; 500 `internal`; and on the WebSocket 426 `incompatible_version` when `version` is not Core's exact Runtime protocol version. +The bootstrap and WebSocket routes report these `error.code` values: 400 `missing_params`, `missing_device_id` or `bad_json`; 401 `missing_bearer`, `unknown_device` or `bad_credential`; 403 `wrong_runtime_type`; 500 `internal`; and on the WebSocket 426 `incompatible_version` when `version` is not Core's exact Runtime protocol version. ### Enroll a self-hosted daemon @@ -99,6 +105,6 @@ The bootstrap and WebSocket routes share one error body, `{"error": code, "detai | 400 | A malformed body or any query | | 401 | An invalid, revoked or foreign credential, a deleted Session, or an Environment without current executor authority | | 409 | Another executor key already enrolled the Environment | -| 503 | `{"error": "no_sandbox_link", "detail": "a self_hosted sandbox needs an https public URL"}`, before the credential is checked, when the [public URL](../../docs/configuration.md#changing-the-public-url) is not https; otherwise storage is unavailable | +| 503 | `error.code: "no_sandbox_link"` and `error.message: "a self_hosted sandbox needs an https public URL"`, after header/body validation but before executor authority is checked, when the [public URL](../../docs/configuration.md#changing-the-public-url) is not https; otherwise storage is unavailable | Enrollment creates no managed allocation, binds no Session and grants no Session API access. Core binds the Session to the deployment's agent host while the machine serves the resource ([Session assignments](../../docs/runtime-protocol.md#session-assignments)). The relay rechecks the credential's authority on every Serve and Open, so rotation, revocation and Session deletion end further use. The [self-hosted guide](../../docs/getting-started/self-hosted.md) gives the operator steps, and the [executor credential contract](./environment-executor-credentials.md#revoked-or-rotated-credential) describes how the daemon handles a permanent rejection. diff --git a/contracts/agents-api/runtime.openapi.yaml b/contracts/agents-api/runtime.openapi.yaml index 713b07526..984c23f37 100644 --- a/contracts/agents-api/runtime.openapi.yaml +++ b/contracts/agents-api/runtime.openapi.yaml @@ -1,36 +1,5 @@ basePath: / definitions: - api.CoreAPIError: - properties: - code: - type: string - x-nullable: true - details: - description: |- - Details contains only documented, Core-owned facts: string, finite number, - boolean, null or string array values. Never include request echoes, secrets - or native/provider error text. Empty or invalid details are omitted. - type: object - message: - type: string - param: - type: string - x-nullable: true - type: - type: string - required: - - code - - message - - param - - type - type: object - api.CoreErrorResponse: - properties: - error: - $ref: '#/definitions/api.CoreAPIError' - required: - - error - type: object api.NativeInstallationClaim: properties: executor_token: @@ -113,6 +82,62 @@ definitions: specification_digest: type: string type: object + runtimeenrollment.ConnectionResponse: + properties: + environment_id: + type: string + status: + enum: + - connected + - disconnected + type: string + required: + - environment_id + - status + type: object + runtimeenrollment.EnrollmentRequest: + properties: + environment_id: + type: string + required: + - environment_id + type: object + runtimeenrollment.EnrollmentResponse: + properties: + link_url: + type: string + resource: + $ref: '#/definitions/sandboxbootstrap.Resource' + required: + - link_url + - resource + type: object + runtimegateway.BootstrapRequest: + properties: + device_id: + type: string + required: + - device_id + type: object + runtimegateway.BootstrapResponse: + properties: + device_id: + type: string + heartbeat_seconds: + type: integer + protocol_version: + type: string + workspace_id: + type: string + ws_url: + type: string + required: + - device_id + - heartbeat_seconds + - protocol_version + - workspace_id + - ws_url + type: object sandbox.DeploymentSpec: properties: resources: @@ -148,6 +173,25 @@ definitions: - artifacts - source_commit type: object + sandboxbootstrap.Resource: + properties: + environment_id: + type: string + generation: + type: integer + id: + type: string + kind: + type: string + tenant_id: + type: string + required: + - environment_id + - generation + - id + - kind + - tenant_id + type: object v1.APIError: properties: code: @@ -197,6 +241,235 @@ info: title: OpenAgentCore Machine Connections version: "1" paths: + /api/v1/agent-daemon/bootstrap: + post: + consumes: + - application/json + description: Authenticates the agent-host credential and returns its WebSocket URL and heartbeat interval. + parameters: + - description: Bearer agent-host credential + in: header + name: Authorization + required: true + type: string + - description: Agent-host identity + in: body + name: body + required: true + schema: + $ref: '#/definitions/runtimegateway.BootstrapRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/runtimegateway.BootstrapResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + summary: Bootstrap an agent-host Runtime + tags: + - Runtime Daemon + /api/v1/agent-daemon/connection: + get: + description: Rechecks executor authority around the live Link resource read. Never enrolls the sandbox or starts execution. Responses carry Cache-Control no-store. + parameters: + - description: Bearer executor credential + in: header + name: Authorization + required: true + type: string + - description: Environment UUID + in: query + name: environment_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/runtimeenrollment.ConnectionResponse' + "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' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + summary: Observe a self-hosted sandbox connection + tags: + - Runtime Daemon + /api/v1/agent-daemon/enroll: + post: + consumes: + - application/json + description: Accepts an executor credential and exactly one environment_id. Returns the Environment's Link URL and resource without issuing a credential. Queries and bodies over 4096 bytes are rejected. + parameters: + - description: Bearer executor credential + in: header + name: Authorization + required: true + type: string + - description: Environment identity + in: body + name: body + required: true + schema: + $ref: '#/definitions/runtimeenrollment.EnrollmentRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/runtimeenrollment.EnrollmentResponse' + "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' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + summary: Enroll a self-hosted sandbox + tags: + - Runtime Daemon + /api/v1/agent-daemon/install/{path}: + get: + description: Public versioned bootstrap script, checksum or Linux amd64 archive. An archive may redirect to its qualified release URL; local archives support conditional and range requests. GET and HEAD share the download headers. + parameters: + - description: 'Version and filename: {version}/bootstrap.sh, {version}/linux-amd64.sha256 or {version}/linux-amd64.tar.gz' + in: path + name: path + required: true + type: string + produces: + - text/plain + - application/octet-stream + responses: + "200": + description: OK + schema: + type: file + "206": + description: Partial Content + schema: + type: file + "304": + description: Not Modified + "307": + description: Temporary Redirect + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "405": + description: Method Not Allowed + schema: + $ref: '#/definitions/v1.ErrorResponse' + "416": + description: Requested Range Not Satisfiable + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + summary: Download native installation content + tags: + - Native Installation + head: + description: Public versioned bootstrap script, checksum or Linux amd64 archive. An archive may redirect to its qualified release URL; local archives support conditional and range requests. GET and HEAD share the download headers. + parameters: + - description: 'Version and filename: {version}/bootstrap.sh, {version}/linux-amd64.sha256 or {version}/linux-amd64.tar.gz' + in: path + name: path + required: true + type: string + produces: + - text/plain + - application/octet-stream + responses: + "200": + description: OK + schema: + type: file + "206": + description: Partial Content + schema: + type: file + "304": + description: Not Modified + "307": + description: Temporary Redirect + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "405": + description: Method Not Allowed + schema: + $ref: '#/definitions/v1.ErrorResponse' + "416": + description: Requested Range Not Satisfiable + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + summary: Download native installation content + tags: + - Native Installation /api/v1/agent-daemon/installation: post: description: Accepts a short-lived Environment installation Bearer authorization, not a Project or Core key. Returns frozen connection constraints; it does not claim or rotate credentials. @@ -210,15 +483,15 @@ paths: "401": description: Unauthorized schema: - $ref: '#/definitions/api.CoreErrorResponse' + $ref: '#/definitions/v1.ErrorResponse' "404": description: Not Found schema: - $ref: '#/definitions/api.CoreErrorResponse' + $ref: '#/definitions/v1.ErrorResponse' "503": description: Service Unavailable schema: - $ref: '#/definitions/api.CoreErrorResponse' + $ref: '#/definitions/v1.ErrorResponse' summary: Resolve a native installation authorization tags: - Native Installation @@ -240,32 +513,85 @@ paths: "400": description: Bad Request schema: - $ref: '#/definitions/api.CoreErrorResponse' + $ref: '#/definitions/v1.ErrorResponse' "401": description: Unauthorized schema: - $ref: '#/definitions/api.CoreErrorResponse' + $ref: '#/definitions/v1.ErrorResponse' "409": description: Conflict schema: - $ref: '#/definitions/api.CoreErrorResponse' + $ref: '#/definitions/v1.ErrorResponse' "503": description: Service Unavailable schema: - $ref: '#/definitions/api.CoreErrorResponse' + $ref: '#/definitions/v1.ErrorResponse' summary: Claim an Environment's installation credential tags: - Native Installation + /api/v1/agent-daemon/ws: + get: + description: Authenticates the agent-host credential and exact Runtime protocol version before upgrading to the Core–Runtime wire protocol. + parameters: + - description: Bearer agent-host credential + in: header + name: Authorization + required: true + type: string + - description: Agent-host device ID + in: query + name: device_id + required: true + type: string + - description: Runtime protocol version + in: query + name: version + required: true + type: string + responses: + "101": + description: Switching Protocols + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/v1.ErrorResponse' + "426": + description: Upgrade Required + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + summary: Open an agent-host Runtime connection + tags: + - Runtime Daemon /api/v1/sandbox-link: get: - description: 'Upgrades to a WebSocket that carries the Sandbox link protocol. The Sandbox I/O service connects as the serve peer and the agent-host Runtime as the attach peer. The route takes no credential: each peer authenticates in its Link Hello, and the relay ends a link the Hello does not authenticate.' + description: Upgrades to a WebSocket that carries the Sandbox link protocol. The Sandbox I/O service connects as the serve peer and the agent-host Runtime as the attach peer. Each peer authenticates in its Link Hello after the upgrade. responses: "101": description: Switching Protocols; the connection carries the Link "400": - description: The request is not a WebSocket upgrade + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' "403": - description: The request carries an Origin other than its Host + description: Forbidden + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' summary: Open a sandbox Link tags: - Sandbox Link @@ -313,6 +639,50 @@ paths: summary: Read the active configuration for node installation tags: - Sandbox Node + /api/v1/sandbox-node/connect: + get: + description: Authenticates a node credential before upgrading to the sandbox node wire protocol. Other methods return 503 without authentication or upgrade. + parameters: + - description: Bearer node credential + in: header + name: Authorization + required: true + type: string + - description: Node UUID + in: query + name: node_id + required: true + type: string + responses: + "101": + description: Switching Protocols + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "403": + description: Forbidden + 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' + summary: Open a sandbox node connection + tags: + - Sandbox Nodes /api/v1/sandbox-node/enroll: post: consumes: diff --git a/contracts/agents-api/v1/errors.go b/contracts/agents-api/v1/errors.go new file mode 100644 index 000000000..523ee9553 --- /dev/null +++ b/contracts/agents-api/v1/errors.go @@ -0,0 +1,40 @@ +package v1 + +import ( + "encoding/json" + "net/http" +) + +// NewAPIError constructs the shared HTTP error, including its nullable fields. +func NewAPIError(status int, code, message string, param ...string) APIError { + kind := "invalid_request_error" + if status >= 500 { + kind = "server_error" + } else if status == http.StatusConflict { + kind = "conflict_error" + } else if code == "not_found_error" || code == "invalid_beta" { + kind = code + } + var errorCode, errorParam *string + if code != "" { + errorCode = &code + } + if len(param) > 0 { + errorParam = ¶m[0] + } + return APIError{Message: message, Type: kind, Code: errorCode, Param: errorParam} +} + +// WriteHTTPError writes the shared envelope without changing route-owned cache +// or authentication headers. +func WriteHTTPError(w http.ResponseWriter, status int, code, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(ErrorResponse{Error: NewAPIError(status, code, message)}) +} + +// WebSocketError is the HTTP failure hook for machine WebSocket upgrades. +func WebSocketError(w http.ResponseWriter, _ *http.Request, status int, _ error) { + w.Header().Set("Sec-Websocket-Version", "13") + WriteHTTPError(w, status, "", http.StatusText(status)) +} diff --git a/contracts/agents-api/zh/machine-api.md b/contracts/agents-api/zh/machine-api.md index a3152de69..128df1b03 100644 --- a/contracts/agents-api/zh/machine-api.md +++ b/contracts/agents-api/zh/machine-api.md @@ -1,10 +1,10 @@ --- title: "机器连接 API" source: contracts/agents-api/machine-api.md -source_hash: f42768f959577708b0f4bc1db63d42498e032a8668416ae8f62736d184efc543 +source_hash: 08e8e034ebe1e5a4c70db9143d35f08ceec071701d16e97480220c4ec0ece19f --- -机器通过 `/api/v1` 调用 Core:包括沙箱节点、Runtime daemon、Sandbox I/O 服务和自托管安装器。各路由仅接受所列凭据,不接受 Core 密钥或 Project API 密钥;控制台登录也不授予此处权限。反向代理将 `/api/v1` 直接发送给 Core;Web 不提供这些路由。 +机器通过 `/api/v1` 调用 Core:包括沙箱节点、Runtime daemon、Sandbox I/O 服务和自托管安装器。各路由仅接受所列凭据,不接受 Core 密钥或 Project API 密钥;控制台登录也不授予此处权限。公共源站将 `/api/v1` 转发给 Core,可以直接转发,也可以经过 Web 不改变请求的 HTTP 和 WebSocket 代理。Web 不会给机器请求添加控制台权限。 ## 路由 {#routes} @@ -14,7 +14,7 @@ source_hash: f42768f959577708b0f4bc1db63d42498e032a8668416ae8f62736d184efc543 | `POST sandbox-node/enroll` | 节点安装器 | 登记 token | [登记节点](#enroll-a-node) | | `GET sandbox-node/identity?node_id=` | 节点 | 节点凭据 | [恢复节点身份](#recover-a-node-s-identity) | | WebSocket `GET sandbox-node/connect?node_id=` | 节点 | 节点凭据 | [节点代际协议](node-generation-protocol.md) | -| `GET agent-daemon/install/{version}/…` | 自托管安装器 | 无 | [安装授权](environment-executor-credentials.md#installation-grant) | +| `GET` / `HEAD agent-daemon/install/{version}/…` | 自托管安装器 | 无 | [安装授权](environment-executor-credentials.md#installation-grant) | | `POST agent-daemon/installation`, `POST agent-daemon/installation/claim` | 自托管安装器 | 安装授权 | [安装授权](environment-executor-credentials.md#installation-grant) | | `POST agent-daemon/enroll` | 自托管 daemon | 执行器凭据 | [登记自托管 daemon](#enroll-a-self-hosted-daemon) | | `GET agent-daemon/connection?environment_id=` | 自托管安装器 | 执行器凭据 | [私有连接确认](environment-executor-credentials.md#private-connection-confirmation) | @@ -24,7 +24,13 @@ source_hash: f42768f959577708b0f4bc1db63d42498e032a8668416ae8f62736d184efc543 所有凭据通过 `Authorization: Bearer` 头传输;`sandbox-link` 例外,各 peer 在升级之后的 Link Hello 中发送凭据。凭据从不放入 URL。Core 从 `OAC_PUBLIC_URL` 派生 [Link URL](../../../docs/zh/configuration.md#changing-the-public-url)。 -生成的 [`runtime.openapi.yaml`](../runtime.openapi.yaml) 仅描述 sandbox-node 配置、登记、身份路由、两个安装路由和 `sandbox-link` 升级;该升级上的消息由 Sandbox link 协议定义。`sandbox-node/connect` 和 `agent-daemon/ws` 两个 WebSocket 及 daemon 引导、登记和连接路由在 API 路由器外提供,无生成 schema;本文及所链接契约是它们唯一的定义。 +生成的 [`runtime.openapi.yaml`](../runtime.openapi.yaml) 描述全部机器 HTTP 操作,包括公共下载和 WebSocket 握手。WebSocket 消息仍由链接的 wire 协议定义。 + +### HTTP 错误与方法 {#http-errors-and-methods} + +所有 HTTP 错误使用 `{"error":{"message":"…","type":"invalid_request_error","code":null,"param":null}}`。`code` 在存在时携带路由定义的原因,`param` 在存在时标识被拒绝字段。409 的类型为 `conflict_error`,5xx 的类型为 `server_error`。升级前的握手失败也使用该封装;升级后的错误属于 wire 协议。调用方使用 HTTP 状态决定重试或永久拒绝,也可显示 `error.message`。 + +`HEAD` 不打开连接,也不查询 Runtime:daemon WebSocket、Link 和连接观察路由以 405 拒绝;节点连接对所有非 GET 方法返回 503。节点配置与身份读取支持 HEAD,并执行与 GET 相同的凭据检查。安装器下载支持 GET 和 HEAD,包括归档的条件请求与范围响应;下载错误也使用共享封装。登记只接受 POST。不支持的方法保留路由的状态及 `Allow` 头。裸 `/api/v1/agent-daemon` 和 `/api/v1/agent-daemon/install` 前缀以 301 重定向到带尾斜杠的形式,并保留查询。未知机器路由返回使用共享封装的 404。 ## 凭据 {#credentials} @@ -90,7 +96,7 @@ Core 在一个事务中检查 token 有效、部署已初始化且为节点型 `POST /api/v1/agent-daemon/bootstrap` 携带 agent-host 凭据及 `{"device_id": "…"}`,返回 `device_id`、`workspace_id`(部署范围的主机为空字符串)、`ws_url`(从 `OAC_PUBLIC_URL` 推导,不使用请求头)、`heartbeat_seconds` 和 `protocol_version`。daemon 随后按 [Core–Runtime 协议](../../../docs/zh/runtime-protocol.md#ownership-and-connection)连接 `ws_url`。 -引导和 WebSocket 路由共享错误体 `{"error": code, "detail": text}`:400 `missing_params`、`missing_device_id` 或 `bad_json`;401 `missing_bearer`、`unknown_device` 或 `bad_credential`;403 `wrong_runtime_type`;500 `internal`;WebSocket 的 `version` 不等于 Core 精确 Runtime 协议版本时返回 426 `incompatible_version`。 +引导和 WebSocket 路由报告以下 `error.code` 值:400 `missing_params`、`missing_device_id` 或 `bad_json`;401 `missing_bearer`、`unknown_device` 或 `bad_credential`;403 `wrong_runtime_type`;500 `internal`;WebSocket 的 `version` 不等于 Core 精确 Runtime 协议版本时返回 426 `incompatible_version`。 ### 登记自托管 daemon {#enroll-a-self-hosted-daemon} @@ -101,6 +107,6 @@ Core 在一个事务中检查 token 有效、部署已初始化且为节点型 | 400 | 正文格式错误或存在任何查询 | | 401 | 凭据无效、撤销、属于其他范围,Session 已删除,或 Environment 无当前执行器权限 | | 409 | 其他执行器密钥已登记该 Environment | -| 503 | [公共 URL](../../../docs/zh/configuration.md#changing-the-public-url) 不是 https 时,在检查凭据前返回 `{"error": "no_sandbox_link", "detail": "a self_hosted sandbox needs an https public URL"}`;否则表示存储不可用 | +| 503 | [公共 URL](../../../docs/zh/configuration.md#changing-the-public-url) 不是 https 时,在验证请求头和正文后、检查执行器权限前返回 `error.code: "no_sandbox_link"` 和 `error.message: "a self_hosted sandbox needs an https public URL"`;否则表示存储不可用 | 登记不创建受管分配,不绑定 Session,也不授予 Session API 访问权限。机器为该 resource 提供服务期间,Core 把 Session 绑定到部署的 agent host([Session 分配](../../../docs/zh/runtime-protocol.md#session-assignments))。relay 在每次 Serve 和 Open 时重查凭据权限,因此轮换、撤销和删除 Session 终止后续使用。[自托管指南](../../../docs/zh/getting-started/self-hosted.md)提供操作步骤,[执行器凭据契约](environment-executor-credentials.md#revoked-or-rotated-credential)描述 daemon 如何处理永久拒绝。 diff --git a/deploy/node/node_spec.py b/deploy/node/node_spec.py index 80fbd2800..f8c43dfdd 100644 --- a/deploy/node/node_spec.py +++ b/deploy/node/node_spec.py @@ -141,7 +141,7 @@ def fetch(args, token, retained, open_request, allow_enrollment=False, generatio "deployment change retired it. Uninstall it with node-install.pyz --uninstall " "--installation-id " + args.installation_id + ", then add the host with a new command.") from None 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 + raise SpecificationError("Core node configuration was not found (HTTP 404); route /api/v1 on the public origin to Core, directly or through Web's machine proxy") from None if error.code == 409: raise SpecificationError("Core refused node configuration (HTTP 409): the deployment is resetting 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 diff --git a/internal/sandboxbootstrap/bootstrap.go b/internal/sandboxbootstrap/bootstrap.go index 96210497e..c10743df6 100644 --- a/internal/sandboxbootstrap/bootstrap.go +++ b/internal/sandboxbootstrap/bootstrap.go @@ -32,11 +32,11 @@ type Input struct { // Resource is the sandbox the serve credential serves: a Link ResourceRef. type Resource struct { - TenantID string `json:"tenant_id"` - EnvironmentID string `json:"environment_id"` - Kind string `json:"kind"` - ID string `json:"id"` - Generation uint64 `json:"generation"` + TenantID string `json:"tenant_id" binding:"required"` + EnvironmentID string `json:"environment_id" binding:"required"` + Kind string `json:"kind" binding:"required"` + ID string `json:"id" binding:"required"` + Generation uint64 `json:"generation" binding:"required"` } var resourceKinds = map[string]sandboxlink.ResourceKind{ diff --git a/internal/sandboxlink/relay/relay.go b/internal/sandboxlink/relay/relay.go index d08bdfa98..10ad88439 100644 --- a/internal/sandboxlink/relay/relay.go +++ b/internal/sandboxlink/relay/relay.go @@ -14,8 +14,10 @@ import ( "sync/atomic" "time" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" + "github.com/gorilla/websocket" "github.com/libp2p/go-yamux/v5" ) @@ -382,7 +384,7 @@ type splice struct { // ServeHTTP upgrades a peer's request to a WebSocket and serves the link until // it ends. func (rl *Relay) ServeHTTP(w http.ResponseWriter, r *http.Request) { - conn, err := sandboxlink.UpgradeWebSocket(w, r) + conn, err := sandboxlink.UpgradeWebSocket(w, r, websocket.Upgrader{HandshakeTimeout: sandboxlink.HandshakeTimeout, Error: v1.WebSocketError}) if err != nil { return } diff --git a/internal/sandboxlink/transport.go b/internal/sandboxlink/transport.go index f5d6c2cda..c15abcedb 100644 --- a/internal/sandboxlink/transport.go +++ b/internal/sandboxlink/transport.go @@ -72,13 +72,11 @@ func DialWebSocket(ctx context.Context, rawURL string, tlsConfig *tls.Config) (n return newWSConn(ws), nil } -var upgrader = websocket.Upgrader{HandshakeTimeout: HandshakeTimeout} - // UpgradeWebSocket upgrades a relay request to a WebSocket byte stream. On // failure the upgrader has already answered the request. The relay is served // behind the installation's HTTPS ingress, so it accepts the request the // ingress forwards; peers enforce TLS when they dial. -func UpgradeWebSocket(w http.ResponseWriter, r *http.Request) (net.Conn, error) { +func UpgradeWebSocket(w http.ResponseWriter, r *http.Request, upgrader websocket.Upgrader) (net.Conn, error) { ws, err := upgrader.Upgrade(w, r, nil) if err != nil { return nil, err diff --git a/services/core/IMPLEMENTATION.md b/services/core/IMPLEMENTATION.md index df1a97280..ab354b4cf 100644 --- a/services/core/IMPLEMENTATION.md +++ b/services/core/IMPLEMENTATION.md @@ -42,6 +42,8 @@ Domain owners, each with its PostgreSQL adapter under `internal/persistence/post ## Request handling +`api.registerMachineRoutes` owns every `/api/v1` registration and its HTTP annotations. `cmd/server` only supplies the required handlers in `Execution` and `Sandboxes`; the gateway, enrollment, node and Link implementations own their authentication and wire lifetimes. Machine HTTP errors use the shared `v1.ErrorResponse` constructor; API observability and administrator-only details remain in `api`. + Every Agents API JSON route reads its body through `readJSONObject` before decoding, validation or lookup. The gate requires a JSON Content-Type, applies the route's body limit and rejects invalid UTF-8, malformed JSON (including unpaired surrogate escapes), repeated keys and non-object roots with the official messages; an empty body or `null` becomes `{}`. DELETE, multipart, Core extension and internal routes keep their own readers. Member names match exactly: decode request objects with `decodeInputObject`, or check `inexactMember` before another decoder, so `encoding/json` never matches a case variant. Report a validation failure that has official evidence through the typed field error, which emits `invalid_request_error` with the observed param and message; keep other local codes until their official fields are sampled. Saved and inline Agent configuration pass one path-tracking validator of the pinned shapes before their parsers and harness admission; do not grow it into a JSON Schema engine. A malformed path identifier must produce exactly the response of a well-formed missing one on that route, including for invalid bodies, queries and storage availability: the handler passes it unchanged, the storage call resolves it with `pgunit.PathID` to the never-assigned maximum UUID, and the missing path runs, or reject it directly only where the lookup is the next check. An `after` cursor that does not resolve inside its already resolved parent, malformed ones included, returns that list family's observed error, and foreign and missing cursors stay identical. U+0000 is rejected explicitly only in metadata (`metadata.`), by the `metadata` package; other stored strings rely on the PostgreSQL error mapping, so keep each request's writes in one transaction. diff --git a/services/core/cmd/server/http_routes.go b/services/core/cmd/server/http_routes.go deleted file mode 100644 index 3154facff..000000000 --- a/services/core/cmd/server/http_routes.go +++ /dev/null @@ -1,32 +0,0 @@ -package main - -import ( - "net/http" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" -) - -// daemonRoutes are the Runtime transport handlers served beside the API. Each -// authenticates its own callers. -type daemonRoutes struct { - gateway, enrollment, connection, nodeConnect http.Handler -} - -// serverHandler composes the daemon transport routes with the API handler. -// Path canonicalization wraps the whole composition, so the ServeMux, the API -// router and every middleware decide on the same canonical path, and the -// ServeMux never redirects a non-canonical path. Its only remaining redirect is -// the exact daemon prefix /api/v1/agent-daemon to /api/v1/agent-daemon/. The API -// handler canonicalizes again when served alone; the operation is idempotent. -func serverHandler(apiHandler http.Handler, daemon *daemonRoutes) http.Handler { - mux := http.NewServeMux() - mux.Handle("/api/v1/agent-daemon/", daemon.gateway) - mux.Handle("/api/v1/agent-daemon/enroll", daemon.enrollment) - mux.Handle("/api/v1/agent-daemon/connection", daemon.connection) - mux.Handle("/api/v1/agent-daemon/install/", apiHandler) - mux.Handle("/api/v1/agent-daemon/installation", apiHandler) - mux.Handle("/api/v1/agent-daemon/installation/", apiHandler) - mux.Handle("/api/v1/sandbox-node/connect", daemon.nodeConnect) - mux.Handle("/", apiHandler) - return api.CanonicalPaths(mux) -} diff --git a/services/core/cmd/server/http_routes_test.go b/services/core/cmd/server/http_routes_test.go deleted file mode 100644 index 1b52b8e5e..000000000 --- a/services/core/cmd/server/http_routes_test.go +++ /dev/null @@ -1,328 +0,0 @@ -package main - -import ( - "bufio" - "context" - "crypto/sha256" - "fmt" - "io" - "net" - "net/http" - "net/http/httptest" - "net/url" - "slices" - "strconv" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" - "github.com/google/uuid" -) - -// The daemon-enabled configuration canonicalizes paths before the ServeMux: -// no request is redirected, and a dirty or encoded path reaches exactly the -// handler its canonical path reaches, with the body intact (HP-17/HP-18). -func TestServerHandlerRoutesCanonicalPaths(t *testing.T) { - type observation struct{ route, method, path, rawPath, body string } - var seen []observation - sentinel := func(route string) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - seen = append(seen, observation{route, r.Method, r.URL.Path, r.URL.RawPath, string(body)}) - w.WriteHeader(http.StatusNoContent) - }) - } - handler := serverHandler(sentinel("api"), &daemonRoutes{gateway: sentinel("gateway"), enrollment: sentinel("enrollment"), - connection: sentinel("connection"), nodeConnect: sentinel("node")}) - for _, test := range []struct{ target, route, path, rawPath string }{ - {"/v1/agents/a", "api", "/v1/agents/a", ""}, - {"/v1//agents/a", "api", "/v1/agents/a", ""}, - {"//v1/agents", "api", "/v1/agents", ""}, - {"/v1/agents/x/../a", "api", "/v1/agents/a", ""}, - {"/v1/agents/agent%5Fa", "api", "/v1/agents/agent_a", ""}, - {"/v1/agents/a%2Fb", "api", "/v1/agents/a/b", "/v1/agents/a%2Fb"}, - {"/api/v1/agent-daemon/%2E%2E/%2E%2E/%2E%2E/v1/agents", "api", "/v1/agents", ""}, - {"/api/v1/agent-daemon/../../../core/v1/sandbox/nodes", "api", "/core/v1/sandbox/nodes", ""}, - {"/api/v1/agent-daemon%2Fenroll", "api", "/api/v1/agent-daemon/enroll", "/api/v1/agent-daemon%2Fenroll"}, - {"/api/v1/agent-daemon/ws", "gateway", "/api/v1/agent-daemon/ws", ""}, - {"/api/v1//agent-daemon/ws", "gateway", "/api/v1/agent-daemon/ws", ""}, - {"/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", ""}, - {"/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"}`)) - response := httptest.NewRecorder() - handler.ServeHTTP(response, request) - want := observation{test.route, http.MethodPost, test.path, test.rawPath, `{"model":"x"}`} - if response.Code != http.StatusNoContent || response.Header().Get("Location") != "" || len(seen) != 1 || seen[0] != want { - t.Errorf("%s = %d %v, want %v", test.target, response.Code, seen, want) - } - } -} - -// trapProjectsReader resolves the fixture Project keys; every other call panics. -type trapProjectsReader struct { - api.ProjectsReader - keys fixtureKeyResolver -} - -func (p trapProjectsReader) ResolveAPIKey(ctx context.Context, digest [sha256.Size]byte) (projects.KeyBinding, error) { - return p.keys.ResolveAPIKey(ctx, digest) -} - -// daemonComposition serves the real API handler beside sentinel daemon routes. -// Every dependency call panics, marking a request that reached a handler. -func daemonComposition(t testing.TB) http.Handler { - t.Helper() - keys := newTestAuthenticator(t, []testAPIKey{{OrganizationID: "org", ProjectID: "project", SubjectKind: "service_account", - SubjectID: "runner", TokenSHA256: runtimedevice.HashCredential("project-key"), TenantID: uuid.NewString()}}) - admin, err := api.NewDeploymentAuthenticator([]string{runtimedevice.HashCredential("admin-key")}) - if err != nil { - t.Fatal(err) - } - apiHandler, err := api.NewHandler(api.Dependencies{ - Engine: "codex", CoreKeys: admin, InstallationBindings: struct{ api.InstallationBindings }{}, - Projects: struct{ api.Projects }{}, ProjectsReader: trapProjectsReader{keys: keys}, - ModelProviders: struct{ api.ModelProviders }{}, ModelProvidersReader: struct{ api.ModelProvidersReader }{}, - Vaults: struct{ api.Vaults }{}, VaultsReader: struct{ api.VaultsReader }{}, - Files: struct{ api.Files }{}, FilesReader: struct{ api.FilesReader }{}, - Skills: struct{ api.Skills }{}, SkillsReader: struct{ api.SkillsReader }{}, - Agents: struct{ api.Agents }{}, AgentsReader: struct{ api.AgentsReader }{}, - EnvironmentTemplates: struct{ api.EnvironmentTemplates }{}, EnvironmentTemplatesReader: struct{ api.EnvironmentTemplatesReader }{}, - Sessions: struct{ api.Sessions }{}, - SessionsReader: struct{ api.SessionsReader }{}, - SessionCreation: struct{ api.SessionCreation }{}, - SessionEvents: struct{ api.SessionEvents }{}, - Turns: struct{ api.Turns }{}, - Items: struct{ api.Items }{}, - Subagents: struct{ api.Subagents }{}, - Artifacts: struct{ api.Artifacts }{}, - ArtifactsReader: struct{ api.ArtifactsReader }{}, - SessionAdmin: struct{ api.SessionAdmin }{}, Environments: struct{ api.Environments }{}, EnvironmentsReader: struct{ api.EnvironmentsReader }{}, ExecutorConnections: struct{ api.ExecutorConnections }{}, - Admin: struct{ api.Admin }{}, AdminAudit: struct{ api.AdminAudit }{}, WriteAudit: struct{ api.WriteAudit }{}, Metrics: struct{ api.Metrics }{}, - RuntimeObservations: struct{ api.RuntimeObservations }{}, RuntimeHistory: struct{ api.RuntimeHistory }{}, - Execution: api.Execution{ - ExecutorURL: "wss://core.example/api/v1/agent-daemon/ws", - SessionAdmission: struct{ api.SessionAdmission }{}, - InputAdmission: struct{ api.InputAdmission }{}, - SessionArchive: struct{ api.SessionArchive }{}, - Workspaces: struct{ api.EnvironmentWorkspaces }{}, - Links: struct{ http.Handler }{}, - }, - Sandboxes: api.Sandboxes{Deployment: struct{ api.Deployment }{}, NodeAllocations: unusedNodeAllocations{}, DeploymentChanges: struct{ api.DeploymentChanges }{}, - DeploymentReset: struct{ api.DeploymentReset }{}, ConfigurationDiscovery: struct{ api.ConfigurationDiscovery }{}}, - }) - if err != nil { - t.Fatal(err) - } - sentinel := func(route string) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Sentinel", route+" "+r.URL.Path+" "+r.URL.RawPath) - w.WriteHeader(http.StatusNoContent) - }) - } - return serverHandler(apiHandler, &daemonRoutes{gateway: sentinel("gateway"), enrollment: sentinel("enrollment"), - connection: sentinel("connection"), nodeConnect: sentinel("node")}) -} - -func parseRaw(target string) (*http.Request, error) { - return http.ReadRequest(bufio.NewReader(strings.NewReader("GET " + target + " HTTP/1.1\r\nHost: example.test\r\n\r\n"))) -} - -func outcome(handler http.Handler, request *http.Request) (result string) { - defer func() { - if recover() != nil { - result = "handler reached" - } - }() - response := httptest.NewRecorder() - handler.ServeHTTP(response, request) - return fmt.Sprintf("%d %q %q %q %q", response.Code, response.Header().Get("X-Sentinel"), response.Body.String(), response.Header().Get("Allow"), response.Header().Get("Location")) -} - -// canonical returns the escaped path the composition routes a request on. -func canonical(request *http.Request) string { - var seen string - api.CanonicalPaths(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { seen = r.URL.EscapedPath() })).ServeHTTP(httptest.NewRecorder(), request) - return seen -} - -// In the daemon-enabled configuration, a raw path with bytes that are invalid -// in an escaped path cannot turn %2F into a separator to reach daemon, node or -// sandbox administration routes; it reaches what its canonical form reaches. -func TestServerHandlerRawPathsKeepEncodedSeparators(t *testing.T) { - handler := daemonComposition(t) - server := httptest.NewServer(handler) - defer server.Close() - 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..%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/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/api/v1/sandbox-node/connect", "204"}, - } { - request, err := parseRaw(test.target) - if err != nil { - t.Fatal(err) - } - again, _ := parseRaw(canonical(request)) - got, want := outcome(handler, request), outcome(handler, again) - if got != want || !strings.HasPrefix(got, test.want+" ") { - t.Errorf("%s = %s; canonical form gives %s", test.target, got, want) - } - connection, err := net.Dial("tcp", server.Listener.Addr().String()) - if err != nil { - t.Fatal(err) - } - _, _ = io.WriteString(connection, "GET "+test.target+" HTTP/1.1\r\nHost: example.test\r\nConnection: close\r\n\r\n") - response, err := http.ReadResponse(bufio.NewReader(connection), nil) - if err != nil || strconv.Itoa(response.StatusCode) != test.want { - t.Errorf("raw %s = %v %v", test.target, response, err) - } - _ = connection.Close() - } -} - -// Differential property for the daemon-enabled configuration: any request path -// 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", "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/projects/x/%2E%2E/%2E%2E/%2E%2E/%2E%2E/api/v1/agent-daemon/enroll"} { - f.Add(seed) - } - handler := daemonComposition(f) - // Every route of the sentinel composition reports the path it was served on, - // as chi reads it: RawPath when set, otherwise the decoded Path. - sentinel := func(route string) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - routed := r.URL.RawPath - if routed == "" { - routed = r.URL.EscapedPath() - } - w.Header().Set("X-Route", route) - w.Header().Set("X-Routed-Path", routed) - w.WriteHeader(http.StatusNoContent) - }) - } - observed := serverHandler(sentinel("api"), &daemonRoutes{gateway: sentinel("gateway"), enrollment: sentinel("enrollment"), - connection: sentinel("connection"), nodeConnect: sentinel("node")}) - f.Fuzz(func(t *testing.T, path string) { - if strings.ContainsAny(path, " ?#") || len(path) > 512 { - t.Skip() - } - segments, trailing, ok := oraclePath("/" + path) - request, err := parseRaw("/" + path) - if err != nil || !ok { - t.Skip() - } - again, err := parseRaw(canonical(request)) - if err != nil { - t.Fatal(err) - } - if got, want := outcome(handler, request), outcome(handler, again); got != want { - t.Fatalf("%q = %s; canonical %q gives %s", path, got, again.RequestURI, want) - } - // Independently of CanonicalPaths: the ServeMux dispatches to the same - // route as the oracle's spelling, and the handler sees the oracle's segments. - oracle, err := parseRaw(oracleTarget(segments, trailing)) - if err != nil { - t.Fatal(err) - } - request, _ = parseRaw("/" + path) - served, expected := httptest.NewRecorder(), httptest.NewRecorder() - observed.ServeHTTP(served, request) - observed.ServeHTTP(expected, oracle) - if served.Code != expected.Code || served.Header().Get("X-Route") != expected.Header().Get("X-Route") { - t.Fatalf("%q = %d %s; oracle %q gives %d %s", path, served.Code, served.Header().Get("X-Route"), oracle.RequestURI, expected.Code, expected.Header().Get("X-Route")) - } - if served.Code == http.StatusNoContent { - if got, gotTrailing := routedSegments(served.Header().Get("X-Routed-Path")); !slices.Equal(got, segments) || gotTrailing != trailing { - t.Fatalf("%q is served on %q %v; the oracle gives %q %v", path, got, gotTrailing, segments, trailing) - } - } - }) -} - -// oraclePath derives the canonical segments of a raw request path without the -// implementation: split only on a literal '/', decode each segment once, treat -// a segment as a dot segment only if it decodes to "." or "..", and drop empty -// segments. Encoded separators such as %2F and %5C therefore stay inside their -// segment. It reports whether a trailing slash remains and whether every -// segment is validly escaped. -func oraclePath(raw string) (segments []string, trailing, ok bool) { - for _, part := range strings.Split(raw, "/") { - decoded, err := url.PathUnescape(part) - if err != nil { - return nil, false, false - } - switch decoded { - case "", ".": - case "..": - if len(segments) > 0 { - segments = segments[:len(segments)-1] - } - default: - segments = append(segments, decoded) - } - } - return segments, strings.HasSuffix(raw, "/") && len(segments) > 0, true -} - -// routedSegments splits a routed escaped path on literal '/' and decodes each -// segment once, so hex case does not affect the comparison. -func routedSegments(escaped string) (segments []string, trailing bool) { - trimmed := strings.TrimPrefix(escaped, "/") - trailing = strings.HasSuffix(trimmed, "/") - if trimmed = strings.TrimSuffix(trimmed, "/"); trimmed == "" { - return nil, false - } - for _, part := range strings.Split(trimmed, "/") { - decoded, _ := url.PathUnescape(part) - segments = append(segments, decoded) - } - return segments, trailing -} - -// oracleTarget spells oracle segments as a request path, escaping '%', '/' -// and every byte outside RFC 3986 pchar. -func oracleTarget(segments []string, trailing bool) string { - var target strings.Builder - for _, segment := range segments { - target.WriteByte('/') - for i := 0; i < len(segment); i++ { - c := segment[i] - if 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' || strings.IndexByte("-._~!$&'()*+,;=:@", c) >= 0 { - target.WriteByte(c) - } else { - fmt.Fprintf(&target, "%%%02X", c) - } - } - } - if trailing || len(segments) == 0 { - target.WriteByte('/') - } - return target.String() -} - -// unusedNodeAllocations stands in for the node allocation list, which the -// route tests never read. The interface's method shares its name, so the -// embedded-interface stand-in the other dependencies use cannot satisfy it. -type unusedNodeAllocations struct{} - -func (unusedNodeAllocations) NodeAllocations(context.Context, string) ([]deployment.NodeAllocation, error) { - panic("unexpected NodeAllocations") -} diff --git a/services/core/cmd/server/main.go b/services/core/cmd/server/main.go index c931e1d51..9d7661088 100644 --- a/services/core/cmd/server/main.go +++ b/services/core/cmd/server/main.go @@ -67,7 +67,6 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/skills" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults" "github.com/MiniMax-AI/OpenAgentCore/services/core/migrations" - "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -205,8 +204,6 @@ func run(config processconfig.Config) error { Authenticator: runtimegateway.NewAuthenticator(sessionStore), Registry: registry, Heartbeat: sessionService, Links: links, PublicWSURL: executorURL, }) - daemonHandler := chi.NewRouter() - daemonHandler.Route("/api/v1", func(r chi.Router) { runtimegateway.RegisterRoutes(r, gateway) }) defer registry.CloseConnections() linkRelay := relay.New(links) defer linkRelay.Close() @@ -338,8 +335,11 @@ func run(config processconfig.Config) error { Workspaces: worker, Links: linkRelay, NativeInstaller: nativeInstaller, + Bootstrap: http.HandlerFunc(gateway.Bootstrap), RuntimeConnect: http.HandlerFunc(gateway.WS), + Enrollment: runtimeenrollment.EnrollmentHandler(sessionService, config.PublicOrigin), Connection: connections, }, Sandboxes: api.Sandboxes{ + NodeConnect: managedNodes, Deployment: deploymentService, NodeAllocations: deploymentStore, DeploymentChanges: worker, @@ -351,11 +351,8 @@ func run(config processconfig.Config) error { if err != nil { return err } - handler := serverHandler(apiHandler, &daemonRoutes{gateway: daemonHandler, - enrollment: runtimeenrollment.EnrollmentHandler(sessionService, config.PublicOrigin), - connection: connections, - nodeConnect: managedNodes}) - server := &http.Server{Addr: config.Addr, Handler: handler, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second} + + server := &http.Server{Addr: config.Addr, Handler: apiHandler, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second} done := make(chan error, 1) go func() { done <- server.ListenAndServe() }() select { diff --git a/services/core/internal/api/contract_routes_test.go b/services/core/internal/api/contract_routes_test.go index 65b557b1c..e8d6bd797 100644 --- a/services/core/internal/api/contract_routes_test.go +++ b/services/core/internal/api/contract_routes_test.go @@ -16,10 +16,11 @@ import ( // Registered routes that are deliberately not contract operations, keyed // "METHOD /path"; the method * matches every method. var unpublishedRoutes = map[string]string{ - "GET /healthz": "liveness probe, not part of the Agent API", - "GET /docs": "reference page rendering the published contracts", - "GET /docs/{document}": "the published contract documents themselves", - "* /api/v1/agent-daemon/install/*": "public immutable native release content, not an API operation", + "GET /healthz": "liveness probe, not part of the Agent API", + "GET /docs": "reference page rendering the published contracts", + "GET /docs/{document}": "the published contract documents themselves", + "* /api/v1/agent-daemon/install": "canonical installer prefix redirect, not an operation", + "* /api/v1/agent-daemon": "canonical daemon prefix redirect, not an operation", } // contractOperations reads one committed contract as "METHOD /path" keys and @@ -84,6 +85,18 @@ func TestContractsPublishExactlyTheRegisteredCoreAndMachineRoutes(t *testing.T) return nil } } + // These handlers own their method errors; Handle preserves their + // existing status and credential precedence for unsupported methods. + methods := map[string][]string{ + "/api/v1/sandbox-node/connect": {http.MethodGet}, + "/api/v1/agent-daemon/enroll": {http.MethodPost}, + "/api/v1/agent-daemon/connection": {http.MethodGet}, + "/api/v1/agent-daemon/install/*": {http.MethodGet, http.MethodHead}, + } + if supported, exists := methods[route]; exists && !slices.Contains(supported, method) { + return nil + } + route = strings.ReplaceAll(route, "/install/*", "/install/{path}") // An explicit HEAD or OPTIONS 405 guard is not an operation. if f, ok := handler.(http.HandlerFunc); ok && (method == http.MethodHead || method == http.MethodOptions) && reflect.ValueOf(f).Pointer() == guard { return nil diff --git a/services/core/internal/api/dependencies.go b/services/core/internal/api/dependencies.go index 9bf48bfb0..68227eb9c 100644 --- a/services/core/internal/api/dependencies.go +++ b/services/core/internal/api/dependencies.go @@ -76,6 +76,8 @@ type Execution struct { Workspaces EnvironmentWorkspaces // Links is the Link relay, served at GET /api/v1/sandbox-link. Links http.Handler + // Machine handlers authenticate their own callers. + Bootstrap, RuntimeConnect, Enrollment, Connection http.Handler // NativeInstaller is nil for a build without a source revision: the native // installation routes are then absent and Sessions carry no installation. NativeInstaller *NativeInstaller @@ -84,6 +86,7 @@ type Execution struct { // Sandboxes is the managed sandbox deployment's surface. Every field is // required. type Sandboxes struct { + NodeConnect http.Handler Deployment Deployment NodeAllocations NodeAllocations DeploymentChanges DeploymentChanges @@ -152,6 +155,9 @@ func (d Dependencies) validate() error { field{"Execution.SessionArchive", e.SessionArchive}, field{"Execution.Workspaces", e.Workspaces}, field{"Execution.Links", e.Links}, + field{"Execution.Bootstrap", e.Bootstrap}, field{"Execution.RuntimeConnect", e.RuntimeConnect}, + field{"Execution.Enrollment", e.Enrollment}, field{"Execution.Connection", e.Connection}, + field{"Sandboxes.NodeConnect", s.NodeConnect}, field{"Sandboxes.Deployment", s.Deployment}, field{"Sandboxes.NodeAllocations", s.NodeAllocations}, field{"Sandboxes.DeploymentChanges", s.DeploymentChanges}, diff --git a/services/core/internal/api/dependencies_test.go b/services/core/internal/api/dependencies_test.go index a38c3b575..8d29370b9 100644 --- a/services/core/internal/api/dependencies_test.go +++ b/services/core/internal/api/dependencies_test.go @@ -121,8 +121,10 @@ func testDependencies(t testing.TB) (Dependencies, *testFakes) { SessionArchive: f.sessionArchive, Workspaces: f.workspaces, Links: f.links, + Bootstrap: f.links, RuntimeConnect: f.links, Enrollment: f.links, Connection: f.links, }, Sandboxes: Sandboxes{ + NodeConnect: f.links, Deployment: f.deployment, NodeAllocations: f.nodeAllocations, DeploymentChanges: f.deploymentChanges, @@ -185,6 +187,12 @@ func TestNewHandlerRejectsIncompleteDependencies(t *testing.T) { {"Execution.ExecutorURL", func(d *Dependencies, _ *testFakes) { d.Execution.ExecutorURL = "" }}, {"Execution.SessionAdmission", func(d *Dependencies, _ *testFakes) { d.Execution.SessionAdmission = nil }}, {"Execution.InputAdmission", func(d *Dependencies, _ *testFakes) { d.Execution.InputAdmission = nil }}, + + {"Execution.Bootstrap", func(d *Dependencies, _ *testFakes) { d.Execution.Bootstrap = nil }}, + {"Execution.RuntimeConnect", func(d *Dependencies, _ *testFakes) { d.Execution.RuntimeConnect = nil }}, + {"Execution.Enrollment", func(d *Dependencies, _ *testFakes) { d.Execution.Enrollment = nil }}, + {"Execution.Connection", func(d *Dependencies, _ *testFakes) { d.Execution.Connection = nil }}, + {"Sandboxes.NodeConnect", func(d *Dependencies, _ *testFakes) { d.Sandboxes.NodeConnect = nil }}, {"Execution.NativeInstaller.Version", func(d *Dependencies, _ *testFakes) { d.Execution.NativeInstaller = &NativeInstaller{} }}, {"Sandboxes.ConfigurationDiscovery", func(d *Dependencies, _ *testFakes) { d.Sandboxes.ConfigurationDiscovery = nil }}, } { diff --git a/services/core/internal/api/environment_installation.go b/services/core/internal/api/environment_installation.go index b90e80a55..cfa7a21d7 100644 --- a/services/core/internal/api/environment_installation.go +++ b/services/core/internal/api/environment_installation.go @@ -59,18 +59,6 @@ func (h *Handler) addSessionInstallation(w http.ResponseWriter, r *http.Request, return nil } -func (h *Handler) registerNativeInstallationRoutes(r chi.Router) { - installer := h.Execution.NativeInstaller - if installer == nil { - return - } - if installer.Catalog != nil { - r.Handle("/api/v1/agent-daemon/install/*", installer.Catalog) - } - r.Post("/api/v1/agent-daemon/installation", h.prepareNativeInstallation) - r.Post("/api/v1/agent-daemon/installation/claim", h.claimNativeInstallation) -} - // installationAuthorization validates a grant route's bearer grant. The routes // are registered only when this Core serves a native installer. func (h *Handler) installationAuthorization(w http.ResponseWriter, r *http.Request) (sessions.InstallationAuthorization, string, bool) { @@ -97,7 +85,7 @@ func (h *Handler) installationAuthorization(w http.ResponseWriter, r *http.Reque // @Tags Native Installation // @Produce json // @Success 200 {object} v1.NativeInstallationContext -// @Failure 401,404,503 {object} CoreErrorResponse +// @Failure 401,404,503 {object} v1.ErrorResponse // @Router /api/v1/agent-daemon/installation [post] func (h *Handler) prepareNativeInstallation(w http.ResponseWriter, r *http.Request) { claim, _, ok := h.installationAuthorization(w, r) @@ -132,7 +120,7 @@ type NativeInstallationClaim struct { // @Accept json // @Param body body api.NativeInstallationClaim true "Locally persisted executor secret" // @Success 204 -// @Failure 400,401,409,503 {object} CoreErrorResponse +// @Failure 400,401,409,503 {object} v1.ErrorResponse // @Router /api/v1/agent-daemon/installation/claim [post] func (h *Handler) claimNativeInstallation(w http.ResponseWriter, r *http.Request) { _, token, ok := h.installationAuthorization(w, r) diff --git a/services/core/internal/api/errors.go b/services/core/internal/api/errors.go index ff91dce09..b0ad61538 100644 --- a/services/core/internal/api/errors.go +++ b/services/core/internal/api/errors.go @@ -29,27 +29,12 @@ func writeError(w http.ResponseWriter, status int, code, message string, param . func writeAPIError(w http.ResponseWriter, status int, code, message string, details CoreErrorDetails, param ...string) { reportAPIError(w, code) - kind := "invalid_request_error" - if status >= 500 { - kind = "server_error" - } else if status == http.StatusConflict { - kind = "conflict_error" - } else if code == "not_found_error" || code == "invalid_beta" { - kind = code - } - var errorCode *string - if code != "" { - errorCode = &code - } - var errorParam *string - if len(param) > 0 { - errorParam = ¶m[0] - } + response := v1.NewAPIError(status, code, message, param...) if isCoreErrorWriter(w) { - writeJSON(w, status, CoreErrorResponse{Error: CoreAPIError{Message: message, Type: kind, Code: errorCode, Param: errorParam, Details: validCoreDetails(details)}}) + writeJSON(w, status, CoreErrorResponse{Error: CoreAPIError{Message: response.Message, Type: response.Type, Code: response.Code, Param: response.Param, Details: validCoreDetails(details)}}) return } - writeJSON(w, status, v1.ErrorResponse{Error: v1.APIError{Message: message, Type: kind, Code: errorCode, Param: errorParam}}) + writeJSON(w, status, v1.ErrorResponse{Error: response}) } // writeContentTooLarge reports uploaded or copied content beyond the diff --git a/services/core/internal/api/handler.go b/services/core/internal/api/handler.go index 8fa9b128a..f96bd62b7 100644 --- a/services/core/internal/api/handler.go +++ b/services/core/internal/api/handler.go @@ -69,11 +69,8 @@ func (h *Handler) routes() *chi.Mux { r.Head("/v1/files/{file_id}/content", methodNotAllowed) r.Delete("/v1/files/{file_id}", h.deleteSourceFile) }) - h.registerSandboxNodeRoutes(router) h.registerCoreRoutes(router) - h.registerNativeInstallationRoutes(router) - router.Get("/api/v1/sandbox-link", h.sandboxLink) - router.Head("/api/v1/sandbox-link", methodNotAllowed) + h.registerMachineRoutes(router) router.Route("/v1", func(r chi.Router) { r.Use(h.authenticate) r.Post("/vaults", h.createVault) diff --git a/services/core/internal/api/machine_paths_test.go b/services/core/internal/api/machine_paths_test.go new file mode 100644 index 000000000..4d6ea1bfa --- /dev/null +++ b/services/core/internal/api/machine_paths_test.go @@ -0,0 +1,210 @@ +package api + +import ( + "bufio" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "slices" + "strconv" + "strings" + "testing" + + "github.com/go-chi/chi/v5" +) + +// Machine registration receives canonical paths before routing: +// no request is redirected, and a dirty or encoded path reaches exactly the +// handler its canonical path reaches, with the body intact (HP-17/HP-18). +func TestMachineHandlerRoutesCanonicalPaths(t *testing.T) { + type observation struct{ route, method, path, rawPath, body string } + var seen []observation + sentinel := func(route string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + seen = append(seen, observation{route, r.Method, r.URL.Path, r.URL.RawPath, string(body)}) + w.WriteHeader(http.StatusNoContent) + }) + } + handler := machineSentinels(sentinel("api"), sentinel("gateway"), sentinel("enrollment"), sentinel("connection"), sentinel("node")) + for _, test := range []struct{ target, route, path, rawPath string }{ + {"/v1/agents/a", "api", "/v1/agents/a", ""}, + {"/v1//agents/a", "api", "/v1/agents/a", ""}, + {"//v1/agents", "api", "/v1/agents", ""}, + {"/v1/agents/x/../a", "api", "/v1/agents/a", ""}, + {"/v1/agents/agent%5Fa", "api", "/v1/agents/agent_a", ""}, + {"/v1/agents/a%2Fb", "api", "/v1/agents/a/b", "/v1/agents/a%2Fb"}, + {"/api/v1/agent-daemon/%2E%2E/%2E%2E/%2E%2E/v1/agents", "api", "/v1/agents", ""}, + {"/api/v1/agent-daemon/../../../core/v1/sandbox/nodes", "api", "/core/v1/sandbox/nodes", ""}, + + {"/api/v1/agent-daemon/ws", "gateway", "/api/v1/agent-daemon/ws", ""}, + {"/api/v1//agent-daemon/ws", "gateway", "/api/v1/agent-daemon/ws", ""}, + {"/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", ""}, + {"/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.MethodGet, test.target, strings.NewReader(`{"model":"x"}`)) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + want := observation{test.route, http.MethodGet, test.path, test.rawPath, `{"model":"x"}`} + if response.Code != http.StatusNoContent || response.Header().Get("Location") != "" || len(seen) != 1 || seen[0] != want { + t.Errorf("%s = %d %v, want %v", test.target, response.Code, seen, want) + } + } +} + +// daemonComposition exercises the API's machine registration with sentinel +// transport handlers. Other calls hit strict API dependencies. +func daemonComposition(t testing.TB) http.Handler { + deps, _ := testDependencies(trapTB{t}) + sentinel := func(route string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Sentinel", route+" "+r.URL.Path+" "+r.URL.RawPath) + w.WriteHeader(http.StatusNoContent) + }) + } + deps.Execution.Bootstrap, deps.Execution.RuntimeConnect = sentinel("gateway"), sentinel("gateway") + deps.Execution.Enrollment, deps.Execution.Connection = sentinel("enrollment"), sentinel("connection") + deps.Sandboxes.NodeConnect = sentinel("node") + return newTestHandler(t, deps) +} + +// machineSentinels uses the production registration, with no duplicate route +// table. Requests outside it are observed by the API sentinel. +func machineSentinels(apiHandler, gateway, enrollment, connection, node http.Handler) http.Handler { + h := &Handler{Dependencies: Dependencies{Execution: Execution{Bootstrap: gateway, RuntimeConnect: gateway, Enrollment: enrollment, Connection: connection, Links: gateway}, Sandboxes: Sandboxes{NodeConnect: node}}} + router := chi.NewRouter() + router.NotFound(apiHandler.ServeHTTP) + h.registerMachineRoutes(router) + return CanonicalPaths(router) +} + +func machineParseRaw(target string) (*http.Request, error) { + return http.ReadRequest(bufio.NewReader(strings.NewReader("GET " + target + " HTTP/1.1\r\nHost: example.test\r\n\r\n"))) +} + +func machineOutcome(handler http.Handler, request *http.Request) (result string) { + defer func() { + if recover() != nil { + result = "handler reached" + } + }() + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + return fmt.Sprintf("%d %q %q %q %q", response.Code, response.Header().Get("X-Sentinel"), response.Body.String(), response.Header().Get("Allow"), response.Header().Get("Location")) +} + +// canonical returns the escaped path the composition routes a request on. +func canonical(request *http.Request) string { + var seen string + CanonicalPaths(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { seen = r.URL.EscapedPath() })).ServeHTTP(httptest.NewRecorder(), request) + return seen +} + +// In the machine route configuration, a raw path with bytes that are invalid +// in an escaped path cannot turn %2F into a separator to reach daemon, node or +// sandbox administration routes; it reaches what its canonical form reaches. +func TestMachineHandlerRawPathsKeepEncodedSeparators(t *testing.T) { + handler := daemonComposition(t) + server := httptest.NewServer(handler) + defer server.Close() + 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..%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/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/api/v1/sandbox-node/connect", "204"}, + } { + request, err := machineParseRaw(test.target) + if err != nil { + t.Fatal(err) + } + again, _ := machineParseRaw(canonical(request)) + got, want := machineOutcome(handler, request), machineOutcome(handler, again) + if got != want || !strings.HasPrefix(got, test.want+" ") { + t.Errorf("%s = %s; canonical form gives %s", test.target, got, want) + } + connection, err := net.Dial("tcp", server.Listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(connection, "GET "+test.target+" HTTP/1.1\r\nHost: example.test\r\nConnection: close\r\n\r\n") + response, err := http.ReadResponse(bufio.NewReader(connection), nil) + if err != nil || strconv.Itoa(response.StatusCode) != test.want { + t.Errorf("raw %s = %v %v", test.target, response, err) + } + _ = connection.Close() + } +} + +// Differential property for the machine route configuration: any request path +// reaches the same handler, with the same path, as its canonical form. +func FuzzMachineHandlerRoutesLikeCanonicalForm(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", "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/projects/x/%2E%2E/%2E%2E/%2E%2E/%2E%2E/api/v1/agent-daemon/enroll"} { + f.Add(seed) + } + handler := daemonComposition(f) + // Every route of the sentinel composition reports the path it was served on, + // as chi reads it: RawPath when set, otherwise the decoded Path. + sentinel := func(route string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + routed := r.URL.RawPath + if routed == "" { + routed = r.URL.EscapedPath() + } + w.Header().Set("X-Route", route) + w.Header().Set("X-Routed-Path", routed) + w.WriteHeader(http.StatusNoContent) + }) + } + observed := machineSentinels(sentinel("api"), sentinel("gateway"), sentinel("enrollment"), sentinel("connection"), sentinel("node")) + f.Fuzz(func(t *testing.T, path string) { + if strings.ContainsAny(path, " ?#") || len(path) > 512 { + t.Skip() + } + segments, trailing, ok := oraclePath("/" + path) + request, err := machineParseRaw("/" + path) + if err != nil || !ok { + t.Skip() + } + again, err := machineParseRaw(canonical(request)) + if err != nil { + t.Fatal(err) + } + if got, want := machineOutcome(handler, request), machineOutcome(handler, again); got != want { + t.Fatalf("%q = %s; canonical %q gives %s", path, got, again.RequestURI, want) + } + // Independently of CanonicalPaths: the router dispatches to the same + // route as the oracle's spelling, and the handler sees the oracle's segments. + oracle, err := machineParseRaw(oracleTarget(segments, trailing)) + if err != nil { + t.Fatal(err) + } + request, _ = machineParseRaw("/" + path) + served, expected := httptest.NewRecorder(), httptest.NewRecorder() + observed.ServeHTTP(served, request) + observed.ServeHTTP(expected, oracle) + if served.Code != expected.Code || served.Header().Get("X-Route") != expected.Header().Get("X-Route") { + t.Fatalf("%q = %d %s; oracle %q gives %d %s", path, served.Code, served.Header().Get("X-Route"), oracle.RequestURI, expected.Code, expected.Header().Get("X-Route")) + } + if served.Code == http.StatusNoContent { + if got, gotTrailing := routedSegments(served.Header().Get("X-Routed-Path")); !slices.Equal(got, segments) || gotTrailing != trailing { + t.Fatalf("%q is served on %q %v; the oracle gives %q %v", path, got, gotTrailing, segments, trailing) + } + } + }) +} diff --git a/services/core/internal/api/machine_routes.go b/services/core/internal/api/machine_routes.go new file mode 100644 index 000000000..9e431781c --- /dev/null +++ b/services/core/internal/api/machine_routes.go @@ -0,0 +1,115 @@ +package api + +import ( + "net/http" + + "github.com/go-chi/chi/v5" +) + +// registerMachineRoutes owns every machine HTTP route. Handlers retain their +// credential checks and method precedence; the API only composes them. +func (h *Handler) registerMachineRoutes(router chi.Router) { + for _, prefix := range []string{"/api/v1/agent-daemon", "/api/v1/agent-daemon/install"} { + router.HandleFunc(prefix, func(w http.ResponseWriter, r *http.Request) { + target := prefix + "/" + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + http.Redirect(w, r, target, http.StatusMovedPermanently) + }) + } + router.Route("/api/v1", func(r chi.Router) { + r.NotFound(func(w http.ResponseWriter, _ *http.Request) { + writeError(w, http.StatusNotFound, "", "404 page not found") + }) + r.Post("/sandbox-node/enroll", h.enrollSandboxNode) + r.Get("/sandbox-node/identity", h.sandboxNodeIdentity) + r.Get("/sandbox-node/configuration", h.sandboxNodeConfiguration) + + // @Summary Open a sandbox node connection + // @Description Authenticates a node credential before upgrading to the sandbox node wire protocol. Other methods return 503 without authentication or upgrade. + // @Tags Sandbox Nodes + // @Param Authorization header string true "Bearer node credential" + // @Param node_id query string true "Node UUID" + // @Success 101 "Switching Protocols" + // @Failure 400,401,403,409,500,503 {object} v1.ErrorResponse + // @Router /api/v1/sandbox-node/connect [get] + r.Handle("/sandbox-node/connect", h.Sandboxes.NodeConnect) + + // @Summary Bootstrap an agent-host Runtime + // @Description Authenticates the agent-host credential and returns its WebSocket URL and heartbeat interval. + // @Tags Runtime Daemon + // @Accept json + // @Produce json + // @Param Authorization header string true "Bearer agent-host credential" + // @Param body body runtimegateway.BootstrapRequest true "Agent-host identity" + // @Success 200 {object} runtimegateway.BootstrapResponse + // @Failure 400,401,403,500 {object} v1.ErrorResponse + // @Router /api/v1/agent-daemon/bootstrap [post] + r.Method(http.MethodPost, "/agent-daemon/bootstrap", h.Execution.Bootstrap) + + // @Summary Open an agent-host Runtime connection + // @Description Authenticates the agent-host credential and exact Runtime protocol version before upgrading to the Core–Runtime wire protocol. + // @Tags Runtime Daemon + // @Param Authorization header string true "Bearer agent-host credential" + // @Param device_id query string true "Agent-host device ID" + // @Param version query string true "Runtime protocol version" + // @Success 101 "Switching Protocols" + // @Failure 400,401,403,426,500 {object} v1.ErrorResponse + // @Router /api/v1/agent-daemon/ws [get] + r.Method(http.MethodGet, "/agent-daemon/ws", h.Execution.RuntimeConnect) + r.Head("/agent-daemon/ws", methodNotAllowed) + + // @Summary Enroll a self-hosted sandbox + // @Description Accepts an executor credential and exactly one environment_id. Returns the Environment's Link URL and resource without issuing a credential. Queries and bodies over 4096 bytes are rejected. + // @Tags Runtime Daemon + // @Accept json + // @Produce json + // @Param Authorization header string true "Bearer executor credential" + // @Param body body runtimeenrollment.EnrollmentRequest true "Environment identity" + // @Success 200 {object} runtimeenrollment.EnrollmentResponse + // @Failure 400,401,409,503 {object} v1.ErrorResponse + // @Router /api/v1/agent-daemon/enroll [post] + r.Handle("/agent-daemon/enroll", h.Execution.Enrollment) + + // @Summary Observe a self-hosted sandbox connection + // @Description Rechecks executor authority around the live Link resource read. Never enrolls the sandbox or starts execution. Responses carry Cache-Control no-store. + // @Tags Runtime Daemon + // @Produce json + // @Param Authorization header string true "Bearer executor credential" + // @Param environment_id query string true "Environment UUID" + // @Success 200 {object} runtimeenrollment.ConnectionResponse + // @Failure 400,401,409,503 {object} v1.ErrorResponse + // @Router /api/v1/agent-daemon/connection [get] + r.Handle("/agent-daemon/connection", h.Execution.Connection) + + if installer := h.Execution.NativeInstaller; installer != nil { + if installer.Catalog != nil { + // @Summary Download native installation content + // @Description Public versioned bootstrap script, checksum or Linux amd64 archive. An archive may redirect to its qualified release URL; local archives support conditional and range requests. GET and HEAD share the download headers. + // @Tags Native Installation + // @Produce plain,octet-stream + // @Param path path string true "Version and filename: {version}/bootstrap.sh, {version}/linux-amd64.sha256 or {version}/linux-amd64.tar.gz" + // @Success 200 {file} file + // @Success 206 {file} file + // @Success 304 "Not Modified" + // @Success 307 "Temporary Redirect" + // @Failure 400,403,404,405,416,500 {object} v1.ErrorResponse + // @Router /api/v1/agent-daemon/install/{path} [get] + // @Router /api/v1/agent-daemon/install/{path} [head] + r.Handle("/agent-daemon/install/*", installer.Catalog) + } + r.Post("/agent-daemon/installation", h.prepareNativeInstallation) + r.Post("/agent-daemon/installation/claim", h.claimNativeInstallation) + } + + // @Summary Open a sandbox Link + // @Description Upgrades to a WebSocket that carries the Sandbox link protocol. The Sandbox I/O service connects as the serve peer and the agent-host Runtime as the attach peer. Each peer authenticates in its Link Hello after the upgrade. + // @Tags Sandbox Link + // @Success 101 "Switching Protocols; the connection carries the Link" + // @Failure 400,403,500 {object} v1.ErrorResponse + // @Router /api/v1/sandbox-link [get] + r.Method(http.MethodGet, "/sandbox-link", h.Execution.Links) + r.Head("/sandbox-link", methodNotAllowed) + }) +} diff --git a/services/core/internal/api/machine_routes_test.go b/services/core/internal/api/machine_routes_test.go new file mode 100644 index 000000000..4b5479dde --- /dev/null +++ b/services/core/internal/api/machine_routes_test.go @@ -0,0 +1,185 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxbootstrap" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink/relay" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink/sandboxlinktest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/nativeinstaller" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeenrollment" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/node" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" + "github.com/google/uuid" +) + +// machineStore authenticates one fixture host and rejects all executor keys. +// Unexpected state reads fail: rejected credentials must precede those reads. +type machineStore struct { + t *testing.T + calls int +} + +func (s *machineStore) GetDeviceCredential(_ context.Context, id string) (runtimedevice.Credential, bool, error) { + s.calls++ + return runtimedevice.Credential{ID: "host", Type: runtimedevice.RuntimeTypeAgentDaemon, CredentialHash: runtimedevice.HashCredential("host-key")}, id == "host", nil +} +func (s *machineStore) EnrollRuntime(context.Context, string, string) (sandboxbootstrap.Resource, error) { + s.calls++ + return sandboxbootstrap.Resource{}, sessions.ErrNotFound +} +func (s *machineStore) AuthenticateEnvironmentExecutor(context.Context, string, string) (string, error) { + s.calls++ + return "", sessions.ErrNotFound +} +func (s *machineStore) GetEnvironment(context.Context, string, string) (sessions.Environment, error) { + s.t.Fatal("unauthorized environment read") + return sessions.Environment{}, nil +} +func (s *machineStore) GetEnvironmentResource(context.Context, string, string) (runtimedevice.ServeAuthority, error) { + s.t.Fatal("unauthorized resource read") + return runtimedevice.ServeAuthority{}, nil +} + +func TestMachineRoutesPreserveAuthorityAndMethodPrecedence(t *testing.T) { + deps, fakes := testDependencies(t) + store := &machineStore{t: t} + links := relay.New(sandboxlinktest.NewAuthority()) + defer links.Close() + registry := runtimegateway.NewRegistry() + defer registry.CloseConnections() + gateway := runtimegateway.NewHandler(runtimegateway.HandlerConfig{Authenticator: runtimegateway.NewAuthenticator(store), Registry: registry, PublicWSURL: testExecutorURL}) + origin, err := deployment.NewPublicOrigin("https://core.example") + if err != nil { + t.Fatal(err) + } + deps.Execution.Bootstrap, deps.Execution.RuntimeConnect = http.HandlerFunc(gateway.Bootstrap), http.HandlerFunc(gateway.WS) + deps.Execution.Enrollment = runtimeenrollment.EnrollmentHandler(store, origin) + deps.Execution.Connection = &runtimeenrollment.Connections{Store: store, Links: links} + deps.Execution.Links = links + deps.Execution.NativeInstaller = &NativeInstaller{Version: "build", Base: "https://core.example/api/v1/agent-daemon/install/", Catalog: &nativeinstaller.Catalog{Version: "build"}} + nodeID := uuid.NewString() + hub := node.NewHub(node.HubOptions{Authenticate: func(_ context.Context, id, credential string) (node.Identity, error) { + store.calls++ + if credential != "node-key" { + return node.Identity{}, node.ErrAuthentication + } + return node.Identity{NodeID: id}, nil + }, OwnerEpoch: func(context.Context) (uint64, error) { return 1, nil }}) + defer hub.Close() + deps.Sandboxes.NodeConnect = hub + fakes.deployment.nodeConfiguration = func(context.Context, string, string, uint64) (deployment.NodeConfiguration, error) { + return deployment.NodeConfiguration{}, deployment.ErrNodeCredential + } + fakes.deployment.nodeStatus = func(context.Context, string, string) (deployment.NodeStatus, error) { + return deployment.NodeStatus{}, deployment.ErrNodeCredential + } + fakes.deployment.enroll = func(context.Context, string, deployment.Enrollment) (deployment.NodeIdentity, error) { + return deployment.NodeIdentity{}, deployment.ErrNodeCredential + } + fakes.environments.validateEnvironmentInstallation = func(context.Context, string, string) (sessions.InstallationAuthorization, error) { + return sessions.InstallationAuthorization{}, sessions.ErrInstallationAuthorization + } + handler := newTestHandler(t, deps) + for _, tc := range []struct { + name, method, path, authorization, body string + status, calls int + allow string + }{ + {"node configuration missing", "GET", "/sandbox-node/configuration", "", "", 401, 0, ""}, + {"node configuration wrong", "GET", "/sandbox-node/configuration", "Bearer wrong", "", 401, 0, ""}, + {"node identity wrong", "GET", "/sandbox-node/identity?node_id=" + nodeID, "Bearer wrong", "", 401, 0, ""}, + {"node enroll credential before body", "POST", "/sandbox-node/enroll", "", "{", 401, 0, ""}, + {"node enroll wrong", "POST", "/sandbox-node/enroll", "Bearer wrong", `{"core_url":"https://core.example"}`, 401, 0, ""}, + {"node handshake wrong", "GET", "/sandbox-node/connect?node_id=" + nodeID, "Bearer wrong", "", 401, 1, ""}, + {"node handshake invalid", "GET", "/sandbox-node/connect?node_id=" + nodeID, "Bearer node-key", "", 400, 1, ""}, + {"node HEAD never authenticates", "HEAD", "/sandbox-node/connect?node_id=" + nodeID, "Bearer node-key", "", 503, 0, ""}, + {"node POST preserves status", "POST", "/sandbox-node/connect", "", "", 503, 0, ""}, + {"bootstrap credential before body", "POST", "/agent-daemon/bootstrap", "", "{", 401, 0, ""}, + {"bootstrap wrong credential", "POST", "/agent-daemon/bootstrap", "Bearer wrong", `{"device_id":"host"}`, 401, 1, ""}, + {"bootstrap malformed", "POST", "/agent-daemon/bootstrap", "Bearer host-key", "{", 400, 0, ""}, + {"bootstrap HEAD", "HEAD", "/agent-daemon/bootstrap", "Bearer host-key", "", 405, 0, "POST"}, + {"runtime missing", "GET", "/agent-daemon/ws", "", "", 400, 0, ""}, + {"runtime wrong", "GET", "/agent-daemon/ws?device_id=host&version=" + proto.Version, "Bearer wrong", "", 401, 1, ""}, + {"runtime handshake", "GET", "/agent-daemon/ws?device_id=host&version=" + proto.Version, "Bearer host-key", "", 400, 1, ""}, + {"runtime version", "GET", "/agent-daemon/ws?device_id=host&version=invalid", "Bearer host-key", "", 426, 0, ""}, + {"runtime HEAD", "HEAD", "/agent-daemon/ws?device_id=host&version=" + proto.Version, "Bearer host-key", "", 405, 0, "GET"}, + {"enroll missing before body", "POST", "/agent-daemon/enroll", "", "{", 401, 0, ""}, + {"enroll wrong", "POST", "/agent-daemon/enroll", "Bearer wrong", `{"environment_id":"environment"}`, 401, 1, ""}, + {"enroll oversized", "POST", "/agent-daemon/enroll", "Bearer wrong", `{"environment_id":"` + strings.Repeat("x", 4096) + `"}`, 400, 0, ""}, + {"enroll query", "POST", "/agent-daemon/enroll?x=1", "Bearer wrong", `{"environment_id":"environment"}`, 400, 0, ""}, + {"enroll HEAD", "HEAD", "/agent-daemon/enroll", "", "", 405, 0, "POST"}, + {"connection missing", "GET", "/agent-daemon/connection", "", "", 401, 0, ""}, + {"connection wrong", "GET", "/agent-daemon/connection?environment_id=environment", "Bearer wrong", "", 401, 1, ""}, + {"connection HEAD", "HEAD", "/agent-daemon/connection?environment_id=environment", "Bearer wrong", "", 405, 0, "GET"}, + {"installation missing", "POST", "/agent-daemon/installation", "", "", 401, 0, ""}, + {"installation wrong", "POST", "/agent-daemon/installation", "Bearer wrong", "", 401, 0, ""}, + {"claim missing before body", "POST", "/agent-daemon/installation/claim", "", "{", 401, 0, ""}, + {"claim wrong", "POST", "/agent-daemon/installation/claim", "Bearer wrong", "{", 401, 0, ""}, + {"installer missing", "GET", "/agent-daemon/install/build/no-file", "", "", 404, 0, ""}, + {"installer wrong method", "POST", "/agent-daemon/install/build/bootstrap.sh", "", "", 405, 0, ""}, + {"link handshake", "GET", "/sandbox-link", "", "", 400, 0, ""}, + {"link HEAD", "HEAD", "/sandbox-link", "", "", 405, 0, "GET"}, + {"unknown daemon", "GET", "/agent-daemon/missing", "", "", 404, 0, ""}, + {"encoded route separator", "GET", "/agent-daemon%2Fconnection", "Bearer wrong", "", 404, 0, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + store.calls = 0 + request := httptest.NewRequest(tc.method, "/api/v1"+tc.path, strings.NewReader(tc.body)) + request.Header.Set("Authorization", tc.authorization) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != tc.status || store.calls != tc.calls || response.Header().Get("Allow") != tc.allow { + t.Fatalf("status/calls/Allow = %d/%d/%q; want %d/%d/%q; %s", response.Code, store.calls, response.Header().Get("Allow"), tc.status, tc.calls, tc.allow, response.Body) + } + var envelope v1.ErrorResponse + if response.Header().Get("Content-Type") != "application/json" || json.Unmarshal(response.Body.Bytes(), &envelope) != nil || envelope.Error.Type == "" { + t.Fatalf("non-machine error: %s", response.Body) + } + if strings.Contains(tc.name, "handshake") && tc.status == 400 && response.Header().Get("Sec-Websocket-Version") != "13" { + t.Fatal("WebSocket version response header lost") + } + if strings.Contains(response.Body.String(), "host-key") || strings.Contains(response.Body.String(), "node-key") { + t.Fatal("credential leaked") + } + }) + } + // An otherwise valid WebSocket request still respects the node and Link + // origin checks, and preserves the SDK's failure headers. + for _, tc := range []struct { + path, credential string + calls int + }{ + {"/sandbox-node/connect?node_id=" + nodeID, "Bearer node-key", 1}, + {"/sandbox-link", "", 0}, + } { + store.calls = 0 + request := httptest.NewRequest(http.MethodGet, "/api/v1"+tc.path, nil) + request.Header = http.Header{"Authorization": {tc.credential}, "Connection": {"Upgrade"}, "Upgrade": {"websocket"}, "Sec-Websocket-Version": {"13"}, "Sec-Websocket-Key": {"dGhlIHNhbXBsZSBub25jZQ=="}, "Origin": {"https://foreign.example"}} + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + var envelope v1.ErrorResponse + if response.Code != 403 || store.calls != tc.calls || response.Header().Get("Sec-Websocket-Version") != "13" || json.Unmarshal(response.Body.Bytes(), &envelope) != nil || envelope.Error.Message != http.StatusText(403) { + t.Fatalf("origin check %s: %v", tc.path, response) + } + } + for _, prefix := range []string{"/api/v1/agent-daemon", "/api/v1/agent-daemon/install"} { + for _, method := range []string{"GET", "POST", "HEAD"} { + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(method, prefix+"?keep=query", nil)) + if response.Code != 301 || response.Header().Get("Location") != prefix+"/?keep=query" { + t.Fatalf("prefix redirect %s: %v", method, response) + } + } + } +} diff --git a/services/core/internal/api/routing.go b/services/core/internal/api/routing.go index 782c7d4dd..1cae8c316 100644 --- a/services/core/internal/api/routing.go +++ b/services/core/internal/api/routing.go @@ -27,8 +27,7 @@ import ( // segment resolves like a literal one, and empty and dot segments are resolved // with ServeMux cleanPath semantics, keeping a trailing slash. Other escapes, // such as %2F and %5C, stay encoded and never become separators. Path and -// RawPath are then set consistently, so chi (which prefers RawPath), the -// ServeMux (which uses EscapedPath) and every middleware see the same path, and +// RawPath are then set consistently, so chi (which prefers RawPath) and every middleware see the same path, and // every spelling reaches exactly the route and authentication of its canonical // form written literally. It is idempotent. func CanonicalPaths(next http.Handler) http.Handler { diff --git a/services/core/internal/api/routing_test.go b/services/core/internal/api/routing_test.go index 6b802bd3b..3ef5065d4 100644 --- a/services/core/internal/api/routing_test.go +++ b/services/core/internal/api/routing_test.go @@ -276,7 +276,13 @@ func TestEveryRouteAuthenticatesItsCanonicalPath(t *testing.T) { handler, router, s := routingFixture(t) selfAuthenticated := map[string]bool{"GET /healthz": false, "GET /docs": false, "GET /docs/{document}": false, "POST /api/v1/sandbox-node/enroll": false, "GET /api/v1/sandbox-node/identity": false, "GET /api/v1/sandbox-node/configuration": false, // The Link authenticates in its Hello, after the upgrade. - "GET /api/v1/sandbox-link": false, "HEAD /api/v1/sandbox-link": false} + "GET /api/v1/sandbox-link": false, "HEAD /api/v1/sandbox-link": false, + "POST /api/v1/agent-daemon/bootstrap": false, "GET /api/v1/agent-daemon/ws": false, "HEAD /api/v1/agent-daemon/ws": false} + for _, route := range []string{"/api/v1/agent-daemon", "/api/v1/agent-daemon/install", "/api/v1/agent-daemon/enroll", "/api/v1/agent-daemon/connection", "/api/v1/sandbox-node/connect"} { + for _, method := range []string{"CONNECT", "DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "TRACE", "QUERY"} { + selfAuthenticated[method+" "+route] = 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"}), diff --git a/services/core/internal/api/sandbox_link.go b/services/core/internal/api/sandbox_link.go deleted file mode 100644 index e4721a2e1..000000000 --- a/services/core/internal/api/sandbox_link.go +++ /dev/null @@ -1,14 +0,0 @@ -package api - -import "net/http" - -// @Summary Open a sandbox Link -// @Description Upgrades to a WebSocket that carries the Sandbox link protocol. The Sandbox I/O service connects as the serve peer and the agent-host Runtime as the attach peer. The route takes no credential: each peer authenticates in its Link Hello, and the relay ends a link the Hello does not authenticate. -// @Tags Sandbox Link -// @Success 101 "Switching Protocols; the connection carries the Link" -// @Failure 400 "The request is not a WebSocket upgrade" -// @Failure 403 "The request carries an Origin other than its Host" -// @Router /api/v1/sandbox-link [get] -func (h *Handler) sandboxLink(w http.ResponseWriter, r *http.Request) { - h.Execution.Links.ServeHTTP(w, r) -} diff --git a/services/core/internal/api/sandbox_manager.go b/services/core/internal/api/sandbox_manager.go index 57713a257..eddc255b8 100644 --- a/services/core/internal/api/sandbox_manager.go +++ b/services/core/internal/api/sandbox_manager.go @@ -56,14 +56,6 @@ type NodeAllocations interface { NodeAllocations(ctx context.Context, nodeID string) ([]deployment.NodeAllocation, error) } -// registerSandboxNodeRoutes serves node machine connections. They authenticate -// with an enrollment token or node credential, never the Core key. -func (h *Handler) registerSandboxNodeRoutes(r chi.Router) { - 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) -} - // registerSandboxManagerRoutes adds sandbox deployment and node administration // to the Core-key-authenticated /core/v1 router. func (h *Handler) registerSandboxManagerRoutes(r chi.Router) { diff --git a/services/core/internal/nativeinstaller/catalog.go b/services/core/internal/nativeinstaller/catalog.go index 1d857be62..900209e56 100644 --- a/services/core/internal/nativeinstaller/catalog.go +++ b/services/core/internal/nativeinstaller/catalog.go @@ -17,6 +17,7 @@ import ( "regexp" "strings" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -86,12 +87,12 @@ func Load(directory, version string) (*Catalog, error) { func (c *Catalog) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { - w.WriteHeader(http.StatusMethodNotAllowed) + v1.WriteHTTPError(w, http.StatusMethodNotAllowed, "", http.StatusText(http.StatusMethodNotAllowed)) return } name := strings.TrimPrefix(r.URL.Path, "/api/v1/agent-daemon/install/"+c.Version+"/") if strings.Contains(name, "/") { - http.NotFound(w, r) + v1.WriteHTTPError(w, http.StatusNotFound, "", "404 page not found") return } if name == "bootstrap.sh" { @@ -103,7 +104,7 @@ func (c *Catalog) ServeHTTP(w http.ResponseWriter, r *http.Request) { platform := strings.TrimSuffix(strings.TrimSuffix(name, ".sha256"), ".tar.gz") artifact, ok := c.Artifacts[platform] if !ok { - http.NotFound(w, r) + v1.WriteHTTPError(w, http.StatusNotFound, "", "404 page not found") return } if name == platform+".sha256" { @@ -112,11 +113,11 @@ func (c *Catalog) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } if name != platform+".tar.gz" { - http.NotFound(w, r) + v1.WriteHTTPError(w, http.StatusNotFound, "", "404 page not found") return } if c.local[platform] { - http.ServeFile(w, r, filepath.Join(c.directory, name)) + http.ServeFile(&downloadResponse{ResponseWriter: w}, r, filepath.Join(c.directory, name)) return } http.Redirect(w, r, artifact.URL, http.StatusTemporaryRedirect) @@ -134,3 +135,33 @@ func (c *Catalog) Commands(installerBase, authorization string) map[string]strin "posix": "bash -c " + shellQuote(posix) + " -- " + shellQuote(base) + " " + shellQuote(authorization), } } + +// downloadResponse adapts standard-library download failures to the machine +// envelope. Successful content is streamed unchanged, without buffering. +type downloadResponse struct { + http.ResponseWriter + status int +} + +func (w *downloadResponse) WriteHeader(status int) { + if w.status != 0 { + return + } + w.status = status + if status >= 400 { + w.Header().Del("Content-Length") + v1.WriteHTTPError(w.ResponseWriter, status, "", http.StatusText(status)) + return + } + w.ResponseWriter.WriteHeader(status) +} + +func (w *downloadResponse) Write(body []byte) (int, error) { + if w.status == 0 { + w.WriteHeader(http.StatusOK) + } + if w.status >= 400 { + return len(body), nil + } + return w.ResponseWriter.Write(body) +} diff --git a/services/core/internal/nativeinstaller/catalog_test.go b/services/core/internal/nativeinstaller/catalog_test.go index 3478f6ea4..fba4024dc 100644 --- a/services/core/internal/nativeinstaller/catalog_test.go +++ b/services/core/internal/nativeinstaller/catalog_test.go @@ -4,12 +4,14 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -131,3 +133,58 @@ func TestCatalogRejectsUnsupportedPlatforms(t *testing.T) { }) } } + +func TestDownloadResponsesKeepConditionalRangesAndJSONErrors(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "linux-amd64.tar.gz") + if err := os.WriteFile(file, []byte("archive fixture"), 0600); err != nil { + t.Fatal(err) + } + catalog := &Catalog{Version: "build", directory: dir, local: map[string]bool{"linux-amd64": true}, Artifacts: map[string]Artifact{"linux-amd64": {}}} + target := "/api/v1/agent-daemon/install/build/linux-amd64.tar.gz" + get := func(method string, headers http.Header) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(method, target, nil) + if headers != nil { + request.Header = headers + } + response := httptest.NewRecorder() + catalog.ServeHTTP(response, request) + return response + } + full := get("GET", nil) + if full.Code != 200 || full.Body.String() != "archive fixture" || full.Header().Get("Content-Length") != "15" || full.Header().Get("Last-Modified") == "" { + t.Fatalf("full: %v", full) + } + head := get("HEAD", nil) + if head.Code != 200 || head.Body.Len() != 0 || head.Header().Get("Content-Length") != "15" || head.Header().Get("Last-Modified") != full.Header().Get("Last-Modified") { + t.Fatalf("head: %v", head) + } + for _, method := range []string{"GET", "HEAD"} { + cached := get(method, http.Header{"If-Modified-Since": {full.Header().Get("Last-Modified")}}) + if cached.Code != 304 || cached.Body.Len() != 0 { + t.Fatalf("conditional %s: %v", method, cached) + } + } + partial := get("GET", http.Header{"Range": {"bytes=2-5"}}) + if partial.Code != 206 || partial.Body.String() != "chiv" || partial.Header().Get("Content-Range") != "bytes 2-5/15" || partial.Header().Get("Content-Length") != "4" { + t.Fatalf("range: %v", partial) + } + invalid := get("GET", http.Header{"Range": {"bytes=99-100"}}) + if invalid.Code != 416 || invalid.Header().Get("Content-Range") != "bytes */15" { + t.Fatalf("invalid range: %v", invalid) + } + if err := os.Remove(file); err != nil { + t.Fatal(err) + } + missing := get("GET", nil) + if missing.Code != 404 { + t.Fatalf("missing: %v", missing) + } + for _, response := range []*httptest.ResponseRecorder{invalid, missing} { + var body v1.ErrorResponse + if response.Header().Get("Content-Type") != "application/json" || response.Header().Get("Content-Length") != "" || json.Unmarshal(response.Body.Bytes(), &body) != nil || body.Error.Message == "" { + t.Fatalf("download error: %v", response) + } + } +} diff --git a/services/core/internal/runtimeenrollment/connection.go b/services/core/internal/runtimeenrollment/connection.go index fb016fec3..551ac61d2 100644 --- a/services/core/internal/runtimeenrollment/connection.go +++ b/services/core/internal/runtimeenrollment/connection.go @@ -9,12 +9,18 @@ import ( "strings" "time" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxbootstrap" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink/relay" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) +type ConnectionResponse struct { + EnvironmentID string `json:"environment_id" binding:"required"` + Status string `json:"status" enums:"connected,disconnected" binding:"required"` +} + type ConnectionStore interface { AuthenticateEnvironmentExecutor(context.Context, string, string) (string, error) GetEnvironment(context.Context, string, string) (sessions.Environment, error) @@ -31,7 +37,7 @@ type Connections struct { // Executor authority never grants access to the public Session API. func (c *Connections) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") - fail := func(status int) { http.Error(w, http.StatusText(status), status) } + fail := func(status int) { v1.WriteHTTPError(w, status, "", http.StatusText(status)) } if r.Method != http.MethodGet { w.Header().Set("Allow", http.MethodGet) fail(http.StatusMethodNotAllowed) @@ -65,10 +71,7 @@ func (c *Connections) ServeHTTP(w http.ResponseWriter, r *http.Request) { status = "connected" } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(struct { - EnvironmentID string `json:"environment_id"` - Status string `json:"status"` - }{environment, status}) + _ = json.NewEncoder(w).Encode(ConnectionResponse{environment, status}) } } diff --git a/services/core/internal/runtimeenrollment/enrollment.go b/services/core/internal/runtimeenrollment/enrollment.go index 089007961..fd1bd787a 100644 --- a/services/core/internal/runtimeenrollment/enrollment.go +++ b/services/core/internal/runtimeenrollment/enrollment.go @@ -9,12 +9,21 @@ import ( "strings" "time" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxbootstrap" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) +type EnrollmentRequest struct { + EnvironmentID string `json:"environment_id" binding:"required"` +} +type EnrollmentResponse struct { + LinkURL string `json:"link_url" binding:"required"` + Resource sandboxbootstrap.Resource `json:"resource" binding:"required"` +} + type EnrollmentStore interface { EnrollRuntime(context.Context, string, string) (sandboxbootstrap.Resource, error) } @@ -31,7 +40,7 @@ func EnrollmentHandler(s EnrollmentStore, origin deployment.PublicOrigin) http.H } noLink := err != nil return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fail := func(status int) { http.Error(w, http.StatusText(status), status) } + fail := func(status int) { v1.WriteHTTPError(w, status, "", http.StatusText(status)) } if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) fail(http.StatusMethodNotAllowed) @@ -42,9 +51,7 @@ func EnrollmentHandler(s EnrollmentStore, origin deployment.PublicOrigin) http.H fail(http.StatusUnauthorized) return } - var input struct { - EnvironmentID string `json:"environment_id"` - } + var input EnrollmentRequest decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)) decoder.DisallowUnknownFields() if len(r.URL.Query()) != 0 || decoder.Decode(&input) != nil || input.EnvironmentID == "" || decoder.Decode(new(any)) != io.EOF { @@ -52,12 +59,7 @@ func EnrollmentHandler(s EnrollmentStore, origin deployment.PublicOrigin) http.H return } if noLink { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusServiceUnavailable) - _ = json.NewEncoder(w).Encode(struct { - Error string `json:"error"` - Detail string `json:"detail"` - }{"no_sandbox_link", "a self_hosted sandbox needs an https public URL"}) + v1.WriteHTTPError(w, http.StatusServiceUnavailable, "no_sandbox_link", "a self_hosted sandbox needs an https public URL") return } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) @@ -73,10 +75,7 @@ func EnrollmentHandler(s EnrollmentStore, origin deployment.PublicOrigin) http.H default: w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") - _ = json.NewEncoder(w).Encode(struct { - LinkURL string `json:"link_url"` - Resource sandboxbootstrap.Resource `json:"resource"` - }{link, resource}) + _ = json.NewEncoder(w).Encode(EnrollmentResponse{link, resource}) } }) } diff --git a/services/core/internal/runtimeenrollment/enrollment_test.go b/services/core/internal/runtimeenrollment/enrollment_test.go index 8009f24e9..d2058e663 100644 --- a/services/core/internal/runtimeenrollment/enrollment_test.go +++ b/services/core/internal/runtimeenrollment/enrollment_test.go @@ -66,7 +66,7 @@ func TestEnrollmentConnectionContract(t *testing.T) { if response.Code == 200 && (response.Body.String() != `{"link_url":"wss://core.example/api/v1/sandbox-link","resource":{"tenant_id":"tenant","environment_id":"environment","kind":"enrollment","id":"enrollment","generation":2}}`+"\n" || response.Header().Get("Cache-Control") != "no-store") { t.Fatalf("enrollment response %s", response.Body.String()) } - if test.name == "no wss Link" && !strings.Contains(response.Body.String(), `"error":"no_sandbox_link"`) { + if test.name == "no wss Link" && !strings.Contains(response.Body.String(), `"code":"no_sandbox_link"`) { t.Fatalf("no Link response %s", response.Body.String()) } }) diff --git a/services/core/internal/runtimegateway/handler.go b/services/core/internal/runtimegateway/handler.go index 9ee2f53a3..bcdcd16de 100644 --- a/services/core/internal/runtimegateway/handler.go +++ b/services/core/internal/runtimegateway/handler.go @@ -8,10 +8,10 @@ import ( "strings" "time" - "github.com/gorilla/websocket" - + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/gorilla/websocket" ) // HeartbeatTouch is the persistence interface the gateway uses to @@ -82,6 +82,7 @@ func NewHandler(cfg HandlerConfig) *Handler { // Daemon is a non-browser client and sends no Origin; // the Authorization bearer is the actual auth boundary. CheckOrigin: func(*http.Request) bool { return true }, + Error: v1.WebSocketError, }, } } @@ -95,11 +96,11 @@ func (h *Handler) WS(w http.ResponseWriter, r *http.Request) { token := bearerFromAuthHeader(r) version := q.Get("version") if deviceID == "" || token == "" || version == "" { - writeAuthError(w, http.StatusBadRequest, "missing_params", "device_id, version and Authorization bearer are required") + v1.WriteHTTPError(w, http.StatusBadRequest, "missing_params", "device_id, version and Authorization bearer are required") return } if !proto.VersionCompatible(version) { - writeAuthError(w, http.StatusUpgradeRequired, "incompatible_version", + v1.WriteHTTPError(w, http.StatusUpgradeRequired, "incompatible_version", "daemon protocol "+version+" incompatible with server "+proto.Version) return } @@ -110,7 +111,7 @@ func (h *Handler) WS(w http.ResponseWriter, r *http.Request) { // trace; the credential itself stays out of the log. h.cfg.Log("agentdaemon gateway: ws auth rejected device_id=%s code=%s status=%d err=%v", deviceID, code, status, err) - writeAuthError(w, status, code, err.Error()) + v1.WriteHTTPError(w, status, code, err.Error()) return } conn, err := h.upgrader.Upgrade(w, r, nil) @@ -141,29 +142,38 @@ func (h *Handler) WS(w http.ResponseWriter, r *http.Request) { sess.Start() } +type BootstrapRequest struct { + DeviceID string `json:"device_id" binding:"required"` +} +type BootstrapResponse struct { + DeviceID string `json:"device_id" binding:"required"` + WorkspaceID string `json:"workspace_id" binding:"required"` + WSURL string `json:"ws_url" binding:"required"` + HeartbeatSeconds int `json:"heartbeat_seconds" binding:"required"` + ProtocolVersion string `json:"protocol_version" binding:"required"` +} + // Bootstrap is the daemon's first HTTP call with its credential. Validating // the bearer in a separate HTTP step (rather than folded into the WS // upgrade) lets the daemon fail fast on credential problems with a // real HTTP status rather than the opaque WS close code. func (h *Handler) Bootstrap(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - writeAuthError(w, http.StatusMethodNotAllowed, "method_not_allowed", "") + v1.WriteHTTPError(w, http.StatusMethodNotAllowed, "method_not_allowed", "") return } bearer := bearerFromAuthHeader(r) if bearer == "" { - writeAuthError(w, http.StatusUnauthorized, "missing_bearer", "Authorization: Bearer required") + v1.WriteHTTPError(w, http.StatusUnauthorized, "missing_bearer", "Authorization: Bearer required") return } - var body struct { - DeviceID string `json:"device_id"` - } + var body BootstrapRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeAuthError(w, http.StatusBadRequest, "bad_json", err.Error()) + v1.WriteHTTPError(w, http.StatusBadRequest, "bad_json", err.Error()) return } if body.DeviceID == "" { - writeAuthError(w, http.StatusBadRequest, "missing_device_id", "request body must contain device_id") + v1.WriteHTTPError(w, http.StatusBadRequest, "missing_device_id", "request body must contain device_id") return } auth, err := h.cfg.Authenticator.AuthenticateBearer(r.Context(), body.DeviceID, bearer) @@ -171,16 +181,12 @@ func (h *Handler) Bootstrap(w http.ResponseWriter, r *http.Request) { status, code := mapAuthError(err) h.cfg.Log("agentdaemon gateway: bootstrap auth rejected device_id=%s code=%s status=%d err=%v", body.DeviceID, code, status, err) - writeAuthError(w, status, code, err.Error()) + v1.WriteHTTPError(w, status, code, err.Error()) return } - resp := map[string]any{ - "device_id": auth.DeviceID, - "workspace_id": auth.WorkspaceID, - "ws_url": h.cfg.PublicWSURL, - "heartbeat_seconds": int(h.cfg.HeartbeatInterval.Seconds()), - "protocol_version": proto.Version, - } + resp := BootstrapResponse{DeviceID: auth.DeviceID, WorkspaceID: auth.WorkspaceID, + WSURL: h.cfg.PublicWSURL, HeartbeatSeconds: int(h.cfg.HeartbeatInterval.Seconds()), ProtocolVersion: proto.Version} + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(resp) @@ -201,15 +207,6 @@ func bearerFromAuthHeader(r *http.Request) string { return strings.TrimSpace(strings.TrimPrefix(raw, "Bearer ")) } -func writeAuthError(w http.ResponseWriter, status int, code, detail string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(map[string]any{ - "error": code, - "detail": detail, - }) -} - // mapAuthError translates a typed auth error into (HTTP status, // machine-readable code). Keeps the wire response stable across handlers. func mapAuthError(err error) (int, string) { diff --git a/services/core/internal/runtimegateway/routes.go b/services/core/internal/runtimegateway/routes.go deleted file mode 100644 index c8f2dcf51..000000000 --- a/services/core/internal/runtimegateway/routes.go +++ /dev/null @@ -1,23 +0,0 @@ -package runtimegateway - -import ( - "github.com/go-chi/chi/v5" -) - -// RegisterRoutes mounts the agent_daemon HTTP / WebSocket endpoints -// onto a chi router. -// -// GET /agent-daemon/ws — daemon dial-in (WS upgrade) -// POST /agent-daemon/bootstrap — daemon first-call to fetch wsUrl + heartbeat cadence -// -// Both accept the daemon credential described in -// contracts/agents-api/machine-api.md. -func RegisterRoutes(r chi.Router, h *Handler) { - if h == nil { - panic("agentdaemon gateway: RegisterRoutes called with nil handler") - } - r.Route("/agent-daemon", func(r chi.Router) { - r.Get("/ws", h.WS) - r.Post("/bootstrap", h.Bootstrap) - }) -} diff --git a/services/core/internal/runtimegateway/wire_test.go b/services/core/internal/runtimegateway/wire_test.go index 41151dc76..443588136 100644 --- a/services/core/internal/runtimegateway/wire_test.go +++ b/services/core/internal/runtimegateway/wire_test.go @@ -12,12 +12,12 @@ import ( "testing" "time" - "github.com/gorilla/websocket" - + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" + "github.com/gorilla/websocket" ) // These tests run Core's gateway against a scripted Runtime that replays the @@ -138,11 +138,9 @@ func TestWireRejectsIncompatibleVersions(t *testing.T) { if err == nil || response == nil || response.StatusCode != prototest.IncompatibleVersionStatus { t.Fatalf("upgrade with version %q: %v", version, err) } - var body struct { - Error string `json:"error"` - } - if json.NewDecoder(response.Body).Decode(&body) != nil || body.Error != prototest.IncompatibleVersionCode { - t.Fatalf("rejection code %q", body.Error) + var body v1.ErrorResponse + if json.NewDecoder(response.Body).Decode(&body) != nil || body.Error.Code == nil || *body.Error.Code != prototest.IncompatibleVersionCode { + t.Fatalf("rejection code %v", body.Error.Code) } if devices := reg.Devices(); len(devices) != 0 { t.Fatalf("rejected connection registered %v", devices) diff --git a/services/core/internal/sandbox/node/hub.go b/services/core/internal/sandbox/node/hub.go index a3360905d..190e2ca02 100644 --- a/services/core/internal/sandbox/node/hub.go +++ b/services/core/internal/sandbox/node/hub.go @@ -8,6 +8,7 @@ import ( "sync" "time" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/google/uuid" "github.com/gorilla/websocket" @@ -91,13 +92,13 @@ func (p *peer) close() { p.once.Do(func() { close(p.done); p.cancel(); _ = p.con func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || h.options.Authenticate == nil || h.options.OwnerEpoch == nil { - http.Error(w, "node unavailable", http.StatusServiceUnavailable) + v1.WriteHTTPError(w, http.StatusServiceUnavailable, "", "node unavailable") return } id := r.URL.Query().Get("node_id") auth := strings.Fields(r.Header.Get("Authorization")) if !validID(id) || len(r.Header.Values("Authorization")) != 1 || len(auth) != 2 || auth[0] != "Bearer" { - http.Error(w, "unauthorized", http.StatusUnauthorized) + v1.WriteHTTPError(w, http.StatusUnauthorized, "", "unauthorized") return } ctx, cancel := h.lifetime(r.Context()) @@ -105,31 +106,31 @@ func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request) { identity, err := callbackValue(ctx, func(ctx context.Context) (Identity, error) { return h.options.Authenticate(ctx, id, auth[1]) }) if err != nil { if errors.Is(err, ErrAuthentication) { - http.Error(w, "unauthorized", http.StatusUnauthorized) + v1.WriteHTTPError(w, http.StatusUnauthorized, "", "unauthorized") } else { - http.Error(w, "node authentication unavailable", http.StatusServiceUnavailable) + v1.WriteHTTPError(w, http.StatusServiceUnavailable, "", "node authentication unavailable") } return } if identity.NodeID != id { - http.Error(w, "unauthorized", http.StatusUnauthorized) + v1.WriteHTTPError(w, http.StatusUnauthorized, "", "unauthorized") return } epoch, err := callbackValue(ctx, h.options.OwnerEpoch) if err != nil || epoch == 0 { - http.Error(w, "owner unavailable", http.StatusServiceUnavailable) + v1.WriteHTTPError(w, http.StatusServiceUnavailable, "", "owner unavailable") return } h.mu.Lock() if h.closed { h.mu.Unlock() - http.Error(w, "node unavailable", http.StatusServiceUnavailable) + v1.WriteHTTPError(w, http.StatusServiceUnavailable, "", "node unavailable") return } if _, reserved := h.reservations[id]; reserved { h.mu.Unlock() - http.Error(w, "node identity already connected", http.StatusConflict) + v1.WriteHTTPError(w, http.StatusConflict, "", "node identity already connected") return } lifetime := &connectionLifetime{cancel: cancel} @@ -144,7 +145,7 @@ func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request) { } h.mu.Unlock() }() - upgrade := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return r.Header.Get("Origin") == "" }} + upgrade := websocket.Upgrader{Error: v1.WebSocketError, CheckOrigin: func(r *http.Request) bool { return r.Header.Get("Origin") == "" }} conn, err := upgrade.Upgrade(w, r, nil) if err != nil { return diff --git a/services/core/internal/sandbox/providers/configuration_flow_test.go b/services/core/internal/sandbox/providers/configuration_flow_test.go index 4a4731e27..e7fc81699 100644 --- a/services/core/internal/sandbox/providers/configuration_flow_test.go +++ b/services/core/internal/sandbox/providers/configuration_flow_test.go @@ -161,8 +161,9 @@ func TestAdditionalConfigurationProviderUsesCommonAPIAndStore(t *testing.T) { SessionArchive: struct{ api.SessionArchive }{}, Workspaces: struct{ api.EnvironmentWorkspaces }{}, Links: struct{ http.Handler }{}, + Bootstrap: http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("unexpected machine bootstrap") }), RuntimeConnect: http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("unexpected Runtime connection") }), Enrollment: http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("unexpected enrollment") }), Connection: http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("unexpected connection") }), }, - Sandboxes: api.Sandboxes{Deployment: service, NodeAllocations: deploymentpg.New(pgunit.NewPool(pool), pgtest.CredentialKey(t)), DeploymentChanges: leaseSetup{t: t, changes: changes, installation: installation}, + Sandboxes: api.Sandboxes{NodeConnect: http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("unexpected node connection") }), Deployment: service, NodeAllocations: deploymentpg.New(pgunit.NewPool(pool), pgtest.CredentialKey(t)), DeploymentChanges: leaseSetup{t: t, changes: changes, installation: installation}, DeploymentReset: leaseSetup{t: t, changes: changes, installation: installation}, ConfigurationDiscovery: struct{ api.ConfigurationDiscovery }{}}, }) if err != nil { diff --git a/services/core/tests/integration/dispatch_test.go b/services/core/tests/integration/dispatch_test.go index 477a62439..fb201c698 100644 --- a/services/core/tests/integration/dispatch_test.go +++ b/services/core/tests/integration/dispatch_test.go @@ -15,13 +15,13 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxbootstrap" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink/sandboxlinktest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/modelconfigurationpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" - "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/gorilla/websocket" ) @@ -34,9 +34,14 @@ func fixtureGateway(t *testing.T, s *Store, publicWSURL string) (http.Handler, * Authenticator: runtimegateway.NewAuthenticator(sessionAdapter(s)), Registry: registry, Heartbeat: sessionService(t, s), Links: runtimegateway.NewLinkAuthority(sessionAdapter(s)), PublicWSURL: publicWSURL, }) - r := chi.NewRouter() - r.Route("/api/v1", func(r chi.Router) { runtimegateway.RegisterRoutes(r, h) }) - return r, registry + handler, err := publicHandler(t, s, newTestAuthenticator(t, nil), "codex", func(d *api.Dependencies) { + d.Execution.RuntimeConnect = http.HandlerFunc(h.WS) + d.Execution.Bootstrap = http.HandlerFunc(h.Bootstrap) + }) + if err != nil { + t.Fatal(err) + } + return handler, registry } type dispatchHarness struct { diff --git a/services/core/tests/integration/public_handler_fixture_test.go b/services/core/tests/integration/public_handler_fixture_test.go index 8ff08a722..f4329bf24 100644 --- a/services/core/tests/integration/public_handler_fixture_test.go +++ b/services/core/tests/integration/public_handler_fixture_test.go @@ -97,8 +97,8 @@ func publicHandler(t testing.TB, s *Store, keys fixtureKeyResolver, engine strin ArtifactsReader: sessionStore, SessionAdmin: sessionStore, Environments: service, EnvironmentsReader: sessionStore, Admin: sessionStore, AdminAudit: audit, WriteAudit: audit, ExecutorConnections: strict, Metrics: strict, RuntimeObservations: strict, RuntimeHistory: strict, - Execution: api.Execution{ExecutorURL: testExecutorURL, SessionAdmission: service, InputAdmission: service, SessionArchive: strict, Workspaces: strict, Links: strict}, - Sandboxes: api.Sandboxes{Deployment: deployments, NodeAllocations: deploymentStore(s), DeploymentChanges: strict, DeploymentReset: strict, ConfigurationDiscovery: strict}, + Execution: api.Execution{ExecutorURL: testExecutorURL, SessionAdmission: service, InputAdmission: service, SessionArchive: strict, Workspaces: strict, Links: strict, Bootstrap: strict, RuntimeConnect: strict, Enrollment: strict, Connection: strict}, + Sandboxes: api.Sandboxes{NodeConnect: strict, Deployment: deployments, NodeAllocations: deploymentStore(s), DeploymentChanges: strict, DeploymentReset: strict, ConfigurationDiscovery: strict}, } for _, c := range configure { c(&deps) @@ -154,6 +154,7 @@ func workerExecution(t testing.TB, worker *execution.Worker) func(*api.Dependenc SessionArchive: strictStandIn{t}, Workspaces: worker, Links: strictStandIn{t}, + Bootstrap: strictStandIn{t}, RuntimeConnect: strictStandIn{t}, Enrollment: strictStandIn{t}, Connection: strictStandIn{t}, } } } diff --git a/services/core/tests/integration/self_hosted_initial_public_test.go b/services/core/tests/integration/self_hosted_initial_public_test.go index 5eb46ca07..f69b78d74 100644 --- a/services/core/tests/integration/self_hosted_initial_public_test.go +++ b/services/core/tests/integration/self_hosted_initial_public_test.go @@ -54,6 +54,7 @@ func TestSelfHostedInitialCreationOfficialClient(t *testing.T) { SessionArchive: strictStandIn{t}, Workspaces: strictStandIn{t}, Links: strictStandIn{t}, + Bootstrap: strictStandIn{t}, RuntimeConnect: strictStandIn{t}, Enrollment: strictStandIn{t}, Connection: strictStandIn{t}, } }) } From 5f5f94f59f88bf169b4569d8643785c9a342acb4 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 8 Oct 2026 14:59:32 +0000 Subject: [PATCH 2/4] Declare required native installation credentials --- contracts/agents-api/runtime.openapi.yaml | 13 +++++++ .../core/internal/api/contract_routes_test.go | 36 +++++++++++++++++++ .../internal/api/environment_installation.go | 4 ++- .../environment_installation_test.go | 31 ++++++++++++++++ 4 files changed, 83 insertions(+), 1 deletion(-) diff --git a/contracts/agents-api/runtime.openapi.yaml b/contracts/agents-api/runtime.openapi.yaml index 984c23f37..051aa7a04 100644 --- a/contracts/agents-api/runtime.openapi.yaml +++ b/contracts/agents-api/runtime.openapi.yaml @@ -4,6 +4,8 @@ definitions: properties: executor_token: type: string + required: + - executor_token type: object deployment.Enrollment: properties: @@ -473,6 +475,12 @@ paths: /api/v1/agent-daemon/installation: post: description: Accepts a short-lived Environment installation Bearer authorization, not a Project or Core key. Returns frozen connection constraints; it does not claim or rotate credentials. + parameters: + - description: Bearer installation grant + in: header + name: Authorization + required: true + type: string produces: - application/json responses: @@ -501,6 +509,11 @@ paths: - application/json description: A valid installation Bearer authorization can claim one connect-only key. The client persists its generated secret before submitting it. Retries must present that same secret; a different, rotated or revoked credential is never replaced. parameters: + - description: Bearer installation grant + in: header + name: Authorization + required: true + type: string - description: Locally persisted executor secret in: body name: body diff --git a/services/core/internal/api/contract_routes_test.go b/services/core/internal/api/contract_routes_test.go index e8d6bd797..69ed45da8 100644 --- a/services/core/internal/api/contract_routes_test.go +++ b/services/core/internal/api/contract_routes_test.go @@ -129,3 +129,39 @@ func TestContractsPublishExactlyTheRegisteredCoreAndMachineRoutes(t *testing.T) } } } + +func TestInstallationContractRequiresGrantAndExecutorToken(t *testing.T) { + raw, err := os.ReadFile("../../../../contracts/agents-api/runtime.openapi.yaml") + if err != nil { + t.Fatal(err) + } + var contract struct { + Paths map[string]map[string]struct { + Parameters []struct { + Name string `yaml:"name"` + In string `yaml:"in"` + Required bool `yaml:"required"` + } + } `yaml:"paths"` + Definitions map[string]struct { + Required []string `yaml:"required"` + } `yaml:"definitions"` + } + if err := yaml.Unmarshal(raw, &contract); err != nil { + t.Fatal(err) + } + for _, path := range []string{"/api/v1/agent-daemon/installation", "/api/v1/agent-daemon/installation/claim"} { + present := false + for _, parameter := range contract.Paths[path]["post"].Parameters { + if parameter.In == "header" && parameter.Name == "Authorization" && parameter.Required { + present = true + } + } + if !present { + t.Errorf("%s does not require its installation grant", path) + } + } + if !slices.Contains(contract.Definitions["api.NativeInstallationClaim"].Required, "executor_token") { + t.Fatal("claim schema does not require the executor secret") + } +} diff --git a/services/core/internal/api/environment_installation.go b/services/core/internal/api/environment_installation.go index cfa7a21d7..d4268234a 100644 --- a/services/core/internal/api/environment_installation.go +++ b/services/core/internal/api/environment_installation.go @@ -84,6 +84,7 @@ func (h *Handler) installationAuthorization(w http.ResponseWriter, r *http.Reque // @Description Accepts a short-lived Environment installation Bearer authorization, not a Project or Core key. Returns frozen connection constraints; it does not claim or rotate credentials. // @Tags Native Installation // @Produce json +// @Param Authorization header string true "Bearer installation grant" // @Success 200 {object} v1.NativeInstallationContext // @Failure 401,404,503 {object} v1.ErrorResponse // @Router /api/v1/agent-daemon/installation [post] @@ -111,13 +112,14 @@ func (h *Handler) prepareNativeInstallation(w http.ResponseWriter, r *http.Reque } type NativeInstallationClaim struct { - ExecutorToken string `json:"executor_token"` + ExecutorToken string `json:"executor_token" binding:"required"` } // @Summary Claim an Environment's installation credential // @Description A valid installation Bearer authorization can claim one connect-only key. The client persists its generated secret before submitting it. Retries must present that same secret; a different, rotated or revoked credential is never replaced. // @Tags Native Installation // @Accept json +// @Param Authorization header string true "Bearer installation grant" // @Param body body api.NativeInstallationClaim true "Locally persisted executor secret" // @Success 204 // @Failure 400,401,409,503 {object} v1.ErrorResponse diff --git a/services/core/tests/integration/environment_installation_test.go b/services/core/tests/integration/environment_installation_test.go index ba25bfe3f..e2697942a 100644 --- a/services/core/tests/integration/environment_installation_test.go +++ b/services/core/tests/integration/environment_installation_test.go @@ -4,11 +4,15 @@ import ( "encoding/base64" "encoding/json" "errors" + "net/http" + "net/http/httptest" "strings" "sync" "testing" "time" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/nativeinstaller" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" ) @@ -32,6 +36,28 @@ func TestEnvironmentInstallationClaimLifetimeAndRetries(t *testing.T) { if err != nil || expires <= time.Now().Unix() || expires > time.Now().Add(31*time.Minute).Unix() { t.Fatal("authorization", err) } + handler, err := publicHandler(t, s, fixtureKeyResolver{}, "codex", func(d *api.Dependencies) { + d.Execution.NativeInstaller = &api.NativeInstaller{Version: "build", Base: "https://core.example/api/v1/agent-daemon/install/", Catalog: &nativeinstaller.Catalog{Version: "build"}} + }) + if err != nil { + t.Fatal(err) + } + claimHTTP := func(path, grant, body string, status int) { + t.Helper() + request := httptest.NewRequest(http.MethodPost, "/api/v1/agent-daemon/"+path, strings.NewReader(body)) + if grant != "" { + request.Header.Set("Authorization", "Bearer "+grant) + } + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != status { + t.Fatalf("%s returned %d, want %d: %s", path, response.Code, status, response.Body) + } + } + claimHTTP("installation", "", "", http.StatusUnauthorized) + claimHTTP("installation/claim", "", "{}", http.StatusUnauthorized) + claimHTTP("installation/claim", token, "{}", http.StatusBadRequest) for _, pair := range [][2]string{{token + "x", "build"}, {token, "other-build"}, {"", "build"}} { if _, err := installations.ValidateEnvironmentInstallation(ctx, pair[0], pair[1]); !errors.Is(err, sessions.ErrInstallationAuthorization) { t.Fatal("accepted invalid authorization", err) @@ -78,6 +104,11 @@ func TestEnvironmentInstallationClaimLifetimeAndRetries(t *testing.T) { if err := installations.ClaimEnvironmentInstallation(ctx, token, "build", secrets[winner]); err != nil { t.Fatal("lost-response retry", err) } + claimBody, err := json.Marshal(api.NativeInstallationClaim{ExecutorToken: secrets[winner]}) + if err != nil { + t.Fatal(err) + } + claimHTTP("installation/claim", token, string(claimBody), http.StatusNoContent) if _, err := sessionAdapter(s).AuthenticateEnvironmentExecutor(ctx, environment.ID, executorDigest(secrets[winner])); err != nil { t.Fatal(err) } From 55db8511c6bffb743aa6a27aed0068b79bcf3d07 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 8 Oct 2026 15:07:06 +0000 Subject: [PATCH 3/4] Document machine extension method rejection --- contracts/agents-api/machine-api.md | 2 +- contracts/agents-api/runtime.openapi.yaml | 2 +- contracts/agents-api/zh/machine-api.md | 4 ++-- services/core/internal/api/machine_routes.go | 2 +- services/core/internal/api/machine_routes_test.go | 5 ++++- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/contracts/agents-api/machine-api.md b/contracts/agents-api/machine-api.md index d23318601..0c6e6b922 100644 --- a/contracts/agents-api/machine-api.md +++ b/contracts/agents-api/machine-api.md @@ -28,7 +28,7 @@ The generated [`runtime.openapi.yaml`](./runtime.openapi.yaml) describes every m Every HTTP error uses `{"error":{"message":"…","type":"invalid_request_error","code":null,"param":null}}`. `code` carries a route's defined reason when present; `param` identifies a rejected field when present. A 409 has type `conflict_error`, and a 5xx has type `server_error`. Handshake failures use the same envelope before upgrade; errors after upgrade belong to the wire protocol. Consumers use HTTP status for retry and permanent-rejection decisions and may display `error.message`. -`HEAD` never opens a connection or queries a Runtime: daemon WebSocket, Link and connection observation routes reject it with 405; the node connection rejects every non-GET method with 503. Node configuration and identity reads support HEAD with the same credential checks as GET. Installer downloads support GET and HEAD, including archive conditional and range responses; download errors also use the shared envelope. Enrollment accepts POST only. Unsupported methods retain the route's status and `Allow` header. The bare `/api/v1/agent-daemon` and `/api/v1/agent-daemon/install` prefixes redirect with 301 to their trailing-slash forms, preserving their queries. Unknown machine routes return 404 in the shared envelope. +`HEAD` never opens a connection or queries a Runtime: daemon WebSocket, Link and connection observation routes reject it with 405; the node connection rejects standard HTTP methods other than GET with 503. Unsupported extension methods such as `PROPFIND` are rejected by the shared router with 405 before authentication or upgrade. Node configuration and identity reads support HEAD with the same credential checks as GET. Installer downloads support GET and HEAD, including archive conditional and range responses; download errors also use the shared envelope. Enrollment accepts POST only. Other rejected methods use the shared error envelope. The bare `/api/v1/agent-daemon` and `/api/v1/agent-daemon/install` prefixes redirect with 301 to their trailing-slash forms, preserving their queries. Unknown machine routes return 404 in the shared envelope. ## Credentials diff --git a/contracts/agents-api/runtime.openapi.yaml b/contracts/agents-api/runtime.openapi.yaml index 051aa7a04..7cf1723e3 100644 --- a/contracts/agents-api/runtime.openapi.yaml +++ b/contracts/agents-api/runtime.openapi.yaml @@ -654,7 +654,7 @@ paths: - Sandbox Node /api/v1/sandbox-node/connect: get: - description: Authenticates a node credential before upgrading to the sandbox node wire protocol. Other methods return 503 without authentication or upgrade. + description: Authenticates a node credential before upgrading to the sandbox node wire protocol. Standard HTTP methods other than GET return 503 without authentication or upgrade. Unsupported extension methods are rejected by the shared router with 405 before authentication or upgrade. parameters: - description: Bearer node credential in: header diff --git a/contracts/agents-api/zh/machine-api.md b/contracts/agents-api/zh/machine-api.md index 128df1b03..e5788d446 100644 --- a/contracts/agents-api/zh/machine-api.md +++ b/contracts/agents-api/zh/machine-api.md @@ -1,7 +1,7 @@ --- title: "机器连接 API" source: contracts/agents-api/machine-api.md -source_hash: 08e8e034ebe1e5a4c70db9143d35f08ceec071701d16e97480220c4ec0ece19f +source_hash: e5fb4508091ff0311acbebe10349fe9ae2c649e1fb7a721d6047f581a2fd7299 --- 机器通过 `/api/v1` 调用 Core:包括沙箱节点、Runtime daemon、Sandbox I/O 服务和自托管安装器。各路由仅接受所列凭据,不接受 Core 密钥或 Project API 密钥;控制台登录也不授予此处权限。公共源站将 `/api/v1` 转发给 Core,可以直接转发,也可以经过 Web 不改变请求的 HTTP 和 WebSocket 代理。Web 不会给机器请求添加控制台权限。 @@ -30,7 +30,7 @@ source_hash: 08e8e034ebe1e5a4c70db9143d35f08ceec071701d16e97480220c4ec0ece19f 所有 HTTP 错误使用 `{"error":{"message":"…","type":"invalid_request_error","code":null,"param":null}}`。`code` 在存在时携带路由定义的原因,`param` 在存在时标识被拒绝字段。409 的类型为 `conflict_error`,5xx 的类型为 `server_error`。升级前的握手失败也使用该封装;升级后的错误属于 wire 协议。调用方使用 HTTP 状态决定重试或永久拒绝,也可显示 `error.message`。 -`HEAD` 不打开连接,也不查询 Runtime:daemon WebSocket、Link 和连接观察路由以 405 拒绝;节点连接对所有非 GET 方法返回 503。节点配置与身份读取支持 HEAD,并执行与 GET 相同的凭据检查。安装器下载支持 GET 和 HEAD,包括归档的条件请求与范围响应;下载错误也使用共享封装。登记只接受 POST。不支持的方法保留路由的状态及 `Allow` 头。裸 `/api/v1/agent-daemon` 和 `/api/v1/agent-daemon/install` 前缀以 301 重定向到带尾斜杠的形式,并保留查询。未知机器路由返回使用共享封装的 404。 +`HEAD` 不打开连接,也不查询 Runtime:daemon WebSocket、Link 和连接观察路由以 405 拒绝;节点连接对 GET 以外的标准 HTTP 方法返回 503。对于 `PROPFIND` 等不支持的扩展方法,共同路由器在鉴权或升级前返回 405。节点配置与身份读取支持 HEAD,并执行与 GET 相同的凭据检查。安装器下载支持 GET 和 HEAD,包括归档的条件请求与范围响应;下载错误也使用共享封装。登记只接受 POST。其他被拒绝的方法使用共享错误封装。裸 `/api/v1/agent-daemon` 和 `/api/v1/agent-daemon/install` 前缀以 301 重定向到带尾斜杠的形式,并保留查询。未知机器路由返回使用共享封装的 404。 ## 凭据 {#credentials} diff --git a/services/core/internal/api/machine_routes.go b/services/core/internal/api/machine_routes.go index 9e431781c..d0a0253b0 100644 --- a/services/core/internal/api/machine_routes.go +++ b/services/core/internal/api/machine_routes.go @@ -27,7 +27,7 @@ func (h *Handler) registerMachineRoutes(router chi.Router) { r.Get("/sandbox-node/configuration", h.sandboxNodeConfiguration) // @Summary Open a sandbox node connection - // @Description Authenticates a node credential before upgrading to the sandbox node wire protocol. Other methods return 503 without authentication or upgrade. + // @Description Authenticates a node credential before upgrading to the sandbox node wire protocol. Standard HTTP methods other than GET return 503 without authentication or upgrade. Unsupported extension methods are rejected by the shared router with 405 before authentication or upgrade. // @Tags Sandbox Nodes // @Param Authorization header string true "Bearer node credential" // @Param node_id query string true "Node UUID" diff --git a/services/core/internal/api/machine_routes_test.go b/services/core/internal/api/machine_routes_test.go index 4b5479dde..73a6e7693 100644 --- a/services/core/internal/api/machine_routes_test.go +++ b/services/core/internal/api/machine_routes_test.go @@ -105,6 +105,7 @@ func TestMachineRoutesPreserveAuthorityAndMethodPrecedence(t *testing.T) { {"node handshake invalid", "GET", "/sandbox-node/connect?node_id=" + nodeID, "Bearer node-key", "", 400, 1, ""}, {"node HEAD never authenticates", "HEAD", "/sandbox-node/connect?node_id=" + nodeID, "Bearer node-key", "", 503, 0, ""}, {"node POST preserves status", "POST", "/sandbox-node/connect", "", "", 503, 0, ""}, + {"node extension stops before authentication", "PROPFIND", "/sandbox-node/connect?node_id=" + nodeID, "Bearer node-key", "", 405, 0, ""}, {"bootstrap credential before body", "POST", "/agent-daemon/bootstrap", "", "{", 401, 0, ""}, {"bootstrap wrong credential", "POST", "/agent-daemon/bootstrap", "Bearer wrong", `{"device_id":"host"}`, 401, 1, ""}, {"bootstrap malformed", "POST", "/agent-daemon/bootstrap", "Bearer host-key", "{", 400, 0, ""}, @@ -139,7 +140,9 @@ func TestMachineRoutesPreserveAuthorityAndMethodPrecedence(t *testing.T) { request.Header.Set("Authorization", tc.authorization) response := httptest.NewRecorder() handler.ServeHTTP(response, request) - if response.Code != tc.status || store.calls != tc.calls || response.Header().Get("Allow") != tc.allow { + // Extension rejection belongs to the router; its Allow list does not + // declare the node handler's supported operations. + if response.Code != tc.status || store.calls != tc.calls || tc.method != "PROPFIND" && response.Header().Get("Allow") != tc.allow { t.Fatalf("status/calls/Allow = %d/%d/%q; want %d/%d/%q; %s", response.Code, store.calls, response.Header().Get("Allow"), tc.status, tc.calls, tc.allow, response.Body) } var envelope v1.ErrorResponse From ae40f8f498d76648c3e92c8dd2b69d5ce4b6371c Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 8 Oct 2026 15:13:49 +0000 Subject: [PATCH 4/4] Check initial activity against its queued Turn --- services/core/tests/official_client.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/services/core/tests/official_client.py b/services/core/tests/official_client.py index e30557f40..d3b9d9861 100644 --- a/services/core/tests/official_client.py +++ b/services/core/tests/official_client.py @@ -178,7 +178,14 @@ def issue_key(project_id, name): assert first.agent.tools == [] and first.agent.multi_agent.enabled is False default_raw = sessions.with_raw_response.retrieve(first.id) assert default_raw.http_response.json()["agent"]["tools"] == [] - assert first.created_at == first.last_active_at and isinstance(first.created_at, int) + first_turns = list(sessions.turns.list(first.id)) + assert len(first_turns) == 1 + first_turn = first_turns[0] + assert first_turn.status == "queued" + assert first_turn.started_at is None and first_turn.completed_at is None + assert isinstance(first.created_at, int) + assert first.created_at <= first_turn.created_at + assert first.last_active_at == first_turn.created_at replay = sessions.create(**spec, metadata={"workspace": "untrusted-reference"}, extra_headers=headers) assert replay == first assert sessions.retrieve(first.id) == first @@ -242,7 +249,6 @@ def issue_key(project_id, name): assert "SECRET" not in repr(recovered) and "PRIVATE" not in repr(recovered) assert [turn.id for turn in turns.list(turn_session.id, limit=2)] == list(reversed([initial_turn.id, *turn_ids])) assert list(turns.list(turn_session.id, after=turn_ids[-1], order="asc")) == [] - assert len(list(turns.list(first.id))) == 1 assert turns.retrieve(turn_ids[0], session_id=turn_session.id) == recovered[0] expect_error(NotFoundError, lambda: b.beta.agents.sessions.turns.list(turn_session.id)) expect_error(NotFoundError, lambda: b.beta.agents.sessions.turns.retrieve(turn_ids[0], session_id=turn_session.id))