diff --git a/contracts/agents-api/openapi.go b/contracts/agents-api/openapi.go new file mode 100644 index 00000000..ab4ae4ae --- /dev/null +++ b/contracts/agents-api/openapi.go @@ -0,0 +1,11 @@ +// Package agentsapi embeds the generated OpenAPI documents of Core's three +// namespaces so Core can serve them at /docs. +package agentsapi + +import "embed" + +// OpenAPI holds openapi.yaml (/v1), core.openapi.yaml (/core/v1) and +// runtime.openapi.yaml (/api/v1), as generated by make openapi. +// +//go:embed openapi.yaml core.openapi.yaml runtime.openapi.yaml +var OpenAPI embed.FS diff --git a/docs/api/index.md b/docs/api/index.md index c25cfa04..cb4c00a4 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -14,6 +14,8 @@ A credential used in another namespace gets 401: a Project API key on `/core/v1` **Routing.** The reverse proxy sends `/v1` and `/api/v1` to Core and everything else to Web ([proxy setup](../getting-started/install-options.md#https-and-the-reverse-proxy)). Browsers reach `/core/v1` only through Web's console server, which adds the Core key after sign-in and answers 404 for `/v1` and `/api/v1` ([console server](../web/console-server.md)). Operator scripts call `/core/v1` on Core's loopback port ([script the Core API](../getting-started/operations.md#script-the-core-api)). +**API reference page.** Core serves a read-only Swagger UI of all three namespaces at `/docs`, and the generated documents it renders at `/docs/openapi.yaml`, `/docs/core.openapi.yaml` and `/docs/runtime.openapi.yaml`. These routes need no credential, and the page sends no API requests. The reverse proxy does not route `/docs` to Core, so open it on Core's own address: on the Core host, `http://127.0.0.1:/docs`, where the port is [`ports.core`](../configuration.md#settings), 8091 by default. The browser loads Swagger UI from `unpkg.com`. + ## 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. diff --git a/docs/zh/api/index.md b/docs/zh/api/index.md index 67d7a40c..564a4ee9 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: 278dc9cde80bdc89641b98c7c8dd386c9405b386cd2ad82e7addaf73f60a6126 +source_hash: d6483deff36f22b113e3d6df256c399238b8f4ffeeda889a1b0e3f0ddbdb6e67 --- Core 提供三个命名空间。每个命名空间都有一种调用方及其独立凭据,凭据只能在其所属命名空间中使用。 @@ -16,6 +16,8 @@ Core 提供三个命名空间。每个命名空间都有一种调用方及其独 **路由。** 反向代理将 `/v1` 和 `/api/v1` 发送到 Core,将其他所有请求发送到 Web([代理设置](../getting-started/install-options.md#https-and-the-reverse-proxy))。浏览器只能通过 Web 的控制台服务器访问 `/core/v1`;该服务器会在登录后添加 Core key,并对 `/v1` 和 `/api/v1` 返回 404([控制台服务器](../web/console-server.md))。操作员脚本通过 Core 的回环端口调用 `/core/v1`([编写 Core API 脚本](../getting-started/operations.md#script-the-core-api))。 +**API 参考页面。** Core 在 `/docs` 提供三个命名空间的只读 Swagger UI,并在 `/docs/openapi.yaml`、`/docs/core.openapi.yaml` 和 `/docs/runtime.openapi.yaml` 提供页面所渲染的生成文档。这些路由不需要凭据,页面也不会发送 API 请求。反向代理不会把 `/docs` 发送到 Core,因此请通过 Core 自己的地址打开:在 Core 主机上访问 `http://127.0.0.1:/docs`,其中端口为 [`ports.core`](../configuration.md#settings),默认是 8091。浏览器从 `unpkg.com` 加载 Swagger UI。 + ## 机器连接 API {#machine-connection-api} 节点、Runtime 守护进程和自托管安装程序使用各自的凭据调用 `/api/v1`。[机器连接 API](../../../contracts/agents-api/zh/machine-api.md) 列出了每个路由、调用方和凭据。 diff --git a/scripts/build-core.sh b/scripts/build-core.sh index 60ee62a7..60b8e096 100755 --- a/scripts/build-core.sh +++ b/scripts/build-core.sh @@ -25,6 +25,7 @@ trap 'rm -rf "$build_context"' EXIT tar -C "$repo_root" -cf - \ go.mod go.sum \ contracts/agents-api/v1 \ + contracts/agents-api/openapi.go contracts/agents-api/openapi.yaml contracts/agents-api/core.openapi.yaml contracts/agents-api/runtime.openapi.yaml \ internal/agentdaemon/proto \ internal/runtimefs internal/runtimebootstrap internal/agentnetwork internal/agentbundle internal/agentcapabilities internal/agentplugin internal/agentskill internal/harnessconfig internal/modelprovider internal/providerassets internal/obs/log services/core \ | tar -C "$build_context" -xf - diff --git a/services/core/internal/api/contract_routes_test.go b/services/core/internal/api/contract_routes_test.go index 09f37f19..8a2133cc 100644 --- a/services/core/internal/api/contract_routes_test.go +++ b/services/core/internal/api/contract_routes_test.go @@ -17,6 +17,8 @@ import ( // "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", } diff --git a/services/core/internal/api/handler.go b/services/core/internal/api/handler.go index 3892bbf8..77746f5b 100644 --- a/services/core/internal/api/handler.go +++ b/services/core/internal/api/handler.go @@ -58,6 +58,7 @@ func (h *Handler) routes() *chi.Mux { router.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) }) + registerOpenAPIDocsRoutes(router) router.Group(func(r chi.Router) { r.Use(h.authenticateProject) h.registerSkillRoutes(r) diff --git a/services/core/internal/api/openapi_docs.go b/services/core/internal/api/openapi_docs.go new file mode 100644 index 00000000..2a7b895a --- /dev/null +++ b/services/core/internal/api/openapi_docs.go @@ -0,0 +1,61 @@ +package api + +import ( + "net/http" + + agentsapi "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api" + "github.com/go-chi/chi/v5" +) + +// openAPIDocsPage renders the embedded documents with a pinned Swagger UI. +// The page stays read-only: no operation can be submitted, the authorization +// controls are removed so it never collects a credential, and validatorUrl +// is null so the browser contacts no validator service. +const openAPIDocsPage = ` + + + + +OpenAgentCore API + + + +
+ + + + + +` + +// registerOpenAPIDocsRoutes serves the API reference page and the documents +// it renders without authentication; they are the published contracts. +func registerOpenAPIDocsRoutes(router chi.Router) { + router.Get("/docs", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(openAPIDocsPage)) + }) + router.Get("/docs/{document}", func(w http.ResponseWriter, r *http.Request) { + document, err := agentsapi.OpenAPI.ReadFile(chi.URLParam(r, "document")) + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/yaml") + _, _ = w.Write(document) + }) +} diff --git a/services/core/internal/api/openapi_docs_test.go b/services/core/internal/api/openapi_docs_test.go new file mode 100644 index 00000000..6961fad9 --- /dev/null +++ b/services/core/internal/api/openapi_docs_test.go @@ -0,0 +1,41 @@ +package api + +import ( + "net/http" + "os" + "strings" + "testing" +) + +// /docs renders every published contract without a credential, and serves +// nothing but those documents. +func TestOpenAPIDocsServeThePublishedContracts(t *testing.T) { + handler, _, _ := routingFixture(t) + page := serve(handler, http.MethodGet, "/docs", "", nil) + if page.Code != http.StatusOK || !strings.HasPrefix(page.Header().Get("Content-Type"), "text/html") { + t.Fatalf("GET /docs = %d %v", page.Code, page.Header()) + } + for _, readOnly := range []string{"supportedSubmitMethods: []", "validatorUrl: null", "authorizeBtn: () => null", "authorizeOperationBtn: () => null"} { + if !strings.Contains(page.Body.String(), readOnly) { + t.Errorf("the page lost read-only setting %q", readOnly) + } + } + for _, file := range []string{"openapi.yaml", "core.openapi.yaml", "runtime.openapi.yaml"} { + if !strings.Contains(page.Body.String(), `"/docs/`+file+`"`) { + t.Errorf("the page does not render %s", file) + } + want, err := os.ReadFile("../../../../contracts/agents-api/" + file) + if err != nil { + t.Fatal(err) + } + got := serve(handler, http.MethodGet, "/docs/"+file, "", nil) + if got.Code != http.StatusOK || got.Header().Get("Content-Type") != "application/yaml" || got.Body.String() != string(want) { + t.Errorf("GET /docs/%s = %d %v", file, got.Code, got.Header()) + } + } + for _, path := range []string{"/docs/openapi.go", "/docs/upstream.json", "/docs/missing.yaml"} { + if got := serve(handler, http.MethodGet, path, "", nil); got.Code != http.StatusNotFound { + t.Errorf("GET %s = %d, want 404", path, got.Code) + } + } +} diff --git a/services/core/internal/api/routing_test.go b/services/core/internal/api/routing_test.go index 3478203f..c1794269 100644 --- a/services/core/internal/api/routing_test.go +++ b/services/core/internal/api/routing_test.go @@ -275,7 +275,7 @@ func dirtyVariants(clean string) []string { // credential, so it can never reach another route group or skip its checks. func TestEveryRouteAuthenticatesItsCanonicalPath(t *testing.T) { handler, router, s := routingFixture(t) - selfAuthenticated := map[string]bool{"GET /healthz": false, "POST /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} 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"}),