fix: enforce openclaw gateway task route
This commit is contained in:
parent
2f5e0a3fa1
commit
fac0a0857a
10
README.md
10
README.md
@ -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:
|
||||
|
||||
|
||||
@ -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 定义
|
||||
|
||||
@ -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 contract,APP 不应保存或拼接这些 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`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 endpoint,capabilities、routing、cancel、close 和
|
||||
gateway control-plane method 继续走 `/acp` 或 `/acp/rpc`。
|
||||
|
||||
### 后续规则
|
||||
|
||||
新增 JSON-RPC over stdio provider 时:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
331
docs/backend-api-design.md
Normal 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`。
|
||||
@ -19,6 +19,7 @@
|
||||
|
||||
### 2. 运行入口与对外接口
|
||||
|
||||
- [Backend API Design](./backend-api-design.md)
|
||||
- [API Interface Reference](./api-reference.md)
|
||||
|
||||
### 3. 内部包与实现参考
|
||||
|
||||
@ -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 差异:
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user