Route OpenClaw tasks through ACP RPC
This commit is contained in:
parent
07d07637f3
commit
3b088f71e2
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`。
|
||||
|
||||
### 后续规则
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`。
|
||||
|
||||
@ -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`
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user