From 3b088f71e27699e6dd0f9e23fa6ce8ca8cc6a8b2 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 26 May 2026 11:06:22 +0800 Subject: [PATCH] Route OpenClaw tasks through ACP RPC --- docs/acp-public-validation-2026-04-09.md | 4 +- docs/api-reference.md | 15 +- docs/architecture/acp-forwarding-topology.md | 27 ++- .../adr-refocus-bridge-as-control-plane.md | 5 +- .../adr-unified-bridge-entrypoints.md | 10 +- docs/backend-api-design.md | 31 ++- docs/internal-reference.md | 6 +- ...ills-interactive-test-report-2026-05-01.md | 15 +- internal/acp/http_handler.go | 90 +-------- internal/acp/web_contract_test.go | 179 ++---------------- 10 files changed, 71 insertions(+), 311 deletions(-) diff --git a/docs/acp-public-validation-2026-04-09.md b/docs/acp-public-validation-2026-04-09.md index 6cf2188..05e4c54 100644 --- a/docs/acp-public-validation-2026-04-09.md +++ b/docs/acp-public-validation-2026-04-09.md @@ -15,7 +15,7 @@ Last Updated: 2026-04-23 - `GET https://xworkmate-bridge.svc.plus/api/ping` - `GET wss://xworkmate-bridge.svc.plus/acp` - `POST https://xworkmate-bridge.svc.plus/acp/rpc` -- `POST https://xworkmate-bridge.svc.plus/gateway/openclaw` for OpenClaw `session.start` / `session.message` only +- OpenClaw `session.start` / `session.message` through `POST https://xworkmate-bridge.svc.plus/acp/rpc` with explicit gateway routing 不再把以下路径视为 public validation target: @@ -59,7 +59,7 @@ Last Updated: 2026-04-23 - 最终结果包含 `turnId` OpenClaw 的 `session.start` 与同一 task 的 follow-up `session.message` 使用 -`/gateway/openclaw`。`session.cancel` 与 `session.close` 仍使用 `/acp` 或 `/acp/rpc`。 +`/acp/rpc`。`session.cancel` 与 `session.close` 仍使用 `/acp` 或 `/acp/rpc`。 ### 5. Gateway Contract diff --git a/docs/api-reference.md b/docs/api-reference.md index 75ef5e1..7d7bbfe 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -6,8 +6,7 @@ - `xworkmate-bridge` 是 **APP-facing ACP control plane and provider runtime layer** - App-facing canonical transport 是 `GET /acp` WebSocket upgrade 后的 JSON-RPC stream -- `POST /acp/rpc` 仅作为 CI、脚本、调试和兼容 fallback -- `POST /gateway/openclaw` 仅是 OpenClaw `session.start` / `session.message` task submit 专用入口,不是全局 ACP base endpoint +- `POST /acp/rpc` 作为 CI、脚本、调试、HTTP fallback 和 OpenClaw gateway task submit 入口 - `/acp-server/*` 不属于 APP-facing contract,APP 不应保存或拼接这些 provider direct path ## 1. Runtime Entry Points @@ -33,9 +32,8 @@ | `/acp` | `GET` + WebSocket upgrade | 是 | App-facing JSON-RPC WebSocket 主入口 | | `/acp/rpc` | `POST` | 是 | JSON-RPC HTTP fallback / CI / 调试入口 | | `/acp/rpc` | `OPTIONS` | 否 | CORS preflight | -| `/gateway/openclaw` | `POST` | 是 | OpenClaw `session.start` / `session.message` task submit 专用入口 | -线上 Caddy 反代 `/api*`、`/acp*`、`/gateway/openclaw` 和 `/` 到 bridge origin。`/acp-server/*` 显式返回 `404`。 +线上 Caddy 反代 `/api*`、`/acp*`、`/artifacts/*` 和 `/` 到 bridge origin。`/acp-server/*` 显式返回 `404`。 ## 3. Auth / Origin @@ -146,8 +144,7 @@ bridge 对 app 的稳定 method family 只有: 路径约束: - `/acp/rpc` 是 capabilities、routing、agent、multi-agent、jobs、tools proxy、cancel、close 的 canonical HTTP RPC 入口。 -- `/gateway/openclaw` 只允许 OpenClaw `session.start` 和 follow-up `session.message`。 -- `/gateway/openclaw` 拒绝 `acp.capabilities`、`xworkmate.routing.resolve`、`xworkmate.gateway.*`、`session.cancel` 和 `session.close`。 +- OpenClaw `session.start` 和 follow-up `session.message` 也通过 `/acp/rpc`,由 `routing.explicitExecutionTarget=gateway` 与 `routing.preferredGatewayProviderId=openclaw` 表达。 ## 6. `acp.capabilities` @@ -261,7 +258,7 @@ multi-agent 输入仍使用同一个 `session.start` / `session.message` method - `routing.steps`: `{ "providerId": "codex", "prompt": "...", "outputAs": "...", "timeoutMs": 300000 }[]` - `routing.participants`、`routing.maxTurns`、`routing.stopConditions` 用于 `conversation` -multi-agent 只允许通过 `/acp` 或 `/acp/rpc` 进入。`/gateway/openclaw` 仍是 OpenClaw task submit 专用入口,并继续拒绝 `multiAgent=true`。 +multi-agent 只允许通过 `/acp` 或 `/acp/rpc` 进入;OpenClaw gateway 单任务同样使用 `/acp/rpc`,但不能与 `multiAgent=true` 混用。 统一结果字段: @@ -300,7 +297,7 @@ bridge 保证: } ``` -OpenClaw gateway 任务的 HTTP task submit 路径是 `/gateway/openclaw`,并且只用于 `session.start` 与同一 OpenClaw task 的 follow-up `session.message`。Bridge 会强制 routing 到 `gateway/openclaw`,并拒绝 `multiAgent=true` 或 agent/provider 冲突参数。 +OpenClaw gateway 任务的 HTTP task submit 路径是 `/acp/rpc`。请求必须在 routing 中声明 `explicitExecutionTarget=gateway` 与 `preferredGatewayProviderId=openclaw`;bridge 不再要求或暴露独立的 OpenClaw task URL。 ## 9. `session.cancel` / `session.close` @@ -327,7 +324,7 @@ gateway method family 保留为 control-plane contract: - app 调 gateway runtime 时仍然只通过 bridge JSON-RPC methods - `openclaw` 是 bridge-owned gateway provider,不是 app-facing direct route -- gateway control-plane method 仍走 `/acp` 或 `/acp/rpc`,不走 `/gateway/openclaw` +- gateway task 和 control-plane method 都走 `/acp` 或 `/acp/rpc` ## 11. Internal Async Jobs diff --git a/docs/architecture/acp-forwarding-topology.md b/docs/architecture/acp-forwarding-topology.md index edf3bb8..9fd174f 100644 --- a/docs/architecture/acp-forwarding-topology.md +++ b/docs/architecture/acp-forwarding-topology.md @@ -9,8 +9,7 @@ Last Updated: 2026-05-03 对 `xworkmate-app` 来说,bridge 只有一个 canonical surface: - `GET /acp` WebSocket,默认主链 -- `POST /acp/rpc`,CI、脚本、调试和兼容 fallback -- `POST /gateway/openclaw`,仅 OpenClaw `session.start` / follow-up `session.message` task submit +- `POST /acp/rpc`,CI、脚本、调试、兼容 fallback 和 OpenClaw gateway task submit app 只感知 method family: @@ -36,9 +35,8 @@ flowchart LR B4["xworkmate.routing.resolve"] B5["session.*"] B6["xworkmate.gateway.*"] - B7["POST /gateway/openclaw
OpenClaw task submit only"] - B8["provider_compat"] - B9["gateway compat"] + B7["provider_compat"] + B8["gateway compat"] end subgraph ADAPTERS["adapter runtime"] @@ -62,21 +60,20 @@ flowchart LR B2 --> B4 B2 --> B5 B2 --> B6 - B7 --> B5 - B5 --> B8 - B6 --> B9 - B8 --> C1 - B8 --> C2 - B8 --> C3 - B8 --> C4 - B9 --> D1 + B5 --> B7 + B6 --> B8 + B7 --> C1 + B7 --> C2 + B7 --> C3 + B7 --> C4 + B8 --> D1 ``` ## Invariants - app 不直接访问 provider-specific public URL -- app 只在 OpenClaw `session.start` / follow-up `session.message` 时使用 `/gateway/openclaw` -- app 不把 `/gateway/openclaw` 解析或保存为全局 ACP base endpoint +- app 的 OpenClaw `session.start` / follow-up `session.message` 也使用 `/acp/rpc` +- app 不保存或解析 provider/gateway 专用 URL - provider catalog 与 gatewayProviders 由 bridge 独占生成 - bridge 只暴露 canonical ACP contract - provider / gateway 实际地址属于 bridge internal truth diff --git a/docs/architecture/adr-refocus-bridge-as-control-plane.md b/docs/architecture/adr-refocus-bridge-as-control-plane.md index 2a2bfb8..afd2560 100644 --- a/docs/architecture/adr-refocus-bridge-as-control-plane.md +++ b/docs/architecture/adr-refocus-bridge-as-control-plane.md @@ -101,9 +101,8 @@ App-facing canonical transport 定义为: - multi-agent 作为 bridge core 路径 - 以 reverse proxy 为中心的 bridge 定位 -保留 `/gateway/openclaw` 的精确定义:它只是 OpenClaw `session.start` 与 follow-up -`session.message` 的 task submit endpoint,capabilities、routing、cancel、close 和 -gateway control-plane method 继续走 `/acp` 或 `/acp/rpc`。 +OpenClaw `session.start` 与 follow-up `session.message` 使用 `/acp/rpc` 加 routing metadata; +capabilities、routing、cancel、close 和 gateway control-plane method 也继续走 `/acp` 或 `/acp/rpc`。 ### 后续规则 diff --git a/docs/architecture/adr-unified-bridge-entrypoints.md b/docs/architecture/adr-unified-bridge-entrypoints.md index 6775488..6b399d9 100644 --- a/docs/architecture/adr-unified-bridge-entrypoints.md +++ b/docs/architecture/adr-unified-bridge-entrypoints.md @@ -34,7 +34,6 @@ Canonical app-facing contract families are: 1. ACP control-plane - `POST /acp/rpc` - `GET /acp` - - `POST /gateway/openclaw` only for OpenClaw `session.start` and follow-up `session.message` 2. Gateway runtime methods - `xworkmate.gateway.connect` - `xworkmate.gateway.request` @@ -62,10 +61,9 @@ The APP should not depend on provider-specific public URLs such as: - `/gemini/acp/rpc` - `/openclaw/` -The only OpenClaw-specific public path is `/gateway/openclaw`, and it is a -task submit endpoint rather than a global ACP base. Capabilities, routing, -gateway control-plane methods, cancel, and close remain on `/acp` or -`/acp/rpc`. +OpenClaw task submit now uses the same `/acp/rpc` HTTP surface with explicit +routing metadata. Capabilities, routing, gateway control-plane methods, cancel, +and close remain on `/acp` or `/acp/rpc`. If the bridge reports execution-target metadata such as `single-agent` or `gateway`, the app should treat those values as routing @@ -132,7 +130,7 @@ Upstream authentication is unified for both ACP and gateway routes: Use these terms consistently: - `canonical app-facing path`: `/acp/rpc` and `/acp` -- `OpenClaw task submit path`: `/gateway/openclaw` for `session.start` / `session.message` +- `OpenClaw task submit`: `/acp/rpc` with `routing.explicitExecutionTarget=gateway` and `routing.preferredGatewayProviderId=openclaw` - `gateway runtime method family`: `xworkmate.gateway.*` - `independent upstream service`: provider / gateway runtime behind bridge-owned compat - `bridge-owned routing`: provider / gateway selection performed inside bridge diff --git a/docs/backend-api-design.md b/docs/backend-api-design.md index 8264356..548a58e 100644 --- a/docs/backend-api-design.md +++ b/docs/backend-api-design.md @@ -28,10 +28,9 @@ xworkmate-app -> bridge 内部路由到 codex / opencode / gemini / hermes / openclaw ``` -例外:OpenClaw task submit 的 HTTP fallback 专用入口是 -`POST https://xworkmate-bridge.svc.plus/gateway/openclaw`。它只接受 -`session.start` 和同一任务生命周期内的 follow-up `session.message`,不得作为 -capabilities、routing、cancel、close 或其他 provider 的通用 ACP base endpoint。 +OpenClaw task submit 也使用 `/acp/rpc`,通过 +`routing.explicitExecutionTarget=gateway` 和 +`routing.preferredGatewayProviderId=openclaw` 表达 gateway 目标。 ## 2. App-Facing Contract @@ -220,15 +219,13 @@ Gateway/OpenClaw 显式路由示例: } ``` -OpenClaw gateway 任务的 HTTP task submit 专用入口是: +OpenClaw gateway 任务的 HTTP task submit 入口是: ```text -POST https://xworkmate-bridge.svc.plus/gateway/openclaw +POST https://xworkmate-bridge.svc.plus/acp/rpc ``` -它只承载 `session.start` 和 follow-up `session.message`。Bridge 会强制注入 -`explicitExecutionTarget=gateway` 与 `preferredGatewayProviderId=openclaw`,并拒绝 -`multiAgent=true`、agent/provider 冲突参数、`acp.capabilities`、`xworkmate.routing.resolve`、`session.cancel` 和 `session.close`。 +它承载 `session.start` 和 follow-up `session.message`,并由 routing 字段明确声明 OpenClaw gateway 目标。 ```json { @@ -250,7 +247,7 @@ POST https://xworkmate-bridge.svc.plus/gateway/openclaw ``` OpenClaw 的 `session.message` 复用同一 `sessionId` / `threadId`,继续提交到 -`/gateway/openclaw`。其他 provider 的 `session.message` 走 `/acp` 或 `/acp/rpc`。 +`/acp/rpc`。其他 provider 的 `session.message` 也走 `/acp` 或 `/acp/rpc`。 `session.cancel` 和 `session.close` 属于 control-plane 操作,继续走 `/acp` 或 `/acp/rpc`。 @@ -259,8 +256,7 @@ OpenClaw 的 `session.message` 复用同一 `sessionId` / `threadId`,继续提 | Path | 协议 | APP 是否使用 | 设计定位 | | --- | --- | --- | --- | | `/acp` | WebSocket | 是,默认 | JSON-RPC 主入口 | -| `/acp/rpc` | HTTP POST | 仅 fallback / CI / 调试 | JSON-RPC 辅助入口 | -| `/gateway/openclaw` | HTTP POST | 仅 OpenClaw task submit | 只接受 `session.start` / `session.message` | +| `/acp/rpc` | HTTP POST | fallback / CI / 调试 / OpenClaw task submit | JSON-RPC 辅助入口 | | `/api/ping` | HTTP GET | 否 | 发布与运行健康检查 | | `/` | HTTP GET | 否 | 简单运行状态 | | `/acp-server/*` | 无 APP contract | 否 | 线上 Caddy 显式返回 `404` | @@ -268,8 +264,8 @@ OpenClaw 的 `session.message` 复用同一 `sessionId` / `threadId`,继续提 陈旧接口清理规则: - 删除 APP 侧对 `/acp-server/codex`、`/acp-server/opencode`、`/acp-server/gemini`、`/acp-server/hermes` 的任何引用。 -- 只允许 APP 的 Gateway/OpenClaw `session.start` 与 follow-up `session.message` 使用 `/gateway/openclaw`。 -- 禁止把 `/gateway/openclaw` 保存或解析为全局 ACP base endpoint。 +- APP 的 Gateway/OpenClaw `session.start` 与 follow-up `session.message` 使用 `/acp/rpc` 和 routing metadata。 +- 禁止把 provider/gateway 专用 URL 保存或解析为全局 ACP base endpoint。 - 不在 APP 侧保存 provider/gateway URL、端口或 service 名。 - 不把 provider 选择逻辑散落在 APP 的 URL 拼接逻辑里。 - 所有 provider/gateway 能力与可用性都来自 `acp.capabilities`。 @@ -285,7 +281,7 @@ OpenClaw 的 `session.message` 复用同一 `sessionId` / `threadId`,继续提 ```text /api* -> 127.0.0.1:8787 /acp* -> 127.0.0.1:8787 -/gateway/openclaw -> 127.0.0.1:8787 +/artifacts/* -> 127.0.0.1:8787 /acp-server/* -> 404 / -> 127.0.0.1:8787 ``` @@ -321,11 +317,10 @@ Authorization: Bearer $BRIDGE_AUTH_TOKEN | WebSocket `/acp` 握手 | `101 Switching Protocols` | | WebSocket `acp.capabilities` | `ok=true`,返回 `agent/gateway`、`codex/opencode/gemini/hermes`、`openclaw` | | `POST /acp/rpc acp.capabilities` | `200`,返回同一能力目录 | -| `POST /gateway/openclaw session.start` | `200`,成功或 structured provider failure;不应是 route/auth failure | +| `POST /acp/rpc session.start` with OpenClaw routing | `200`,成功或 structured provider failure;不应是 route/auth failure | | `POST /acp-server/hermes` | `404` | | `POST /acp-server/codex` | `404` | | `POST /acp-server/gemini` | `404` | | `POST /acp-server/opencode` | `404` | -`/gateway/openclaw` 当前是专用 OpenClaw task submit contract,不是全局 ACP base endpoint。APP 的 -capabilities、routing、agent、multi-agent、cancel 和 close 必须继续使用 `/acp` 或 `/acp/rpc`。 +OpenClaw task submit 当前使用 `/acp/rpc` 加 routing metadata。APP 的 capabilities、routing、agent、multi-agent、cancel 和 close 也必须继续使用 `/acp` 或 `/acp/rpc`。 diff --git a/docs/internal-reference.md b/docs/internal-reference.md index a07d694..b24fee6 100644 --- a/docs/internal-reference.md +++ b/docs/internal-reference.md @@ -27,8 +27,7 @@ APP-facing ACP control plane。 负责: - `/acp` App-facing WebSocket JSON-RPC 主入口 -- `/acp/rpc` HTTP JSON-RPC fallback / CI / 调试入口 -- `/gateway/openclaw` OpenClaw task submit 专用入口,只接受 `session.start` 和 `session.message` +- `/acp/rpc` HTTP JSON-RPC fallback / CI / 调试 / OpenClaw task submit 入口 - JSON-RPC / hybrid envelope - `acp.capabilities` - `xworkmate.routing.resolve` @@ -106,10 +105,11 @@ APP-facing ACP control plane。 以下逻辑不属于当前 APP-facing contract: - `/acp-server/*` +- `/gateway/openclaw` - multi-agent 执行路径 - provider-specific alias handler -`/gateway/openclaw` 只保留为 OpenClaw task submit 专用 handler,不再作为 provider alias、gateway alias 或通用 ACP base endpoint。 +OpenClaw task submit 使用 `/acp/rpc` 和 routing metadata,不再保留独立 app-facing handler。 ## 3. `provider_compat` diff --git a/docs/testing/openclaw-skills-interactive-test-report-2026-05-01.md b/docs/testing/openclaw-skills-interactive-test-report-2026-05-01.md index 602a5c4..33fd3a8 100644 --- a/docs/testing/openclaw-skills-interactive-test-report-2026-05-01.md +++ b/docs/testing/openclaw-skills-interactive-test-report-2026-05-01.md @@ -1,15 +1,16 @@ # OpenClaw Skills Interactive Test Report - **Execution Time:** 2026-05-01 10:37:05 - **BRIDGE_SERVER_URL:** https://xworkmate-bridge.svc.plus -- **Gateway Path:** /gateway/openclaw +- **Gateway Path:** /acp/rpc with explicit OpenClaw gateway routing - **Token:** [REDACTED] - **Test Case File:** /home/ubuntu/.openclaw/workspace/skills-test-cases.md - **Runtime Status:** Active (openclaw-gateway.service active) -> Historical note: this run submitted OpenClaw work through `/acp/rpc`. The -> current APP contract uses `/gateway/openclaw` for OpenClaw `session.start` -> and follow-up `session.message`, while capabilities, routing, cancel, and -> close stay on `/acp/rpc` or `/acp`. +> Historical note: older diagnostics used `/gateway/openclaw` before the +> task-submit contract was finalized. Current app-facing OpenClaw work uses +> `/acp/rpc` for `session.start` and follow-up `session.message`, with +> `routing.explicitExecutionTarget=gateway` and +> `routing.preferredGatewayProviderId=openclaw`. ## Summary | skill | total | pass | fail | blocked | notes | @@ -107,7 +108,7 @@ - **video-translator**: Needs translation service API key. - **browser-automation**: Needs local chrome or Browserbase API key. ### Gateway Path Verification -- Historical run observed `/gateway/openclaw` with curl before the task-submit contract was finalized. +- Historical run observed a direct OpenClaw gateway path with curl before the task-submit contract was finalized. - Actual interaction in this report was successful via `/acp/rpc` with `explicitExecutionTarget: gateway`. -- Current contract sends OpenClaw `session.start` and follow-up `session.message` to `/gateway/openclaw`. +- Current contract sends OpenClaw `session.start` and follow-up `session.message` to `/acp/rpc` with explicit OpenClaw routing metadata. - Bearer authentication is correctly enforced at the bridge level. diff --git a/internal/acp/http_handler.go b/internal/acp/http_handler.go index 21f0d90..16bbd48 100644 --- a/internal/acp/http_handler.go +++ b/internal/acp/http_handler.go @@ -51,10 +51,6 @@ func (s *Server) Handler() http.Handler { case openClawArtifactDownloadPath: s.HandleOpenClawArtifactDownload(w, r) default: - if r.URL.Path == "/gateway/openclaw" { - s.HandleOpenClawGatewayRPC(w, r) - return - } if strings.HasPrefix(r.URL.Path, "/acp-server/") { s.HandleDisabledProviderDirectPath(w, r) return @@ -115,11 +111,7 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) { } func (s *Server) HandleRPC(w http.ResponseWriter, r *http.Request) { - s.handleRPCWithTransform(w, r, rejectOpenClawTaskSubmitOnCanonicalRPC) -} - -func (s *Server) HandleOpenClawGatewayRPC(w http.ResponseWriter, r *http.Request) { - s.handleRPCWithTransform(w, r, forceOpenClawGatewayRequest) + s.handleRPCWithTransform(w, r, nil) } func (s *Server) HandleDisabledProviderDirectPath(w http.ResponseWriter, r *http.Request) { @@ -193,6 +185,9 @@ func (s *Server) handleRPCWithTransform( accept := strings.ToLower(r.Header.Get("Accept")) stream := strings.Contains(accept, "text/event-stream") + openClawGatewayTask := requestUsesOpenClawGatewaySubmit( + shared.AsMap(request.Params), + ) if stream { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") @@ -211,7 +206,7 @@ func (s *Server) handleRPCWithTransform( if !stream { return } - if r.URL.Path == "/gateway/openclaw" { + if openClawGatewayTask { if reason := openClawGatewayNotificationDropReason(message); reason != "" { log.Printf( "level=warn component=acp_sse event=notification_dropped path=%q rpcMethod=%q requestId=%q sessionId=%q threadId=%q reason=%q notificationMethod=%q", @@ -229,7 +224,7 @@ func (s *Server) handleRPCWithTransform( streamWriter.write(message) } if stream { - if r.URL.Path == "/gateway/openclaw" { + if openClawGatewayTask { streamWriter.write(map[string]any{ "jsonrpc": "2.0", "method": "xworkmate.bridge.accepted", @@ -266,7 +261,7 @@ func (s *Server) handleRPCWithTransform( _ = json.NewEncoder(w).Encode(envelope) return } - if r.URL.Path == "/gateway/openclaw" { + if openClawGatewayTask { stripOpenClawArtifactInlineContent(response) } if stream { @@ -444,77 +439,6 @@ func sseEventType(payload map[string]any) string { return "unknown" } -func forceOpenClawGatewayRequest(request shared.RPCRequest) (shared.RPCRequest, *shared.RPCError) { - method := strings.TrimSpace(request.Method) - switch method { - case "session.start", "session.message": - default: - return request, &shared.RPCError{Code: -32601, Message: "OPENCLAW_GATEWAY_METHOD_NOT_ALLOWED: " + method} - } - params := shared.AsMap(request.Params) - if params == nil { - params = map[string]any{} - } - if parseBool(params["multiAgent"]) || strings.EqualFold(strings.TrimSpace(shared.StringArg(params, "mode", "")), "multi-agent") { - return request, &shared.RPCError{Code: -32602, Message: "OPENCLAW_GATEWAY_CONFLICT: multiAgent is not supported on /gateway/openclaw"} - } - if provider := strings.TrimSpace(shared.StringArg(params, "provider", "")); provider != "" { - return request, &shared.RPCError{Code: -32602, Message: "OPENCLAW_GATEWAY_CONFLICT: provider must not be set on /gateway/openclaw"} - } - for _, key := range []string{"executionTarget", "requestedExecutionTarget"} { - if target := strings.TrimSpace(shared.StringArg(params, key, "")); target != "" && !strings.EqualFold(target, "gateway") { - return request, &shared.RPCError{Code: -32602, Message: "OPENCLAW_GATEWAY_CONFLICT: " + key + " must be gateway"} - } - } - for _, key := range []string{"preferredGatewayProviderId", "gatewayProviderId", "gatewayProvider"} { - if provider := strings.TrimSpace(shared.StringArg(params, key, "")); provider != "" && !strings.EqualFold(provider, "openclaw") { - return request, &shared.RPCError{Code: -32602, Message: "OPENCLAW_GATEWAY_CONFLICT: gateway provider must be openclaw"} - } - } - routing := shared.AsMap(params["routing"]) - if routing == nil { - routing = map[string]any{} - } - if strings.TrimSpace(shared.StringArg(routing, "orchestrationMode", "")) != "" { - return request, &shared.RPCError{Code: -32602, Message: "OPENCLAW_GATEWAY_CONFLICT: multiAgent is not supported on /gateway/openclaw"} - } - if provider := strings.TrimSpace(shared.StringArg(routing, "explicitProviderId", "")); provider != "" { - return request, &shared.RPCError{Code: -32602, Message: "OPENCLAW_GATEWAY_CONFLICT: explicitProviderId must not be set on /gateway/openclaw"} - } - if target := strings.TrimSpace(shared.StringArg(routing, "explicitExecutionTarget", "")); target != "" && !strings.EqualFold(target, "gateway") { - return request, &shared.RPCError{Code: -32602, Message: "OPENCLAW_GATEWAY_CONFLICT: explicitExecutionTarget must be gateway"} - } - for _, key := range []string{"preferredGatewayProviderId", "gatewayProviderId", "gatewayProvider"} { - if provider := strings.TrimSpace(shared.StringArg(routing, key, "")); provider != "" && !strings.EqualFold(provider, "openclaw") { - return request, &shared.RPCError{Code: -32602, Message: "OPENCLAW_GATEWAY_CONFLICT: gateway provider must be openclaw"} - } - } - routing["routingMode"] = "explicit" - routing["explicitExecutionTarget"] = "gateway" - routing["preferredGatewayProviderId"] = "openclaw" - delete(routing, "explicitProviderId") - params["routing"] = routing - params["requestedExecutionTarget"] = "gateway" - params["executionTarget"] = "gateway" - request.Params = params - return request, nil -} - -func rejectOpenClawTaskSubmitOnCanonicalRPC(request shared.RPCRequest) (shared.RPCRequest, *shared.RPCError) { - method := strings.TrimSpace(request.Method) - if method != "session.start" && method != "session.message" { - return request, nil - } - params := shared.AsMap(request.Params) - if parseBool(params["multiAgent"]) || strings.EqualFold(strings.TrimSpace(shared.StringArg(params, "mode", "")), "multi-agent") { - return request, nil - } - if requestUsesOpenClawGatewaySubmit(params) { - return request, &shared.RPCError{Code: -32602, Message: "OPENCLAW_TASK_ENDPOINT_REQUIRED: use /gateway/openclaw for OpenClaw task submission"} - } - return request, nil -} - func requestUsesOpenClawGatewaySubmit(params map[string]any) bool { if len(params) == 0 { return false diff --git a/internal/acp/web_contract_test.go b/internal/acp/web_contract_test.go index 411cd43..0b88a11 100644 --- a/internal/acp/web_contract_test.go +++ b/internal/acp/web_contract_test.go @@ -101,50 +101,6 @@ func TestHTTPHandlerProviderDirectPathRequiresAuthorization(t *testing.T) { } } -func TestHTTPHandlerGatewayOpenClawRequiresAuthorization(t *testing.T) { - t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token") - t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml") - server := NewServer() - handler := server.Handler() - - recorder := httptest.NewRecorder() - request := httptest.NewRequest( - http.MethodPost, - "http://127.0.0.1/gateway/openclaw", - strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"sessionId":"test"}}`), - ) - request.Header.Set("Content-Type", "application/json") - handler.ServeHTTP(recorder, request) - - if recorder.Code != http.StatusUnauthorized { - t.Fatalf("expected 401, got %d", recorder.Code) - } -} - -func TestHTTPHandlerGatewayOpenClawRejectsNonSessionMethods(t *testing.T) { - t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token") - t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml") - server := NewServer() - handler := server.Handler() - - recorder := httptest.NewRecorder() - request := httptest.NewRequest( - http.MethodPost, - "http://127.0.0.1/gateway/openclaw", - strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"acp.capabilities","params":{}}`), - ) - request.Header.Set("Content-Type", "application/json") - request.Header.Set("Authorization", "Bearer bridge-test-token") - handler.ServeHTTP(recorder, request) - - if recorder.Code != http.StatusOK { - t.Fatalf("expected JSON-RPC 200, got %d", recorder.Code) - } - if !strings.Contains(recorder.Body.String(), "OPENCLAW_GATEWAY_METHOD_NOT_ALLOWED") { - t.Fatalf("expected method allowlist error, got %q", recorder.Body.String()) - } -} - func TestHTTPHandlerRPCSSEWritesFinalEnvelopeAndDone(t *testing.T) { t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token") t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml") @@ -210,8 +166,8 @@ func TestHTTPHandlerGatewayOpenClawSSEKeepaliveBeforeFinalEnvelopeAndDone(t *tes request, err := http.NewRequest( http.MethodPost, - httpServer.URL+"/gateway/openclaw", - strings.NewReader(`{"jsonrpc":"2.0","id":"task-keepalive","method":"session.start","params":{"sessionId":"s1","threadId":"t1","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`"}}`), + httpServer.URL+"/acp/rpc", + strings.NewReader(`{"jsonrpc":"2.0","id":"task-keepalive","method":"session.start","params":{"sessionId":"s1","threadId":"t1","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`), ) if err != nil { t.Fatalf("build request: %v", err) @@ -307,8 +263,8 @@ func TestHTTPHandlerGatewayOpenClawAdmissionQueuesExcessConcurrentSSE(t *testing <-start request, err := http.NewRequest( http.MethodPost, - httpServer.URL+"/gateway/openclaw", - strings.NewReader(`{"jsonrpc":"2.0","id":"task-`+strconv.Itoa(index)+`","method":"session.start","params":{"sessionId":"s`+strconv.Itoa(index)+`","threadId":"t`+strconv.Itoa(index)+`","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`"}}`), + httpServer.URL+"/acp/rpc", + strings.NewReader(`{"jsonrpc":"2.0","id":"task-`+strconv.Itoa(index)+`","method":"session.start","params":{"sessionId":"s`+strconv.Itoa(index)+`","threadId":"t`+strconv.Itoa(index)+`","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`), ) if err != nil { results <- result{err: err} @@ -385,8 +341,8 @@ func TestHTTPHandlerGatewayOpenClawAdmissionRejectsWhenQueueFull(t *testing.T) { firstRequest, err := http.NewRequest( http.MethodPost, - httpServer.URL+"/gateway/openclaw", - strings.NewReader(`{"jsonrpc":"2.0","id":"task-active","method":"session.start","params":{"sessionId":"active","threadId":"active","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`"}}`), + httpServer.URL+"/acp/rpc", + strings.NewReader(`{"jsonrpc":"2.0","id":"task-active","method":"session.start","params":{"sessionId":"active","threadId":"active","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`), ) if err != nil { t.Fatalf("build first request: %v", err) @@ -409,8 +365,8 @@ func TestHTTPHandlerGatewayOpenClawAdmissionRejectsWhenQueueFull(t *testing.T) { secondRequest, err := http.NewRequest( http.MethodPost, - httpServer.URL+"/gateway/openclaw", - strings.NewReader(`{"jsonrpc":"2.0","id":"task-rejected","method":"session.start","params":{"sessionId":"rejected","threadId":"rejected","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`"}}`), + httpServer.URL+"/acp/rpc", + strings.NewReader(`{"jsonrpc":"2.0","id":"task-rejected","method":"session.start","params":{"sessionId":"rejected","threadId":"rejected","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`), ) if err != nil { t.Fatalf("build second request: %v", err) @@ -457,8 +413,8 @@ func TestHTTPHandlerGatewayOpenClawFiltersRawGatewayEventsAndKeepsFinalResult(t request, err := http.NewRequest( http.MethodPost, - httpServer.URL+"/gateway/openclaw", - strings.NewReader(`{"jsonrpc":"2.0","id":"task-filter","method":"session.start","params":{"sessionId":"session-filter","threadId":"thread-filter","taskPrompt":"make artifact","workingDirectory":"`+t.TempDir()+`"}}`), + httpServer.URL+"/acp/rpc", + strings.NewReader(`{"jsonrpc":"2.0","id":"task-filter","method":"session.start","params":{"sessionId":"session-filter","threadId":"thread-filter","taskPrompt":"make artifact","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`), ) if err != nil { t.Fatalf("build request: %v", err) @@ -566,113 +522,6 @@ func TestHTTPHandlerGatewayOpenClawFiltersRawGatewayEventsAndKeepsFinalResult(t } } -func TestHTTPHandlerGatewayOpenClawAllowsOnlyTaskSubmitMethods(t *testing.T) { - t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token") - t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml") - server := NewServer() - handler := server.Handler() - - for _, method := range []string{"session.cancel", "session.close", "xworkmate.routing.resolve", "xworkmate.gateway.request"} { - recorder := httptest.NewRecorder() - request := httptest.NewRequest( - http.MethodPost, - "http://127.0.0.1/gateway/openclaw", - strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"`+method+`","params":{}}`), - ) - request.Header.Set("Content-Type", "application/json") - request.Header.Set("Authorization", "Bearer bridge-test-token") - handler.ServeHTTP(recorder, request) - - if recorder.Code != http.StatusOK { - t.Fatalf("%s: expected JSON-RPC 200, got %d", method, recorder.Code) - } - if !strings.Contains(recorder.Body.String(), "OPENCLAW_GATEWAY_METHOD_NOT_ALLOWED") { - t.Fatalf("%s: expected method allowlist error, got %q", method, recorder.Body.String()) - } - } -} - -func TestHTTPHandlerGatewayOpenClawRejectsConflictingRouting(t *testing.T) { - t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token") - t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml") - server := NewServer() - handler := server.Handler() - - for _, payload := range []string{ - `{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"multiAgent":true}}`, - `{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"mode":"multi-agent"}}`, - `{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"provider":"codex"}}`, - `{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"executionTarget":"agent"}}`, - `{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"gatewayProviderId":"other"}}`, - `{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"routing":{"orchestrationMode":"sequence"}}}`, - `{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"routing":{"explicitProviderId":"codex"}}}`, - `{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"routing":{"explicitExecutionTarget":"agent"}}}`, - `{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"routing":{"preferredGatewayProviderId":"other"}}}`, - } { - recorder := httptest.NewRecorder() - request := httptest.NewRequest( - http.MethodPost, - "http://127.0.0.1/gateway/openclaw", - strings.NewReader(payload), - ) - request.Header.Set("Content-Type", "application/json") - request.Header.Set("Authorization", "Bearer bridge-test-token") - handler.ServeHTTP(recorder, request) - - if recorder.Code != http.StatusOK { - t.Fatalf("expected JSON-RPC 200, got %d", recorder.Code) - } - if !strings.Contains(recorder.Body.String(), "OPENCLAW_GATEWAY_CONFLICT") { - t.Fatalf("expected conflict error, got %q", recorder.Body.String()) - } - } -} - -func TestHTTPHandlerCanonicalRPCRejectsOpenClawTaskSubmit(t *testing.T) { - t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token") - t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml") - server := NewServer() - handler := server.Handler() - - recorder := httptest.NewRecorder() - request := httptest.NewRequest( - http.MethodPost, - "http://127.0.0.1/acp/rpc", - strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"routing":{"explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`), - ) - request.Header.Set("Content-Type", "application/json") - request.Header.Set("Authorization", "Bearer bridge-test-token") - handler.ServeHTTP(recorder, request) - - if recorder.Code != http.StatusOK { - t.Fatalf("expected JSON-RPC 200, got %d", recorder.Code) - } - if !strings.Contains(recorder.Body.String(), "OPENCLAW_TASK_ENDPOINT_REQUIRED") { - t.Fatalf("expected dedicated endpoint error, got %q", recorder.Body.String()) - } -} - -func TestCanonicalRPCAllowsAgentTaskWithPreferredGatewayMetadata(t *testing.T) { - request := shared.RPCRequest{ - Method: "session.start", - Params: map[string]any{ - "provider": "codex", - "requestedExecutionTarget": "agent", - "routing": map[string]any{ - "routingMode": "explicit", - "explicitExecutionTarget": "agent", - "explicitProviderId": "codex", - "preferredGatewayProviderId": "openclaw", - }, - }, - } - - _, rpcErr := rejectOpenClawTaskSubmitOnCanonicalRPC(request) - if rpcErr != nil { - t.Fatalf("expected agent task to stay on /acp/rpc, got %#v", rpcErr) - } -} - func TestHTTPHandlerGatewayOpenClawForcesGatewayRouting(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) defer gateway.Close() @@ -686,8 +535,8 @@ func TestHTTPHandlerGatewayOpenClawForcesGatewayRouting(t *testing.T) { recorder := httptest.NewRecorder() request := httptest.NewRequest( http.MethodPost, - "http://127.0.0.1/gateway/openclaw", - strings.NewReader(`{"jsonrpc":"2.0","id":"task-1","method":"session.start","params":{"sessionId":"s1","threadId":"t1","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`"}}`), + "http://127.0.0.1/acp/rpc", + strings.NewReader(`{"jsonrpc":"2.0","id":"task-1","method":"session.start","params":{"sessionId":"s1","threadId":"t1","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`), ) request.Header.Set("Content-Type", "application/json") request.Header.Set("Authorization", "Bearer bridge-test-token") @@ -720,8 +569,8 @@ func TestHTTPHandlerSessionGetReturnsCompletedOpenClawResult(t *testing.T) { startRecorder := httptest.NewRecorder() startRequest := httptest.NewRequest( http.MethodPost, - "http://127.0.0.1/gateway/openclaw", - strings.NewReader(`{"jsonrpc":"2.0","id":"task-1","method":"session.start","params":{"sessionId":"s1","threadId":"t1","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`"}}`), + "http://127.0.0.1/acp/rpc", + strings.NewReader(`{"jsonrpc":"2.0","id":"task-1","method":"session.start","params":{"sessionId":"s1","threadId":"t1","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`), ) startRequest.Header.Set("Content-Type", "application/json") startRequest.Header.Set("Authorization", "Bearer bridge-test-token")