diff --git a/README.md b/README.md index 331f764..454e592 100644 --- a/README.md +++ b/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: diff --git a/docs/acp-public-validation-2026-04-09.md b/docs/acp-public-validation-2026-04-09.md index 269cb39..6cf2188 100644 --- a/docs/acp-public-validation-2026-04-09.md +++ b/docs/acp-public-validation-2026-04-09.md @@ -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 定义 diff --git a/docs/api-reference.md b/docs/api-reference.md index 3969fdd..f18b0e2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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 ` +- `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` diff --git a/docs/architecture/acp-forwarding-topology.md b/docs/architecture/acp-forwarding-topology.md index 2e6bc6c..edf3bb8 100644 --- a/docs/architecture/acp-forwarding-topology.md +++ b/docs/architecture/acp-forwarding-topology.md @@ -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
JSON-RPC over WebSocket"] - B2["POST /acp/rpc
App-facing HTTP RPC"] + B2["POST /acp/rpc
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
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 diff --git a/docs/architecture/adr-refocus-bridge-as-control-plane.md b/docs/architecture/adr-refocus-bridge-as-control-plane.md index ded2b03..2a2bfb8 100644 --- a/docs/architecture/adr-refocus-bridge-as-control-plane.md +++ b/docs/architecture/adr-refocus-bridge-as-control-plane.md @@ -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 时: diff --git a/docs/architecture/adr-unified-bridge-entrypoints.md b/docs/architecture/adr-unified-bridge-entrypoints.md index fcbf51a..6775488 100644 --- a/docs/architecture/adr-unified-bridge-entrypoints.md +++ b/docs/architecture/adr-unified-bridge-entrypoints.md @@ -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 diff --git a/docs/architecture/bridge-runtime-design.md b/docs/architecture/bridge-runtime-design.md index 35365e9..c1904ae 100644 --- a/docs/architecture/bridge-runtime-design.md +++ b/docs/architecture/bridge-runtime-design.md @@ -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 计算 diff --git a/docs/backend-api-design.md b/docs/backend-api-design.md new file mode 100644 index 0000000..8264356 --- /dev/null +++ b/docs/backend-api-design.md @@ -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= +``` + +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`。 diff --git a/docs/index.md b/docs/index.md index 467df10..b0ab564 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,6 +19,7 @@ ### 2. 运行入口与对外接口 +- [Backend API Design](./backend-api-design.md) - [API Interface Reference](./api-reference.md) ### 3. 内部包与实现参考 diff --git a/docs/internal-reference.md b/docs/internal-reference.md index f447dfc..a07d694 100644 --- a/docs/internal-reference.md +++ b/docs/internal-reference.md @@ -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 差异: diff --git a/docs/testing/openclaw-skills-interactive-test-report-2026-05-01.md b/docs/testing/openclaw-skills-interactive-test-report-2026-05-01.md index 1b2d3a9..602a5c4 100644 --- a/docs/testing/openclaw-skills-interactive-test-report-2026-05-01.md +++ b/docs/testing/openclaw-skills-interactive-test-report-2026-05-01.md @@ -1,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. \ No newline at end of file +- 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. diff --git a/internal/acp/http_handler.go b/internal/acp/http_handler.go index 4d3591b..039cc55 100644 --- a/internal/acp/http_handler.go +++ b/internal/acp/http_handler.go @@ -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 diff --git a/internal/acp/orchestrator.go b/internal/acp/orchestrator.go index 7e7520d..9913098 100644 --- a/internal/acp/orchestrator.go +++ b/internal/acp/orchestrator.go @@ -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 == "" { diff --git a/internal/acp/routing_test.go b/internal/acp/routing_test.go index 7e22f8a..4a59bf0 100644 --- a/internal/acp/routing_test.go +++ b/internal/acp/routing_test.go @@ -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 diff --git a/internal/acp/web_contract_test.go b/internal/acp/web_contract_test.go index d321ba6..79d4af3 100644 --- a/internal/acp/web_contract_test.go +++ b/internal/acp/web_contract_test.go @@ -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()