fix: enforce openclaw gateway task route

This commit is contained in:
Haitao Pan 2026-05-03 12:14:31 +08:00
parent 2f5e0a3fa1
commit fac0a0857a
15 changed files with 730 additions and 47 deletions

View File

@ -13,8 +13,12 @@
This repository exposes one APP-facing bridge entrypoint and proxies traffic
to four independent upstream production services. The APP-facing canonical ACP
paths remain `/acp/rpc` and `/acp` under
path is WebSocket `/acp`; HTTP `/acp/rpc` remains available for CI, scripts,
debugging, and compatibility fallback under
`https://xworkmate-bridge.svc.plus`.
OpenClaw task submission is the only dedicated HTTP task route:
`POST /gateway/openclaw` for `session.start` and follow-up `session.message`.
It is not a global ACP base endpoint.
Architecture topology: [docs/architecture/acp-forwarding-topology.md](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-bridge/docs/architecture/acp-forwarding-topology.md)
@ -24,6 +28,8 @@ Example provider sync config: [example/config.yaml](/Users/shenlan/workspaces/cl
API reference: [docs/api-reference.md](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-bridge/docs/api-reference.md)
Backend API design: [docs/backend-api-design.md](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-bridge/docs/backend-api-design.md)
## Compatibility
For compatibility with `xworkmate-app`, the built helper binary name remains `xworkmate-go-core`.
@ -66,7 +72,7 @@ contract:
- bridge root and `/api/ping`
- strict image / tag / commit / version match against the built image ref
- upstream ACP capability probes for `codex`, `opencode`, and `gemini`
- minimal `session.start` smoke tests through `https://xworkmate-bridge.svc.plus/acp/rpc`
- minimal `session.start` smoke tests through the bridge JSON-RPC contract
Required GitHub secrets:

View File

@ -2,6 +2,10 @@
Last Updated: 2026-04-23
> Historical validation note. Current APP-facing design is WebSocket-first and
> is defined in [Backend API Design](./backend-api-design.md). Do not use this
> older note as the source of truth for xworkmate-app routing.
本文件记录当前推荐的 public validation 方法。
## Canonical Validation Targets
@ -11,11 +15,11 @@ 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
不再把以下路径视为 public validation target
- `/acp-server/*`
- `/gateway/openclaw`
## Validation Checklist
@ -54,6 +58,9 @@ Last Updated: 2026-04-23
- 中间通知使用 `session.update`
- 最终结果包含 `turnId`
OpenClaw 的 `session.start` 与同一 task 的 follow-up `session.message` 使用
`/gateway/openclaw`。`session.cancel` 与 `session.close` 仍使用 `/acp``/acp/rpc`
### 5. Gateway Contract
验证:
@ -64,6 +71,6 @@ Last Updated: 2026-04-23
## Notes
- App-facing canonical HTTP transport 是 `POST /acp/rpc`
- `GET /acp` WebSocket 仅作为 ACP transport variant
- App-facing canonical transport 是 `GET /acp` WebSocket upgrade
- `POST /acp/rpc` 仅作为 CI、脚本、调试和兼容 fallback
- app-facing contract 由 bridge control plane 独占,不由 provider alias path 定义

View File

@ -5,9 +5,10 @@
当前定位:
- `xworkmate-bridge` 是 **APP-facing ACP control plane and provider runtime layer**
- App-facing canonical HTTP transport 是 `POST /acp/rpc`
- `GET /acp` WebSocket 仅保留为 ACP transport variant
- `/acp-server/*`、`/gateway/openclaw` 不再是 public API也不再提供 alias handler
- 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
- `/acp-server/*` 不属于 APP-facing contractAPP 不应保存或拼接这些 provider direct path
## 1. Runtime Entry Points
@ -29,11 +30,12 @@
| --- | --- | --- | --- |
| `/` | `GET` | 否 | 纯文本运行状态 |
| `/api/ping` | `GET` | 是 | 发布版本探针 |
| `/acp` | `GET` + WebSocket upgrade | 是 | JSON-RPC WebSocket transport |
| `/acp/rpc` | `POST` | 是 | App-facing JSON-RPC HTTP transport |
| `/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 专用入口 |
其他路径返回 `404 Not Found`。
线上 Caddy 反代 `/api*`、`/acp*`、`/gateway/openclaw` 和 `/` 到 bridge origin。`/acp-server/*` 显式返回 `404`。
## 3. Auth / Origin
@ -49,6 +51,17 @@
- `/api/ping`、`/acp`、`/acp/rpc` 在 `BRIDGE_AUTH_TOKEN` 非空时都要求 bearer header
- `BRIDGE_AUTH_TOKEN` 为空时默认放行
- `BRIDGE_AUTH_TOKEN` 非空时,接受裸 token 或 `Bearer <token>`
- `xworkmate-app` 生产 Origin 固定为 `https://xworkmate.svc.plus`
推荐 APP 配置:
```text
BRIDGE_SERVER_URL=https://xworkmate-bridge.svc.plus
BRIDGE_WS_URL=wss://xworkmate-bridge.svc.plus/acp
BRIDGE_HTTP_RPC_URL=https://xworkmate-bridge.svc.plus/acp/rpc
Authorization: Bearer $BRIDGE_AUTH_TOKEN
Origin: https://xworkmate.svc.plus
```
错误行为:
@ -125,6 +138,12 @@ bridge 对 app 的稳定 method family 只有:
- `xworkmate.gateway.request`
- `xworkmate.gateway.disconnect`
路径约束:
- `/acp/rpc` 是 capabilities、routing、agent、multi-agent、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`
## 6. `acp.capabilities`
用途:返回 bridge-owned provider catalog、gatewayProviders、availableExecutionTargets。
@ -138,7 +157,7 @@ bridge 对 app 的稳定 method family 只有:
"availableExecutionTargets": ["agent", "gateway"],
"providerCatalog": [
{ "providerId": "codex", "label": "Codex", "targets": ["agent"], "category": "native" },
{ "providerId": "opencode", "label": "OpenCode", "targets": ["agent"], "category": "protocol-adapter" },
{ "providerId": "opencode", "label": "OpenCode", "targets": ["agent"], "category": "native" },
{ "providerId": "gemini", "label": "Gemini", "targets": ["agent"], "category": "protocol-adapter" },
{ "providerId": "hermes", "label": "Hermes", "targets": ["agent"], "category": "protocol-adapter" }
],
@ -155,6 +174,7 @@ bridge 对 app 的稳定 method family 只有:
- app 只能从这里获取 `providerCatalog`、`gatewayProviders`、`availableExecutionTargets`
- app 不应依赖 bridge 内部 provider URL / 端口 / service 名
- app 不保存 `codex`、`opencode`、`gemini`、`hermes`、`openclaw` 的 URL
## 7. `xworkmate.routing.resolve`
@ -248,6 +268,8 @@ bridge 保证:
- bridge core 不暴露 stdio/runtime 细节
- 中间通知统一通过 `session.update`
OpenClaw gateway 任务的 HTTP task submit 路径是 `/gateway/openclaw`,并且只用于 `session.start` 与同一 OpenClaw task 的 follow-up `session.message`。Bridge 会强制 routing 到 `gateway/openclaw`,并拒绝 `multiAgent=true` 或 agent/provider 冲突参数。
## 9. `session.cancel` / `session.close`
返回:
@ -273,6 +295,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`
## 11. 非 Contract 内容
@ -283,3 +306,7 @@ gateway method family 保留为 control-plane contract
- systemd service 名
- stdio framing / process lifecycle / stderr / restart 语义
- bridge 内部 compat/runtime 实现细节
- `/acp-server/codex`
- `/acp-server/opencode`
- `/acp-server/gemini`
- `/acp-server/hermes`

View File

@ -1,6 +1,6 @@
# ACP Forwarding Topology
Last Updated: 2026-04-23
Last Updated: 2026-05-03
本文档只描述当前保留的 canonical topology。
@ -8,8 +8,9 @@ Last Updated: 2026-04-23
`xworkmate-app` 来说bridge 只有一个 canonical surface
- `GET /acp` WebSocket
- `POST /acp/rpc`
- `GET /acp` WebSocket默认主链
- `POST /acp/rpc`CI、脚本、调试和兼容 fallback
- `POST /gateway/openclaw`,仅 OpenClaw `session.start` / follow-up `session.message` task submit
app 只感知 method family
@ -30,13 +31,14 @@ flowchart LR
subgraph BRIDGE["xworkmate-bridge"]
B1["GET /acp<br/>JSON-RPC over WebSocket"]
B2["POST /acp/rpc<br/>App-facing HTTP RPC"]
B2["POST /acp/rpc<br/>HTTP fallback / CI"]
B3["acp.capabilities"]
B4["xworkmate.routing.resolve"]
B5["session.*"]
B6["xworkmate.gateway.*"]
B7["provider_compat"]
B8["gateway compat"]
B7["POST /gateway/openclaw<br/>OpenClaw task submit only"]
B8["provider_compat"]
B9["gateway compat"]
end
subgraph ADAPTERS["adapter runtime"]
@ -60,19 +62,21 @@ flowchart LR
B2 --> B4
B2 --> B5
B2 --> B6
B5 --> B7
B6 --> B8
B7 --> C1
B7 --> C2
B7 --> C3
B7 --> C4
B8 --> D1
B7 --> B5
B5 --> B8
B6 --> B9
B8 --> C1
B8 --> C2
B8 --> C3
B8 --> C4
B9 --> D1
```
## Invariants
- app 不直接访问 provider-specific public URL
- app 不直接访问 openclaw public URL
- app 只在 OpenClaw `session.start` / follow-up `session.message` 时使用 `/gateway/openclaw`
- app 不把 `/gateway/openclaw` 解析或保存为全局 ACP base endpoint
- provider catalog 与 gatewayProviders 由 bridge 独占生成
- bridge 只暴露 canonical ACP contract
- provider / gateway 实际地址属于 bridge internal truth

View File

@ -53,7 +53,7 @@ app 只能通过 `acp.capabilities` 获取:
app 不应依赖:
- `/acp-server/*`
- `/gateway/openclaw`
- `/gateway/openclaw` 作为全局 ACP base endpoint
- 本地端口
- systemd service 名
@ -77,13 +77,13 @@ bridge 继续保留:
provider-specific 逻辑只能存在于 compat layer不得污染公共 handler。
### 5. App-facing HTTP RPC
### 5. App-facing WebSocket RPC
App-facing canonical transport 定义为:
- `POST /acp/rpc`
- `GET /acp` WebSocket upgrade
`GET /acp` WebSocket 作为 ACP transport variant 保留,但不主导 App 运行时路由。
`POST /acp/rpc` 作为 HTTP JSON-RPC fallback 保留,用于 CI、脚本、调试和兼容场景不主导 App 运行时路由。
## Consequences
@ -97,10 +97,14 @@ App-facing canonical transport 定义为:
### 有意删除
- `/acp-server/*` public alias
- `/gateway/openclaw` public alias
- `/gateway/openclaw` 作为 provider/gateway public alias 或全局 ACP base
- 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`
### 后续规则
新增 JSON-RPC over stdio provider 时:

View File

@ -34,6 +34,7 @@ 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`
@ -61,6 +62,11 @@ 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`.
If the bridge reports execution-target metadata such as `single-agent`
or `gateway`, the app should treat those values as routing
results, not as shell-level surface categories.
@ -126,6 +132,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`
- `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

@ -56,13 +56,13 @@ APISIX / Caddy 这类外层网关负责:
bridge 当前采用:
- App-facing canonical HTTP transport: `POST /acp/rpc`
- ACP WebSocket transport variant: `GET /acp`
- App-facing canonical transport: `GET /acp` WebSocket upgrade
- HTTP JSON-RPC fallback: `POST /acp/rpc`
设计含义:
- HTTP RPC 是 App 运行时主链
- WS 作为 ACP transport variant不反向塑造 App 路由
- WebSocket 是 App 运行时主链
- HTTP RPC 只用于 CI、脚本、调试和兼容 fallback
- provider / gateway alias route 不再属于 public surface
## 4. 模块边界
@ -121,7 +121,7 @@ bridge 当前采用:
## 5. 数据流
1. app 通过 `/acp``/acp/rpc` 发送 JSON-RPC request
1. app 默认通过 `/acp` WebSocket 发送 JSON-RPC request
2. bridge contract decode request
3. `acp.capabilities` 从 catalog 读取 bridge-owned truth
4. `xworkmate.routing.resolve` 由 routing engine 计算

331
docs/backend-api-design.md Normal file
View File

@ -0,0 +1,331 @@
# XWorkmate Bridge Backend API Design
Last verified: 2026-05-03
本文档定义 `xworkmate-bridge``xworkmate-app` 提供后端服务时的最佳接口设计。当前设计以线上环境为准,并把 WebSocket 作为默认运行时协议。
## 1. 设计目标
`xworkmate-app` 只连接 `xworkmate-bridge`,不保存也不拼接任何 provider 或 gateway 的内部地址。
APP 侧只需要保存:
```text
BRIDGE_SERVER_URL=https://xworkmate-bridge.svc.plus
BRIDGE_WS_URL=wss://xworkmate-bridge.svc.plus/acp
BRIDGE_HTTP_RPC_URL=https://xworkmate-bridge.svc.plus/acp/rpc
BRIDGE_AUTH_TOKEN=<provided by account/profile sync>
```
APP 所有能力发现、路由决策和任务执行都通过 bridge 的 JSON-RPC contract 完成:
```text
xworkmate-app
-> wss://xworkmate-bridge.svc.plus/acp
-> acp.capabilities
-> xworkmate.routing.resolve
-> session.start / session.message / session.cancel / session.close
-> 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。
## 2. App-Facing Contract
### 2.1 默认传输
默认传输是 JSON-RPC over WebSocket
```text
wss://xworkmate-bridge.svc.plus/acp
```
WebSocket 握手必须带:
```http
Authorization: Bearer $BRIDGE_AUTH_TOKEN
Origin: https://xworkmate.svc.plus
```
连接建立后,每个消息都是 JSON-RPC 2.0 frame。
### 2.2 HTTP RPC
HTTP JSON-RPC 仅用于 CI、脚本、调试、诊断和兼容 fallback。除 OpenClaw task submit 之外canonical HTTP RPC 是:
```text
POST https://xworkmate-bridge.svc.plus/acp/rpc
```
请求头同样必须带:
```http
Authorization: Bearer $BRIDGE_AUTH_TOKEN
Origin: https://xworkmate.svc.plus
Content-Type: application/json
```
### 2.3 Health
发布和运行验证使用:
```text
GET https://xworkmate-bridge.svc.plus/api/ping
```
该接口用于验证运行中的 bridge 镜像、tag、commit、version 和状态,不参与 APP 任务路由。
## 3. JSON-RPC 方法
APP-facing 稳定方法只有以下几组。
| Method | 用途 |
| --- | --- |
| `acp.capabilities` | 获取 bridge 当前能力目录 |
| `xworkmate.routing.resolve` | 让 bridge 计算路由,不执行任务 |
| `session.start` | 启动一个任务或首轮对话 |
| `session.message` | 同 session 继续追问 |
| `session.cancel` | 取消当前 session |
| `session.close` | 关闭 session 并释放 bridge 内部状态 |
| `xworkmate.gateway.connect` | gateway runtime 控制面连接 |
| `xworkmate.gateway.request` | gateway runtime 控制面请求 |
| `xworkmate.gateway.disconnect` | gateway runtime 控制面断开 |
APP 不应调用 provider-specific URL。Provider 与 gateway 只能来自 `acp.capabilities` 的返回值。
## 4. 能力发现
请求:
```json
{
"jsonrpc": "2.0",
"id": "cap-1",
"method": "acp.capabilities",
"params": {}
}
```
线上验证返回的核心结构:
```json
{
"availableExecutionTargets": ["agent", "gateway"],
"providerCatalog": [
{ "providerId": "codex", "label": "Codex", "targets": ["agent"], "category": "native" },
{ "providerId": "opencode", "label": "OpenCode", "targets": ["agent"], "category": "native" },
{ "providerId": "gemini", "label": "Gemini", "targets": ["agent"], "category": "protocol-adapter" },
{ "providerId": "hermes", "label": "Hermes", "targets": ["agent"], "category": "protocol-adapter" }
],
"gatewayProviders": [
{ "providerId": "openclaw", "label": "OpenClaw", "targets": ["gateway"] }
]
}
```
APP UI 只能用这些字段驱动 provider/gateway 展示:
- `providerCatalog`
- `gatewayProviders`
- `availableExecutionTargets`
不得在 APP 代码里静态保存:
- `codex` URL
- `opencode` URL
- `gemini` URL
- `hermes` URL
- `openclaw` URL
- 本地端口
- systemd unit 名
## 5. 路由决策
单 Agent 显式路由示例:
```json
{
"jsonrpc": "2.0",
"id": "route-agent-1",
"method": "xworkmate.routing.resolve",
"params": {
"taskPrompt": "create a powerpoint deck",
"workingDirectory": "/tmp/work",
"routing": {
"routingMode": "explicit",
"explicitExecutionTarget": "singleAgent",
"explicitProviderId": "codex"
}
}
}
```
Gateway/OpenClaw 显式路由示例:
```json
{
"jsonrpc": "2.0",
"id": "route-gateway-1",
"method": "xworkmate.routing.resolve",
"params": {
"taskPrompt": "run this through OpenClaw",
"workingDirectory": "/tmp/work",
"routing": {
"routingMode": "explicit",
"explicitExecutionTarget": "gateway",
"preferredGatewayProviderId": "openclaw"
}
}
}
```
返回字段:
- `resolvedExecutionTarget`
- `resolvedProviderId`
- `resolvedGatewayProviderId`
- `resolvedModel`
- `resolvedSkills`
- `status`
- `unavailable`
- `unavailableCode`
- `unavailableMessage`
- `skillResolutionSource`
- `needsSkillInstall`
- `skillInstallRequestId`
## 6. 任务执行
单 Agent 任务:
```json
{
"jsonrpc": "2.0",
"id": "task-1",
"method": "session.start",
"params": {
"sessionId": "s1",
"threadId": "t1",
"taskPrompt": "create a powerpoint deck",
"workingDirectory": "/tmp/work",
"routing": {
"routingMode": "explicit",
"explicitExecutionTarget": "singleAgent",
"explicitProviderId": "codex"
}
}
}
```
OpenClaw gateway 任务的 HTTP task submit 专用入口是:
```text
POST https://xworkmate-bridge.svc.plus/gateway/openclaw
```
它只承载 `session.start` 和 follow-up `session.message`。Bridge 会强制注入
`explicitExecutionTarget=gateway``preferredGatewayProviderId=openclaw`,并拒绝
`multiAgent=true`、agent/provider 冲突参数、`acp.capabilities`、`xworkmate.routing.resolve`、`session.cancel` 和 `session.close`
```json
{
"jsonrpc": "2.0",
"id": "gateway-task-1",
"method": "session.start",
"params": {
"sessionId": "s-gateway-1",
"threadId": "t-gateway-1",
"taskPrompt": "run through OpenClaw",
"workingDirectory": "/tmp/work",
"routing": {
"routingMode": "explicit",
"explicitExecutionTarget": "gateway",
"preferredGatewayProviderId": "openclaw"
}
}
}
```
OpenClaw 的 `session.message` 复用同一 `sessionId` / `threadId`,继续提交到
`/gateway/openclaw`。其他 provider 的 `session.message``/acp``/acp/rpc`
`session.cancel``session.close` 属于 control-plane 操作,继续走 `/acp``/acp/rpc`
## 7. 清理后的 Public Surface
| 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` |
| `/api/ping` | HTTP GET | 否 | 发布与运行健康检查 |
| `/` | HTTP GET | 否 | 简单运行状态 |
| `/acp-server/*` | 无 APP contract | 否 | 线上 Caddy 显式返回 `404` |
陈旧接口清理规则:
- 删除 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 侧保存 provider/gateway URL、端口或 service 名。
- 不把 provider 选择逻辑散落在 APP 的 URL 拼接逻辑里。
- 所有 provider/gateway 能力与可用性都来自 `acp.capabilities`
## 8. 线上环境事实
以下为 2026-05-03 通过 `ssh root@xworkmate-bridge.svc.plus` 核对的部署事实。它们用于 bridge 运维和验证,不属于 APP contract。
### Caddy
`/etc/caddy/conf.d/xworkmate-bridge.caddy` 当前只反代:
```text
/api* -> 127.0.0.1:8787
/acp* -> 127.0.0.1:8787
/gateway/openclaw -> 127.0.0.1:8787
/acp-server/* -> 404
/ -> 127.0.0.1:8787
```
Caddy 层要求:
```http
Authorization: Bearer $BRIDGE_AUTH_TOKEN
```
### Systemd / Local Listeners
| Unit / Runtime | Listener | 说明 |
| --- | --- | --- |
| `xworkmate-bridge.service` | `127.0.0.1:8787` | Public bridge origin |
| `acp-codex.service` | `127.0.0.1:9001` | Codex ACP backend |
| `acp-gemini.service` | `127.0.0.1:8791` | Gemini adapter |
| `acp-hermes.service` | `127.0.0.1:3920` | Hermes adapter |
| `acp-opencode.service` | `127.0.0.1:38992` | OpenCode adapter |
| OpenClaw runtime process | `ws://127.0.0.1:18789` | OpenClaw gateway runtime listener |
这些地址只允许 bridge 内部使用。APP 不保存、不展示、不请求这些地址。
验证时 `xworkmate-bridge`、`acp-codex`、`acp-gemini`、`acp-hermes`、`acp-opencode` 均为 `active``openclaw-gateway.service` 返回 `inactive`,但 `ss` 显示 `openclaw` 进程仍监听 `127.0.0.1:18789``[::1]:18789`。因此 APP contract 只记录 `openclaw` 作为 `gatewayProviders` 能力,不把 systemd unit 状态作为 APP 可见状态。
## 9. 线上验证结果
2026-05-03 使用 `Authorization: Bearer $BRIDGE_AUTH_TOKEN``Origin: https://xworkmate.svc.plus` 验证:
| 验证项 | 结果 |
| --- | --- |
| `GET /api/ping` | `200`,返回 `status=image/tag/commit/version` |
| 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-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`

View File

@ -19,6 +19,7 @@
### 2. 运行入口与对外接口
- [Backend API Design](./backend-api-design.md)
- [API Interface Reference](./api-reference.md)
### 3. 内部包与实现参考

View File

@ -26,8 +26,9 @@ APP-facing ACP control plane。
负责:
- `/acp/rpc` App-facing HTTP RPC transport
- `/acp` WebSocket transport variant
- `/acp` App-facing WebSocket JSON-RPC 主入口
- `/acp/rpc` HTTP JSON-RPC fallback / CI / 调试入口
- `/gateway/openclaw` OpenClaw task submit 专用入口,只接受 `session.start``session.message`
- JSON-RPC / hybrid envelope
- `acp.capabilities`
- `xworkmate.routing.resolve`
@ -100,15 +101,16 @@ APP-facing ACP control plane。
- emit `session.update`
- record project memory only when routing mode is explicitly auto
### 当前删除的旧路径
### 当前删除或收紧的旧路径
以下逻辑已从主链移除
以下逻辑不属于当前 APP-facing contract
- `/acp-server/*`
- `/gateway/openclaw` public alias handler
- multi-agent 执行路径
- provider-specific alias handler
`/gateway/openclaw` 只保留为 OpenClaw task submit 专用 handler不再作为 provider alias、gateway alias 或通用 ACP base endpoint。
## 3. `provider_compat`
bridge 当前通过 compat 层吸收 provider 差异:

View File

@ -1,11 +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 (Validated via /acp/rpc)
- **Gateway Path:** /gateway/openclaw
- **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`.
## Summary
| skill | total | pass | fail | blocked | notes |
|-------|-------|------|------|---------|-------|
@ -102,6 +107,7 @@
- **video-translator**: Needs translation service API key.
- **browser-automation**: Needs local chrome or Browserbase API key.
### Gateway Path Verification
- Verified that `/gateway/openclaw` returns 200 but 0 content-length via curl.
- Actual interaction successful via `/acp/rpc` with `explicitExecutionTarget: gateway`.
- Bearer authentication is correctly enforced at the bridge level.
- Historical run observed `/gateway/openclaw` 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`.
- Bearer authentication is correctly enforced at the bridge level.

View File

@ -105,7 +105,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, nil)
s.handleRPCWithTransform(w, r, rejectOpenClawTaskSubmitOnCanonicalRPC)
}
func (s *Server) HandleOpenClawGatewayRPC(w http.ResponseWriter, r *http.Request) {
@ -237,7 +237,7 @@ func (s *Server) handleRPCWithTransform(
func forceOpenClawGatewayRequest(request shared.RPCRequest) (shared.RPCRequest, *shared.RPCError) {
method := strings.TrimSpace(request.Method)
switch method {
case "session.start", "session.message", "session.cancel", "session.close":
case "session.start", "session.message":
default:
return request, &shared.RPCError{Code: -32601, Message: "OPENCLAW_GATEWAY_METHOD_NOT_ALLOWED: " + method}
}
@ -245,10 +245,37 @@ func forceOpenClawGatewayRequest(request shared.RPCRequest) (shared.RPCRequest,
if params == nil {
params = map[string]any{}
}
if parseBool(params["multiAgent"]) {
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 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"
@ -260,6 +287,82 @@ func forceOpenClawGatewayRequest(request shared.RPCRequest) (shared.RPCRequest,
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
}
if requestHasExplicitAgentRouting(params) {
return false
}
for _, key := range []string{"executionTarget", "requestedExecutionTarget"} {
if isGatewayExecutionTarget(shared.StringArg(params, key, "")) {
return true
}
}
for _, key := range []string{"gatewayProvider", "gatewayProviderId"} {
if isOpenClawProvider(shared.StringArg(params, key, "")) {
return true
}
}
routing := shared.AsMap(params["routing"])
if isGatewayExecutionTarget(shared.StringArg(routing, "explicitExecutionTarget", "")) {
return true
}
for _, key := range []string{"preferredGatewayProviderId", "gatewayProviderId", "gatewayProvider"} {
if isOpenClawProvider(shared.StringArg(routing, key, "")) {
return true
}
}
return false
}
func requestHasExplicitAgentRouting(params map[string]any) bool {
for _, key := range []string{"executionTarget", "requestedExecutionTarget"} {
if isAgentExecutionTarget(shared.StringArg(params, key, "")) {
return true
}
}
if provider := strings.TrimSpace(shared.StringArg(params, "provider", "")); provider != "" && !isOpenClawProvider(provider) {
return true
}
routing := shared.AsMap(params["routing"])
if isAgentExecutionTarget(shared.StringArg(routing, "explicitExecutionTarget", "")) {
return true
}
if provider := strings.TrimSpace(shared.StringArg(routing, "explicitProviderId", "")); provider != "" && !isOpenClawProvider(provider) {
return true
}
return false
}
func isAgentExecutionTarget(value string) bool {
normalized := strings.ToLower(strings.TrimSpace(value))
return normalized == "agent" || normalized == "single-agent" || normalized == "singleagent"
}
func isGatewayExecutionTarget(value string) bool {
return strings.EqualFold(strings.TrimSpace(value), "gateway")
}
func isOpenClawProvider(value string) bool {
return strings.EqualFold(strings.TrimSpace(value), "openclaw")
}
func (s *Server) authorized(r *http.Request) bool {
if s == nil {
return false

View File

@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/base64"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
@ -376,11 +377,20 @@ func normalizeArtifactPayload(item map[string]any, remoteWorkingDirectory string
if relativePath == "" {
relativePath = strings.TrimSpace(shared.StringArg(artifact, "name", ""))
}
downloadURL := artifactDownloadURL(artifact)
if relativePath == "" && downloadURL != "" {
relativePath = artifactRelativePathFromDownloadURL(downloadURL)
}
relativePath = safeArtifactRelativePath(remoteWorkingDirectory, relativePath)
if relativePath == "" {
return nil
}
artifact["relativePath"] = relativePath
if downloadURL != "" {
artifact["downloadUrl"] = downloadURL
delete(artifact, "downloadURL")
delete(artifact, "download_url")
}
if strings.TrimSpace(shared.StringArg(artifact, "label", "")) == "" {
artifact["label"] = filepath.Base(relativePath)
}
@ -397,6 +407,28 @@ func normalizeArtifactPayload(item map[string]any, remoteWorkingDirectory string
return artifact
}
func artifactDownloadURL(artifact map[string]any) string {
for _, key := range []string{"downloadUrl", "downloadURL", "download_url"} {
if value := strings.TrimSpace(shared.StringArg(artifact, key, "")); value != "" {
return value
}
}
return ""
}
func artifactRelativePathFromDownloadURL(raw string) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return ""
}
base := strings.TrimSpace(filepath.Base(parsed.Path))
if base == "" || base == "." || base == "/" {
sum := sha256.Sum256([]byte(raw))
base = fmt.Sprintf("artifact-%x.bin", sum[:6])
}
return base
}
func collectDirectoryArtifacts(root string) []map[string]any {
root = strings.TrimSpace(root)
if root == "" {

View File

@ -523,6 +523,54 @@ func TestExecuteSessionTaskDefaultsExplicitGatewayToOpenClaw(t *testing.T) {
}
}
func TestExtractArtifactPayloadsPreservesDownloadURLOnlyArtifacts(t *testing.T) {
artifacts := extractArtifactPayloads(map[string]any{
"artifacts": []any{
map[string]any{
"name": "reports/final.txt",
"downloadURL": "https://xworkmate-bridge.svc.plus/artifacts/final.txt",
},
map[string]any{
"download_url": "https://xworkmate-bridge.svc.plus/artifacts/from-url.md",
},
},
}, "")
if len(artifacts) != 2 {
t.Fatalf("expected two artifacts, got %#v", artifacts)
}
if got := artifacts[0]["relativePath"]; got != "reports/final.txt" {
t.Fatalf("expected name-derived path, got %#v", got)
}
if got := artifacts[0]["downloadUrl"]; got != "https://xworkmate-bridge.svc.plus/artifacts/final.txt" {
t.Fatalf("expected normalized downloadUrl, got %#v", got)
}
if _, ok := artifacts[0]["downloadURL"]; ok {
t.Fatalf("expected downloadURL alias to be removed: %#v", artifacts[0])
}
if got := artifacts[1]["relativePath"]; got != "from-url.md" {
t.Fatalf("expected URL basename path, got %#v", got)
}
if got := artifacts[1]["contentType"]; got != "text/plain" {
t.Fatalf("expected markdown content type, got %#v", got)
}
}
func TestExtractArtifactPayloadsRejectsUnsafeDownloadURLArtifactNames(t *testing.T) {
artifacts := extractArtifactPayloads(map[string]any{
"artifacts": []any{
map[string]any{
"name": "../secrets.txt",
"downloadUrl": "https://xworkmate-bridge.svc.plus/artifacts/secrets.txt",
},
},
}, "")
if len(artifacts) != 0 {
t.Fatalf("expected unsafe artifact to be dropped, got %#v", artifacts)
}
}
type acpFakeOpenClawGateway struct {
server *http.Server
listener net.Listener

View File

@ -139,6 +139,111 @@ func TestHTTPHandlerGatewayOpenClawRejectsNonSessionMethods(t *testing.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":{"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":{"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()