diff --git a/contracts/agents-api/machine-api.md b/contracts/agents-api/machine-api.md index 0b1165b6f..f6dfa22ff 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 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 reverse proxy sends `/api/v1` directly to Core; Web never serves these routes. ## Routes @@ -19,10 +19,11 @@ Machines call Core under `/api/v1`: sandbox nodes, Runtime daemons and the self- | `POST agent-daemon/bootstrap` | Runtime daemon | Daemon credential | [Daemon bootstrap](#daemon-bootstrap) | | `GET agent-daemon/device-status?device_id=` | Runtime daemon | Daemon credential | [Device status](#device-status) | | WebSocket `GET agent-daemon/ws?device_id=&version=` | Runtime daemon | Daemon credential | [Core–Runtime protocol](../../docs/runtime-protocol.md) | +| WebSocket `GET sandbox-link` | Sandbox I/O service (serve peer) and agent-host Runtime (attach peer) | The resource's Serve credential or the agent host's Runtime credential, in the Link Hello | [Sandbox link protocol](../../docs/sandbox-link-protocol.md) | -Every credential travels in an `Authorization: Bearer` header, never in a URL. +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 and the two installation routes. The two WebSockets and the daemon bootstrap, device-status, 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 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, device-status, 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. ## Credentials diff --git a/contracts/agents-api/runtime.openapi.yaml b/contracts/agents-api/runtime.openapi.yaml index 8e1ac37a7..4c73a72ec 100644 --- a/contracts/agents-api/runtime.openapi.yaml +++ b/contracts/agents-api/runtime.openapi.yaml @@ -254,6 +254,19 @@ paths: summary: Claim an Environment's installation credential tags: - Native Installation + /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.' + responses: + "101": + description: Switching Protocols; the connection carries the Link + "400": + description: The request is not a WebSocket upgrade + "403": + description: The request carries an Origin other than its Host + summary: Open a sandbox Link + tags: + - Sandbox Link /api/v1/sandbox-node/configuration: get: description: Authenticates with an unconsumed enrollment token, or a retained node credential with X-OAC-Node-ID. Does not consume the token or expose E2B credentials. Node files cannot override this specification. diff --git a/contracts/agents-api/zh/machine-api.md b/contracts/agents-api/zh/machine-api.md index 9d08366a4..59b0ebe6a 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: 18cc3d9491e65cc9845223045039967317c9e3532987c24cc2732dec820f8c57 +source_hash: ede6031c1e4761dbbf27dd19829642ff892c2e12f26ec972da46dcb76c43c4fe --- -机器通过 `/api/v1` 调用 Core:包括沙箱节点、Runtime daemon 和自托管安装器。各路由仅接受所列凭据,不接受 Core 密钥或 Project API 密钥;控制台登录也不授予此处权限。反向代理将 `/api/v1` 直接发送给 Core;Web 不提供这些路由。 +机器通过 `/api/v1` 调用 Core:包括沙箱节点、Runtime daemon、Sandbox I/O 服务和自托管安装器。各路由仅接受所列凭据,不接受 Core 密钥或 Project API 密钥;控制台登录也不授予此处权限。反向代理将 `/api/v1` 直接发送给 Core;Web 不提供这些路由。 ## 路由 {#routes} @@ -21,10 +21,11 @@ source_hash: 18cc3d9491e65cc9845223045039967317c9e3532987c24cc2732dec820f8c57 | `POST agent-daemon/bootstrap` | Runtime daemon | daemon 凭据 | [daemon 引导](#daemon-bootstrap) | | `GET agent-daemon/device-status?device_id=` | Runtime daemon | daemon 凭据 | [设备状态](#device-status) | | WebSocket `GET agent-daemon/ws?device_id=&version=` | Runtime daemon | daemon 凭据 | [Core–Runtime 协议](../../../docs/zh/runtime-protocol.md) | +| WebSocket `GET sandbox-link` | Sandbox I/O 服务(serve peer)和 agent-host Runtime(attach peer) | 资源的 Serve 凭据或 agent host 的 Runtime 凭据,在 Link Hello 中发送 | [Sandbox link 协议](../../../docs/zh/sandbox-link-protocol.md) | -所有凭据通过 `Authorization: Bearer` 头传输,不放入 URL。 +所有凭据通过 `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 配置、登记、身份路由和两个安装路由。两个 WebSocket 及 daemon 引导、设备状态、登记和连接路由在 API 路由器外提供,无生成 schema;本文及所链接契约是它们唯一的定义。 +生成的 [`runtime.openapi.yaml`](../runtime.openapi.yaml) 仅描述 sandbox-node 配置、登记、身份路由、两个安装路由和 `sandbox-link` 升级;该升级上的消息由 Sandbox link 协议定义。`sandbox-node/connect` 和 `agent-daemon/ws` 两个 WebSocket 及 daemon 引导、设备状态、登记和连接路由在 API 路由器外提供,无生成 schema;本文及所链接契约是它们唯一的定义。 ## 凭据 {#credentials} diff --git a/docs/api/index.md b/docs/api/index.md index 5bef473ef..a1061a939 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -8,7 +8,7 @@ Core serves three namespaces. Each has one kind of caller and its own credential | --- | --- | --- | --- | --- | | `/v1` | Applications: business systems and the official OpenAI SDK | Project API key | Exactly the 58 method and path pairs of the pinned official Agents API, listed in [upstream-routes.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream-routes.json). Core-only fields sit inside `x_agents_core`: `harness`, `model_provider`, `harness_config`, `environment`, and the read-only Session `installation` | [Agents API guide](./public-agent-api.md) | | `/core/v1` | Web's console server and operator scripts | [Core key](../getting-started/operations.md#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, executor credentials, default models, metrics, audit, sandbox deployment and nodes | [Core administration API](../../contracts/agents-api/admin-api.md) | -| `/api/v1` | Nodes, Runtime daemons, self-hosted executors and their installers | Machine credentials: node enrollment tokens and node credentials, installation grants, executor credentials, and daemon credentials. Each works only on its own routes | Machine bootstrap and connections under `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets, and the public native installer downloads | [Machine connection API](../../contracts/agents-api/machine-api.md) | +| `/api/v1` | Nodes, Runtime daemons, the Sandbox I/O service, self-hosted executors and their installers | Machine credentials: node enrollment tokens and node credentials, installation grants, executor credentials, daemon credentials, and the Serve and Runtime credentials a Link Hello carries. Each works only on its own routes | Machine bootstrap and connections under `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets, the sandbox Link at `/api/v1/sandbox-link`, and the public native installer downloads | [Machine connection API](../../contracts/agents-api/machine-api.md) | A credential used in another namespace gets 401: a Project API key on `/core/v1` or `/api/v1`, the Core key on `/v1` or `/api/v1`. How Projects and keys behave is in [Projects own assets](../concepts.md#projects-own-assets). @@ -18,4 +18,4 @@ A credential used in another namespace gets 401: a Project API key on `/core/v1` ## Machine connection API -Nodes, Runtime daemons and the self-hosted installer call `/api/v1` with their own credentials. The [machine connection API](../../contracts/agents-api/machine-api.md) lists every route, caller and credential. +Nodes, Runtime daemons, the Sandbox I/O service and the self-hosted installer call `/api/v1` with their own credentials. The [machine connection API](../../contracts/agents-api/machine-api.md) lists every route, caller and credential. diff --git a/docs/configuration.md b/docs/configuration.md index 5b0a63367..8981945a0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -28,7 +28,9 @@ Use `docker compose ps` to check the services. See [stop and restart](./getting- ### Changing the public URL {#changing-the-public-url} -`OAC_PUBLIC_URL` is the one origin that applications, nodes, sandboxes and self-hosted executors use. Core derives the daemon WebSocket URL, the self-hosted `remote_url` and each sandbox's connection address from it. It is an http or https origin: the address browsers and nodes use. The installation serves Web over HTTP on `OAC_WEB_PORT`; a reverse proxy or hosting platform terminates HTTPS when you put one in front. +`OAC_PUBLIC_URL` is the one origin that applications, nodes, sandboxes and self-hosted executors use. Core derives the daemon WebSocket URL, the sandbox Link URL, the self-hosted `remote_url` and each sandbox's connection address from it. It is an http or https origin: the address browsers and nodes use. The installation serves Web over HTTP on `OAC_WEB_PORT`; a reverse proxy or hosting platform terminates HTTPS when you put one in front. + +Sandboxes and agent hosts dial the [sandbox Link](./sandbox-link-protocol.md) at `wss:///api/v1/sandbox-link` when the origin is https. An http origin on `localhost` or a loopback address gives `ws:///api/v1/sandbox-link`, which only peers in Core's own network namespace can reach. An http origin on any other host gives no Link URL: nothing can use the Link until the origin is https. To change it, point the reverse proxy at the new address first, then edit `OAC_PUBLIC_URL` and run `oac apply`. Afterwards: @@ -153,7 +155,7 @@ Core reads its process environment. Compose interpolates `.env` into it and moun | Variable | Set from | | --- | --- | -| `OAC_PUBLIC_URL` | The public origin. Core derives the daemon WebSocket URL, the self-hosted `remote_url`, the hosted sandbox address and the deployment's read-only `core_url` from it, never from request headers. Without it, Core runs no Runtime gateway and executes no Sessions | +| `OAC_PUBLIC_URL` | The public origin. Core derives the daemon WebSocket URL, the [sandbox Link URL](#changing-the-public-url), the self-hosted `remote_url`, the hosted sandbox address and the deployment's read-only `core_url` from it, never from request headers. Without it, Core runs no Runtime gateway and executes no Sessions | | `OAC_ADDR` | The image sets `:8091`. Independently started Core defaults to `127.0.0.1:8091` when unset or empty | | `OAC_DATABASE_URL` | PostgreSQL without a password | | `OAC_DATABASE_PASSWORD_FILE` | `/run/database/password`. The URL must then carry no password | diff --git a/docs/zh/api/index.md b/docs/zh/api/index.md index 21b217dcd..ac4d31a70 100644 --- a/docs/zh/api/index.md +++ b/docs/zh/api/index.md @@ -1,7 +1,7 @@ --- title: "API 命名空间和凭据" source: docs/api/index.md -source_hash: 11df05084e85f1bc05650e11c2c744318b92ba7ac90280207956fce712335122 +source_hash: aac5fe238391097213335c12fb9b9da4786a7afc1ef79f8ef7d3c3cb5c1a3f75 --- Core 提供三个命名空间。每个命名空间都有一种调用方及其独立凭据,凭据只能在其所属命名空间中使用。 @@ -10,7 +10,7 @@ Core 提供三个命名空间。每个命名空间都有一种调用方及其独 | --- | --- | --- | --- | --- | | `/v1` | 应用程序:业务系统和官方 OpenAI SDK | Project API key | 固定版本官方 Agents API 中全部且仅有的 58 个方法和路径对,列于 [upstream-routes.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream-routes.json)。仅属于 Core 的字段位于 `x_agents_core` 中:`harness`、`model_provider`、`harness_config`、`environment`,以及只读的 Session `installation` | [Agents API 指南](public-agent-api.md) | | `/core/v1` | Web 的控制台服务器和操作员脚本 | [Core key](../getting-started/operations.md#core-key) | 安装信息、Project 和密钥、资源读取和删除、Session 归档、执行器凭据、默认模型、指标、审计、沙箱部署和节点 | [Core 管理 API](../../../contracts/agents-api/zh/admin-api.md) | -| `/api/v1` | 节点、Runtime 守护进程、自托管执行器及其安装程序 | 机器凭据:节点注册令牌和节点凭据、安装授权、执行器凭据和守护进程凭据。每种凭据只能用于其各自的路由 | `/api/v1/sandbox-node/*` 和 `/api/v1/agent-daemon/*` 下的机器初始化与连接(包括 WebSockets),以及公共原生安装程序下载 | [机器连接 API](../../../contracts/agents-api/zh/machine-api.md) | +| `/api/v1` | 节点、Runtime 守护进程、Sandbox I/O 服务、自托管执行器及其安装程序 | 机器凭据:节点注册令牌和节点凭据、安装授权、执行器凭据、守护进程凭据,以及 Link Hello 携带的 Serve 凭据和 Runtime 凭据。每种凭据只能用于其各自的路由 | `/api/v1/sandbox-node/*` 和 `/api/v1/agent-daemon/*` 下的机器初始化与连接(包括 WebSockets)、`/api/v1/sandbox-link` 上的沙箱 Link,以及公共原生安装程序下载 | [机器连接 API](../../../contracts/agents-api/zh/machine-api.md) | 在其他命名空间中使用凭据会返回 401:在 `/core/v1` 或 `/api/v1` 上使用 Project API key,或者在 `/v1` 或 `/api/v1` 上使用 Core key。有关 Project 和密钥的行为,请参阅 [Project 自有资产](../concepts.md#projects-own-assets)。 @@ -20,4 +20,4 @@ Core 提供三个命名空间。每个命名空间都有一种调用方及其独 ## 机器连接 API {#machine-connection-api} -节点、Runtime 守护进程和自托管安装程序使用各自的凭据调用 `/api/v1`。[机器连接 API](../../../contracts/agents-api/zh/machine-api.md) 列出了每个路由、调用方和凭据。 +节点、Runtime 守护进程、Sandbox I/O 服务和自托管安装程序使用各自的凭据调用 `/api/v1`。[机器连接 API](../../../contracts/agents-api/zh/machine-api.md) 列出了每个路由、调用方和凭据。 diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 7d6bfafaf..5cb0860d6 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -1,7 +1,7 @@ --- title: "配置参考" source: docs/configuration.md -source_hash: 61cb35a25bbe3624a9a7dfea02e845b0c393f1e9609900225bb4e82d1b18f36d +source_hash: 92a9515766fa24442f4c6c53bfa2c3940662a0a64a4bc2505af715c4dfd31e7e --- Core 安装的每项设置都恰好只有一个归属位置。共有两类: @@ -30,7 +30,9 @@ Web 的 **System** 页面显示该安装的地址、默认模型和沙箱配置 ### 更改公共 URL {#changing-the-public-url} -`OAC_PUBLIC_URL` 是应用、节点、沙箱和自托管执行器使用的唯一源地址。Core 从中派生守护进程 WebSocket URL、自托管 `remote_url` 和每个沙箱的连接地址。它是 http 或 https 源地址,也就是浏览器和节点使用的地址。安装通过 `OAC_WEB_PORT` 以 HTTP 提供 Web;前面有反向代理或托管平台时,由它们终止 HTTPS。 +`OAC_PUBLIC_URL` 是应用、节点、沙箱和自托管执行器使用的唯一源地址。Core 从中派生守护进程 WebSocket URL、沙箱 Link URL、自托管 `remote_url` 和每个沙箱的连接地址。它是 http 或 https 源地址,也就是浏览器和节点使用的地址。安装通过 `OAC_WEB_PORT` 以 HTTP 提供 Web;前面有反向代理或托管平台时,由它们终止 HTTPS。 + +源地址为 https 时,沙箱和 agent host 通过 `wss:///api/v1/sandbox-link` 连接[沙箱 Link](./sandbox-link-protocol.md)。`localhost` 或回环地址上的 http 源地址得到 `ws:///api/v1/sandbox-link`,只有 Core 自身网络命名空间内的 peer 能访问。其他主机上的 http 源地址没有 Link URL:在源地址改为 https 之前,任何组件都无法使用 Link。 要更改它,先把反向代理指向新地址,然后编辑 `OAC_PUBLIC_URL` 并运行 `oac apply`。之后: @@ -157,7 +159,7 @@ Core 读取进程环境。Compose 将 `.env` 插值到环境中,并把机密 | 变量 | 设置来源 | | --- | --- | -| `OAC_PUBLIC_URL` | `public_url`,或 Core 的回环源地址。Core 从中派生守护进程 WebSocket URL、自托管 `remote_url`、托管沙箱地址和部署的只读 `core_url`,绝不从请求标头派生。未设置时,Core 不运行 Runtime 网关,也不执行任何 Session | +| `OAC_PUBLIC_URL` | `public_url`,或 Core 的回环源地址。Core 从中派生守护进程 WebSocket URL、[沙箱 Link URL](#changing-the-public-url)、自托管 `remote_url`、托管沙箱地址和部署的只读 `core_url`,绝不从请求标头派生。未设置时,Core 不运行 Runtime 网关,也不执行任何 Session | | `OAC_ADDR` | 安装程序在容器中设置为 `:8091`。独立启动的 Core 在未设置或为空时,默认使用 `127.0.0.1:8091` | | `OAC_DATABASE_URL` | 该安装不含密码的 PostgreSQL URL,并将 `core.database_pool` 作为 `pool_*` 查询参数附加到其中 | | `OAC_DATABASE_PASSWORD_FILE` | `/run/database/password`。此时 URL 不得包含密码 | diff --git a/services/core/cmd/server/http_routes_test.go b/services/core/cmd/server/http_routes_test.go index f31600030..f6375996e 100644 --- a/services/core/cmd/server/http_routes_test.go +++ b/services/core/cmd/server/http_routes_test.go @@ -114,6 +114,7 @@ func daemonComposition(t testing.TB) http.Handler { 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 }{}}, diff --git a/services/core/cmd/server/main.go b/services/core/cmd/server/main.go index 31773c625..8dd2407d5 100644 --- a/services/core/cmd/server/main.go +++ b/services/core/cmd/server/main.go @@ -419,6 +419,7 @@ func run() error { InputAdmission: worker, SessionArchive: deploymentExecution, Workspaces: worker, + Links: linkRelay, NativeInstaller: nativeInstaller, } } diff --git a/services/core/internal/api/dependencies.go b/services/core/internal/api/dependencies.go index f4e2b408d..a89f7e1cc 100644 --- a/services/core/internal/api/dependencies.go +++ b/services/core/internal/api/dependencies.go @@ -84,6 +84,8 @@ type Execution struct { InputAdmission InputAdmission SessionArchive SessionArchive Workspaces EnvironmentWorkspaces + // Links is the Link relay, served at GET /api/v1/sandbox-link. + Links 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 @@ -159,6 +161,7 @@ func (d Dependencies) validate() error { field{"Execution.InputAdmission", e.InputAdmission}, field{"Execution.SessionArchive", e.SessionArchive}, field{"Execution.Workspaces", e.Workspaces}, + field{"Execution.Links", e.Links}, ); err != nil { return err } diff --git a/services/core/internal/api/dependencies_test.go b/services/core/internal/api/dependencies_test.go index 70822e5eb..3ba080c2d 100644 --- a/services/core/internal/api/dependencies_test.go +++ b/services/core/internal/api/dependencies_test.go @@ -50,6 +50,7 @@ type testFakes struct { inputAdmission *fakeInputAdmission sessionArchive *fakeSessionArchive workspaces *fakeEnvironmentWorkspaces + links *fakeLinks deployment *fakeDeployment nodeAllocations *fakeNodeAllocations deploymentChanges *fakeDeploymentChanges @@ -88,7 +89,7 @@ func testDependencies(t testing.TB) (Dependencies, *testFakes) { runtimeObservations: &fakeRuntimeObservations{t: t}, runtimeHistory: &fakeRuntimeHistory{t: t}, installationBindings: &fakeInstallationBindings{t: t}, sessionAdmission: &fakeSessionAdmission{t: t}, inputAdmission: &fakeInputAdmission{t: t}, - sessionArchive: &fakeSessionArchive{t: t}, workspaces: &fakeEnvironmentWorkspaces{t: t}, + sessionArchive: &fakeSessionArchive{t: t}, workspaces: &fakeEnvironmentWorkspaces{t: t}, links: &fakeLinks{t: t}, deployment: &fakeDeployment{t: t}, nodeAllocations: &fakeNodeAllocations{t: t}, deploymentChanges: &fakeDeploymentChanges{t: t}, deploymentReset: &fakeDeploymentReset{t: t}, configurationDiscovery: &fakeConfigurationDiscovery{t: t}, @@ -126,6 +127,7 @@ func (f *testFakes) execution() *Execution { InputAdmission: f.inputAdmission, SessionArchive: f.sessionArchive, Workspaces: f.workspaces, + Links: f.links, } } diff --git a/services/core/internal/api/fakes_test.go b/services/core/internal/api/fakes_test.go index f7bb22fac..3d298d938 100644 --- a/services/core/internal/api/fakes_test.go +++ b/services/core/internal/api/fakes_test.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/json" "io" + "net/http" "testing" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" @@ -392,6 +393,12 @@ func (f *fakeEnvironmentWorkspaces) WriteEnvironmentFile(a0 context.Context, a1 return f.writeEnvironmentFile(a0, a1, a2, a3) } +type fakeLinks struct{ t testing.TB } + +func (f *fakeLinks) ServeHTTP(http.ResponseWriter, *http.Request) { + unexpectedCall(f.t, "Links.ServeHTTP") +} + type fakeEnvironmentsReader struct { t testing.TB getEnvironment func(context.Context, string, string) (sessions.Environment, error) diff --git a/services/core/internal/api/handler.go b/services/core/internal/api/handler.go index 2e358785e..7cc03d5cc 100644 --- a/services/core/internal/api/handler.go +++ b/services/core/internal/api/handler.go @@ -48,10 +48,11 @@ type SessionsReader interface { // routes builds the router. HEAD runs the GET route without a body after the // same authentication and Beta checks (HP-19). Routes that stream events, // download content, read a live workspace directory, sample Runtime -// observations or query Runtime history register an explicit HEAD 405 instead, -// so HEAD never holds a stream open, reads full content or does Runtime or -// telemetry work. Every 405, including unknown methods and routes outside the -// Beta group, has the JSON body and Allow header. +// observations, query Runtime history or upgrade to the Link register an +// explicit HEAD 405 instead, so HEAD never holds a stream open, reads full +// content or does Runtime or telemetry work. Every 405, including unknown +// methods and routes outside the Beta group, has the JSON body and Allow +// header. func (h *Handler) routes() *chi.Mux { router := chi.NewRouter() router.Use(h.responseHeaders, log.HTTPMiddleware, middleware.GetHead) @@ -73,6 +74,10 @@ func (h *Handler) routes() *chi.Mux { h.registerSandboxNodeRoutes(router) h.registerCoreRoutes(router) h.registerNativeInstallationRoutes(router) + if h.Execution != nil { + router.Get("/api/v1/sandbox-link", h.sandboxLink) + router.Head("/api/v1/sandbox-link", methodNotAllowed) + } router.Route("/v1", func(r chi.Router) { r.Use(h.authenticate) r.Post("/vaults", h.createVault) diff --git a/services/core/internal/api/routing.go b/services/core/internal/api/routing.go index 5f2cef602..782c7d4dd 100644 --- a/services/core/internal/api/routing.go +++ b/services/core/internal/api/routing.go @@ -1,9 +1,11 @@ package api import ( + "bufio" "crypto/rand" "encoding/hex" "fmt" + "net" "net/http" "net/url" "path" @@ -143,7 +145,8 @@ func newRequestID() string { // processingTimeWriter reports the elapsed handling time when the final // response headers are written, flushed or implied by the first body write. -// Unwrap keeps http.ResponseController deadlines and flushing available. +// Unwrap keeps http.ResponseController deadlines and flushing available, and +// Hijack keeps the WebSocket upgrade, which asserts http.Hijacker, available. type processingTimeWriter struct { http.ResponseWriter started time.Time @@ -186,6 +189,10 @@ func (w *processingTimeWriter) Flush() { _ = w.FlushError() } func (w *processingTimeWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } +func (w *processingTimeWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + return http.NewResponseController(w.ResponseWriter).Hijack() +} + // methodNotAllowed keeps Core's JSON 405 and adds the route's Allow header // (HP-20). It also answers HEAD on routes that exclude it. func methodNotAllowed(w http.ResponseWriter, r *http.Request) { diff --git a/services/core/internal/api/routing_test.go b/services/core/internal/api/routing_test.go index c17942692..b888494b3 100644 --- a/services/core/internal/api/routing_test.go +++ b/services/core/internal/api/routing_test.go @@ -275,7 +275,9 @@ func dirtyVariants(clean string) []string { // credential, so it can never reach another route group or skip its checks. func TestEveryRouteAuthenticatesItsCanonicalPath(t *testing.T) { handler, router, s := routingFixture(t) - selfAuthenticated := map[string]bool{"GET /healthz": false, "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} + 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} 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 new file mode 100644 index 000000000..e4721a2e1 --- /dev/null +++ b/services/core/internal/api/sandbox_link.go @@ -0,0 +1,14 @@ +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/deployment/placement/placement.go b/services/core/internal/deployment/placement/placement.go index 433e6f6b8..ca5acb5d0 100644 --- a/services/core/internal/deployment/placement/placement.go +++ b/services/core/internal/deployment/placement/placement.go @@ -26,6 +26,9 @@ var ( // ErrPublicURLUnreachable rejects selection and admission when the provider // requires a reachable public origin and the installation is loopback. ErrPublicURLUnreachable = errors.New("This sandbox provider needs a reachable HTTPS public URL before they can connect to Core.") + // ErrNoLink reports that the installation public URL gives no Link URL: + // it is neither https nor http on a loopback host. + ErrNoLink = errors.New("the sandbox Link needs an https public URL, or an http one on a loopback host") // ErrNodesPreparing rejects placement while no node serves the target // generation and at least one is preparing it. ErrNodesPreparing = errors.New("sandbox nodes are preparing the target generation") @@ -230,6 +233,26 @@ func CheckRestore(restore Restore) error { return nil } +// LinkURL derives the Link URL from the installation public URL: wss:// for +// an https origin, and ws:// for an http origin on a loopback host, which +// only peers in Core's own network namespace can dial. Any other origin has +// no Link and returns ErrNoLink. +func LinkURL(publicURL string) (string, error) { + u, err := url.Parse(publicURL) + switch { + case err != nil: + return "", ErrNoLink + case u.Scheme == "https": + u.Scheme = "wss" + case u.Scheme == "http" && LoopbackOrigin(publicURL): + u.Scheme = "ws" + default: + return "", ErrNoLink + } + u.Path = "/api/v1/sandbox-link" + return u.String(), nil +} + // LoopbackOrigin reports whether a validated origin names a loopback host, // which nothing outside the Core host can reach. func LoopbackOrigin(value string) bool { diff --git a/services/core/internal/deployment/placement/placement_test.go b/services/core/internal/deployment/placement/placement_test.go index ff7212cde..4f85514c3 100644 --- a/services/core/internal/deployment/placement/placement_test.go +++ b/services/core/internal/deployment/placement/placement_test.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" ) @@ -192,6 +193,32 @@ func TestCheckReservedAndRestore(t *testing.T) { } } +func TestLinkURL(t *testing.T) { + for origin, want := range map[string]string{ + "https://core.example": "wss://core.example/api/v1/sandbox-link", + "https://core.example:8443": "wss://core.example:8443/api/v1/sandbox-link", + "https://127.0.0.1:8443": "wss://127.0.0.1:8443/api/v1/sandbox-link", + "http://localhost:8080": "ws://localhost:8080/api/v1/sandbox-link", + "http://127.0.0.1:8080": "ws://127.0.0.1:8080/api/v1/sandbox-link", + "http://[::1]:8080": "ws://[::1]:8080/api/v1/sandbox-link", + "http://core.example": "", + "http://10.0.0.1:8080": "", + "http://host.localhost": "", + "": "", + } { + got, err := LinkURL(origin) + if want == "" { + if !errors.Is(err, ErrNoLink) || got != "" { + t.Errorf("LinkURL(%q) = %q, %v; want ErrNoLink", origin, got, err) + } + continue + } + if err != nil || got != want || sandboxlink.CheckRelayURL(got) != nil { + t.Errorf("LinkURL(%q) = %q, %v; want %q", origin, got, err, want) + } + } +} + func TestLoopbackOrigin(t *testing.T) { for value, want := range map[string]bool{ "http://localhost:8091": true, "http://127.0.0.1:8091": true, "http://127.0.0.2": true, "http://[::1]:8091": true, diff --git a/services/core/internal/sandbox/providers/configuration_flow_test.go b/services/core/internal/sandbox/providers/configuration_flow_test.go index b61b73c5a..4012e48ef 100644 --- a/services/core/internal/sandbox/providers/configuration_flow_test.go +++ b/services/core/internal/sandbox/providers/configuration_flow_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "net/http" "net/http/httptest" "strings" "testing" @@ -150,6 +151,7 @@ func TestAdditionalConfigurationProviderUsesCommonAPIAndStore(t *testing.T) { InputAdmission: struct{ api.InputAdmission }{}, SessionArchive: struct{ api.SessionArchive }{}, Workspaces: struct{ api.EnvironmentWorkspaces }{}, + Links: struct{ http.Handler }{}, }, Sandboxes: &api.Sandboxes{Deployment: service, NodeAllocations: deploymentpg.New(pgunit.NewPool(pool), nil), DeploymentChanges: leaseSetup{t: t, changes: changes, installation: installation}, DeploymentReset: leaseSetup{t: t, changes: changes, installation: installation}, ConfigurationDiscovery: struct{ api.ConfigurationDiscovery }{}}, diff --git a/services/core/tests/integration/link_authority_test.go b/services/core/tests/integration/link_authority_test.go index 18f084613..d81fbcae9 100644 --- a/services/core/tests/integration/link_authority_test.go +++ b/services/core/tests/integration/link_authority_test.go @@ -4,9 +4,12 @@ import ( "bytes" "context" "crypto/sha256" + "crypto/tls" + "crypto/x509" "encoding/hex" "encoding/json" "errors" + "net/http/httptest" "strings" "testing" "time" @@ -17,8 +20,11 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxbootstrap" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxfs" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink/relay" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink/sandboxlinktest" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment/placement" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" @@ -29,8 +35,8 @@ import ( const linkWait = 10 * time.Second // linkHarness is a hosted Session bound to h.device whose Environment has an -// allocation with a Serve credential, and Core's Link Authority behind a -// relay. +// allocation with a Serve credential, and Core's Link Authority behind the +// Link route. type linkHarness struct { *dispatchHarness relay *sandboxlinktest.Server @@ -51,7 +57,7 @@ func newLinkHarness(t *testing.T, guest bool) *linkHarness { } device = allocated.ID } - l := &linkHarness{dispatchHarness: h, relay: sandboxlinktest.StartRelay(t, runtimegateway.NewLinkAuthority(sessionAdapter(h.s))), serve: []byte(uuid.NewString()), + l := &linkHarness{dispatchHarness: h, relay: startLinkRoute(t, h.s), serve: []byte(uuid.NewString()), resource: sandboxbootstrap.Resource{TenantID: h.tenant, EnvironmentID: h.session.Environment.ID, Kind: "allocation", ID: uuid.NewString(), Generation: 1}} if _, err := h.s.pool.Exec(t.Context(), `INSERT INTO runtime_allocations(id,environment_id,device_id,provider_key,state,create_settled,deployment_generation,serve_credential_hash) VALUES($1,$2,$3,$4,'running',true,(SELECT generation FROM runtime_deployment),$5)`, l.resource.ID, l.resource.EnvironmentID, device, uuid.NewString(), serveHash(l.serve)); err != nil { @@ -60,6 +66,28 @@ func newLinkHarness(t *testing.T, guest bool) *linkHarness { return l } +// startLinkRoute serves Core's Link Authority at the Link route of the API +// handler on s, on an httptest TLS server, and dials it at the Link URL its +// origin derives. The relay closes before the server when the test ends. +func startLinkRoute(t *testing.T, s *Store) *sandboxlinktest.Server { + t.Helper() + rl := relay.New(runtimegateway.NewLinkAuthority(sessionAdapter(s))) + handler, err := publicHandler(t, s, fixtureKeyResolver{}, "codex", storeExecution(t, s), func(d *api.Dependencies) { d.Execution.Links = rl }) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewTLSServer(handler) + t.Cleanup(srv.Close) + t.Cleanup(func() { rl.Close() }) + link, err := placement.LinkURL(srv.URL) + if err != nil { + t.Fatal(err) + } + roots := x509.NewCertPool() + roots.AddCert(srv.Certificate()) + return &sandboxlinktest.Server{URL: link, TLS: &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}, Relay: rl} +} + func serveHash(credential []byte) string { digest := sha256.Sum256(credential) return hex.EncodeToString(digest[:]) @@ -357,7 +385,7 @@ func TestLinkAuthorityEnrollment(t *testing.T) { if _, err := s.pool.Exec(t.Context(), "INSERT INTO sandbox_enrollments(id, environment_id, executor_key_id) VALUES($1, $2, $3)", resource.ID, resource.EnvironmentID, key.KeyID); err != nil { t.Fatal(err) } - srv := sandboxlinktest.StartRelay(t, runtimegateway.NewLinkAuthority(sessionAdapter(s))) + srv := startLinkRoute(t, s) served := startLinkServe(t, srv, []byte(key.Token), resource.Ref()) within(t, served.connected) served.stop() @@ -424,7 +452,7 @@ func TestLinkAuthorityDestroyedAllocation(t *testing.T) { s, _ := newManagedTestStore(t) tenant, session, environment := managedSession(t, s) key := uuid.NewString() - srv := sandboxlinktest.StartRelay(t, runtimegateway.NewLinkAuthority(sessionAdapter(s))) + srv := startLinkRoute(t, s) w := startWorker(t, t.Context(), s, &execution.Dispatcher{Registry: runtimegateway.NewRegistry(), Links: srv.Relay, ManagedRuntimes: &execution.RuntimeProvider{ CoreURL: "http://core.invalid/api/v1", InstallationID: key, BackendFingerprint: strings.Repeat("a", 64), Provider: &lifecycleProvider{resources: map[string]sandbox.Info{}}}}) t.Cleanup(func() { ctx, cancel := context.WithCancel(context.Background()); cancel(); _ = w.Run(ctx) }) diff --git a/services/core/tests/integration/public_handler_fixture_test.go b/services/core/tests/integration/public_handler_fixture_test.go index 382dfec57..b1c022ccd 100644 --- a/services/core/tests/integration/public_handler_fixture_test.go +++ b/services/core/tests/integration/public_handler_fixture_test.go @@ -154,6 +154,7 @@ func storeExecution(t testing.TB, s *Store) func(*api.Dependencies) { InputAdmission: service, SessionArchive: strictStandIn{t}, Workspaces: strictStandIn{t}, + Links: strictStandIn{t}, } } } @@ -168,6 +169,7 @@ func workerExecution(t testing.TB, worker *execution.Worker) func(*api.Dependenc InputAdmission: worker, SessionArchive: strictStandIn{t}, Workspaces: worker, + Links: strictStandIn{t}, } } } @@ -237,6 +239,8 @@ func (s strictStandIn) Read(context.Context, string) (coremetrics.View, error) { func (s strictStandIn) RecordUnavailable() { s.unexpected("RecordUnavailable") } +func (s strictStandIn) ServeHTTP(http.ResponseWriter, *http.Request) { s.unexpected("ServeHTTP") } + func (s strictStandIn) ObserveSession(context.Context, string, string) (runtimeobs.Observation, error) { s.unexpected("ObserveSession") return runtimeobs.Observation{}, nil 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 be5360247..138ef52ac 100644 --- a/services/core/tests/integration/self_hosted_initial_public_test.go +++ b/services/core/tests/integration/self_hosted_initial_public_test.go @@ -53,6 +53,7 @@ func TestSelfHostedInitialCreationOfficialClient(t *testing.T) { InputAdmission: unavailableAdmission{}, SessionArchive: strictStandIn{t}, Workspaces: strictStandIn{t}, + Links: strictStandIn{t}, } }) }