Route OpenClaw tasks through ACP RPC

This commit is contained in:
Haitao Pan 2026-05-26 11:06:22 +08:00
parent 07d07637f3
commit 3b088f71e2
10 changed files with 71 additions and 311 deletions

View File

@ -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

View File

@ -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 contractAPP 不应保存或拼接这些 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

View File

@ -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<br/>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

View File

@ -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 endpointcapabilities、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`
### 后续规则

View File

@ -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

View File

@ -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`

View File

@ -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`

View File

@ -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.

View File

@ -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

View File

@ -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")