fix: use openclaw task submit endpoint

This commit is contained in:
Haitao Pan 2026-05-07 08:44:23 +08:00
parent 3ee9404aa2
commit 0197894698
6 changed files with 250 additions and 158 deletions

View File

@ -4,7 +4,7 @@ Last Updated: 2026-04-21
本文记录 `xworkmate-app` 当前对 `xworkmate-bridge` 的运行时路由合同。UI 不直接承载这些路径Assistant UI 仍由 `acp.capabilities` 返回的 `providerCatalog`、`gatewayProviders`、`availableExecutionTargets` 驱动。
App 侧任务发送只调用 bridge 主入口 `/acp/rpc`,不再拼接 provider-specific 直连 URL。Provider 与 gateway 的实际执行地址是 bridge 内部运行时事实,不属于 App contract。
App 侧任务发送默认调用 bridge 主入口 `/acp/rpc`,不再拼接 provider-specific 直连 URL。OpenClaw `session.start` 和同一任务的 `session.message` 是唯一例外,使用 bridge 公开的 task submit 专用路径 `/gateway/openclaw`。该路径不是全局 ACP base endpoint。
## App Runtime Flow
@ -22,8 +22,11 @@ flowchart TD
D --> J["OpenClaw"]
A --> P["POST https://xworkmate-bridge.svc.plus/acp/rpc"]
A --> T["OpenClaw task POST /gateway/openclaw"]
P --> Q["Authorization: Bearer token"]
T --> Q
P --> R["provider / requestedExecutionTarget params"]
T --> R
R --> S["bridge-owned routing"]
S --> K["Hermes internal runtime"]
@ -35,9 +38,10 @@ flowchart TD
## Routing Rules
- App runtime requests use `https://xworkmate-bridge.svc.plus/acp/rpc`.
- App runtime control-plane requests, agent tasks, multi-agent tasks, `session.cancel`, and `session.close` use `https://xworkmate-bridge.svc.plus/acp/rpc`.
- OpenClaw gateway `session.start` and follow-up `session.message` use `https://xworkmate-bridge.svc.plus/gateway/openclaw`.
- Provider and gateway selection are passed as request params, including `provider`, `routing`, and `requestedExecutionTarget`.
- Bridge-owned internal routing is opaque to the App; it is not represented as public provider paths.
- The app must not route managed bridge tasks to local or LAN endpoints such as `127.0.0.1:*` or `192.168.*:*`.
- The app must not route managed bridge tasks by directly constructing `/acp-server/*` or `/gateway/*` URLs.
- All App-side requests go through `https://xworkmate-bridge.svc.plus/acp/rpc`.
- The app must not route managed bridge tasks by directly constructing `/acp-server/*` URLs.
- `/gateway/openclaw` is allowed only for OpenClaw task submit; it must not be reused for capabilities, routing, gateway control-plane, cancel, close, or as an ACP base endpoint.

View File

@ -2,7 +2,7 @@
## 1. 架构概览 (Unified Routing Architecture)
当前系统采用 `xworkmate-bridge.svc.plus` 作为统一入口。App 侧只通过 managed bridge ACP 主入口发送任务provider / gateway 的执行地址由 bridge 后端内部拥有,不暴露为 App-facing public mapping。
当前系统采用 `xworkmate-bridge.svc.plus` 作为统一入口。App 侧通过 managed bridge ACP 主入口处理能力发现、路由解析、agent / multi-agent 任务和会话控制OpenClaw `session.start` / `session.message` 使用 bridge 暴露的 `/gateway/openclaw` task submit 专用入口。Provider runtime 地址仍由 bridge 后端内部拥有,不暴露为 App-facing public mapping。
```mermaid
graph TD
@ -16,6 +16,7 @@ graph TD
subgraph "Bridge-owned Routing"
ManagedBridge["Managed Bridge ACP<br/>/acp/rpc"]
OpenClawSubmit["OpenClaw task submit<br/>/gateway/openclaw"]
CodexProvider["Codex internal runtime"]
OpenCodeProvider["OpenCode internal runtime"]
GeminiAdapter["Gemini internal runtime"]
@ -26,10 +27,11 @@ graph TD
Client -->|HTTPS/WSS| Bridge_Domain
Bridge_Domain -->|/acp/rpc| ManagedBridge
Bridge_Domain -->|/gateway/openclaw| OpenClawSubmit
ManagedBridge -->|provider routing| CodexProvider
ManagedBridge -->|provider routing| OpenCodeProvider
ManagedBridge -->|provider routing| GeminiAdapter
ManagedBridge -->|gateway routing| OpenClawGateway
OpenClawSubmit -->|forced openclaw routing| OpenClawGateway
%% Service Connections
ManagedBridge -.->|Capabilities Discovery| Client
@ -39,14 +41,15 @@ graph TD
| Bridge-owned mapping | App 侧行为 | 备注 |
| :--- | :--- | :--- |
| `/acp/rpc` | 直接调用 | Managed Bridge ACP 主入口,提供能力发现与任务发送 |
| `/acp/rpc` | 直接调用 | 能力发现、路由解析、agent / multi-agent 任务、cancel、close |
| `/gateway/openclaw` | 仅 OpenClaw task submit | 只用于 OpenClaw `session.start` / `session.message`,不是 ACP base endpoint |
| provider runtime | 不直连 | Bridge 后端内部解析 provider |
| gateway runtime | 不直连 | Bridge 后端内部解析 gateway provider |
## 3. 运维配置优化
### 3.1 统一鉴权
App 发往 `xworkmate-bridge.svc.plus/acp/rpc` 的请求必须携带:
App 发往 `xworkmate-bridge.svc.plus/acp/rpc` `xworkmate-bridge.svc.plus/gateway/openclaw` 的请求必须携带:
- **Header**: `Authorization: Bearer <bridge-auth-token>`
- **未授权响应**: `401 Unauthorized`
@ -62,5 +65,6 @@ App 发往 `xworkmate-bridge.svc.plus/acp/rpc` 的请求必须携带:
## 4. App 侧不变量
- App 不写入或拼接本地 provider endpoint。
- App 不直接调用 `/acp-server/*``/gateway/openclaw`
- App 不直接调用 `/acp-server/*`
- App 仅可在 OpenClaw `session.start` / `session.message` task submit 中调用 `/gateway/openclaw`,不得把它作为全局 ACP base endpoint。
- `acp.capabilities` 是 provider catalog、gateway catalog、available execution targets 的唯一来源。

View File

@ -40,7 +40,7 @@ Last Updated: 2026-04-22
- 安全边界确认:
- `BRIDGE_AUTH_TOKEN` 是否只作为 Bearer token 使用
- `BRIDGE_SERVER_URL` 是否仅作为元数据
- 会话接口是否仍通过统一 `/acp/rpc` 入口
- 会话接口是否按当前 bridge contract 选择 `/acp/rpc` 或 OpenClaw task submit 专用入口 `/gateway/openclaw`
## 2. 统一测试前提
@ -76,7 +76,8 @@ Last Updated: 2026-04-22
| accounts | `/api/auth/login` | `POST` | 无 | 登录并拿 session token | Apple 审核只读账号使用 |
| accounts | `/api/auth/session` | `GET` | `Authorization: Bearer <session token>` | 获取当前会话和用户信息 | APP 端登录态校验 |
| accounts | `/api/auth/xworkmate/profile/sync` | `GET` | `Authorization: Bearer <session token>` | 拉取 bridge 同步元数据 | 返回 `BRIDGE_SERVER_URL` / `BRIDGE_AUTH_TOKEN` |
| bridge | `/acp/rpc` | `POST` | `Authorization: Bearer <BRIDGE_AUTH_TOKEN>` | bridge JSON-RPC 主入口 | 所有运行时任务统一走这里 |
| bridge | `/acp/rpc` | `POST` | `Authorization: Bearer <BRIDGE_AUTH_TOKEN>` | bridge JSON-RPC 主入口 | capabilities、routing、agent / multi-agent 任务、cancel、close |
| bridge | `/gateway/openclaw` | `POST` | `Authorization: Bearer <BRIDGE_AUTH_TOKEN>` | OpenClaw task submit 专用入口 | 仅 OpenClaw `session.start` / `session.message`,不是全局 ACP base endpoint |
| bridge | `acp.capabilities` | JSON-RPC method | 同上 | 拉取 provider catalog / target catalog | capability 只读快照 |
| bridge | `xworkmate.routing.resolve` | JSON-RPC method | 同上 | 解析 provider / gateway / skills 路由 | 返回 resolved / unavailable 信息 |
| bridge | `session.start` | JSON-RPC method | 同上 | 开启新会话 | session 生命周期起点 |
@ -580,8 +581,10 @@ Last Updated: 2026-04-22
### 8.3 建议断言
- `Authorization` 必须是 `Bearer <token>`
- 请求路径必须是 `/acp/rpc`
- 不允许出现 `/acp-server/``/gateway/` 直连请求
- capability、routing、agent / multi-agent 任务、`session.cancel`、`session.close` 请求路径必须是 `/acp/rpc`
- OpenClaw gateway `session.start` / `session.message` 请求路径必须是 `/gateway/openclaw`
- 不允许出现 `/acp-server/` 直连请求
- `/gateway/openclaw` 不得用于 capabilities、routing、gateway control-plane、cancel、close且不得作为全局 ACP base endpoint
- `session.cancel` / `session.close` 必须接受 `sessionId` + `threadId`
- `BRIDGE_SERVER_URL` 不可参与运行时路径拼接
@ -609,7 +612,7 @@ Last Updated: 2026-04-22
- 账户侧负责登录与同步元数据
- bridge 侧负责 capability、路由解析和会话生命周期
- 任务执行必须统一经过 `/acp/rpc`
- `session.start` / `session.message` 的协议入口已验证通畅
- capability、routing、agent / multi-agent 任务、cancel、close 经过 `/acp/rpc`
- OpenClaw `session.start` / `session.message` 经过 `/gateway/openclaw`
- `session.cancel` / `session.close` 的协议入口已验证可用
- 当前剩余风险集中在桥接后端下游 provider 连接,而不是 APP 侧接口拼接

View File

@ -867,6 +867,9 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
if (bridgeEndpoint == null) {
return null;
}
if (_usesOpenClawTaskSubmitEndpointInternal(request)) {
return bridgeEndpoint.replace(path: '/gateway/openclaw');
}
return bridgeEndpoint;
}
@ -967,6 +970,14 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
) => kGatewayRemoteProfileIndex;
}
bool _usesOpenClawTaskSubmitEndpointInternal(GoTaskServiceRequest request) {
if (request.isMultiAgentRequest || !request.target.isGateway) {
return false;
}
return normalizeSingleAgentProviderId(request.provider.providerId) ==
kCanonicalGatewayProviderId;
}
String _normalizeAuthorizationHeaderInternal(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) {

View File

@ -566,7 +566,7 @@ class GatewayAcpClient {
);
httpRequest.headers.set(
HttpHeaders.acceptHeader,
'text/event-stream, application/json',
_httpAcceptHeaderFor(endpoint, request.method),
);
final authorization = await _resolveAuthorizationHeader(
endpoint,
@ -1049,6 +1049,14 @@ class GatewayAcpClient {
Uri? _resolveHttpRpcEndpoint([Uri? endpointOverride, String method = '']) {
final endpoint = endpointOverride ?? endpointResolver();
if (_isOpenClawTaskSubmitEndpoint(endpoint) &&
_isOpenClawTaskSubmitMethod(method)) {
return endpoint?.replace(
path: '/gateway/openclaw',
query: null,
fragment: null,
);
}
return resolveAcpHttpRpcEndpoint(endpoint);
}
@ -1107,7 +1115,33 @@ class GatewayAcpClient {
}
}
bool _isOpenClawTaskSubmitEndpoint(Uri? endpoint) {
var path = endpoint?.path.trim() ?? '';
if (!path.startsWith('/')) {
path = '/$path';
}
path = path.replaceFirst(RegExp(r'/+$'), '');
return path == '/gateway/openclaw';
}
bool _isOpenClawTaskSubmitMethod(String method) {
final normalized = method.trim();
return normalized == 'session.start' || normalized == 'session.message';
}
String _httpAcceptHeaderFor(Uri endpoint, String method) {
if (_isOpenClawTaskSubmitEndpoint(endpoint) &&
_isOpenClawTaskSubmitMethod(method)) {
return 'application/json';
}
return 'text/event-stream, application/json';
}
Duration gatewayAcpHttpResponseTimeoutFor(Uri endpoint, String method) {
if (_isOpenClawTaskSubmitEndpoint(endpoint) &&
_isOpenClawTaskSubmitMethod(method)) {
return const Duration(minutes: 10);
}
return const Duration(seconds: 120);
}

View File

@ -687,160 +687,193 @@ void main() {
},
);
test('desktop task execution routes OpenClaw through bridge RPC', () async {
final capture = await _startAcpHttpServer();
addTearDown(capture.close);
final client = GatewayAcpClient(
endpointResolver: () => capture.baseEndpoint,
authorizationResolver: (_) async => 'bridge-token',
);
final transport = ExternalCodeAgentAcpDesktopTransport(
client: client,
endpointResolver: (_) => capture.baseEndpoint,
taskEndpointResolver: (_) => capture.baseEndpoint,
);
await transport.executeTask(
_taskRequest(
target: AssistantExecutionTarget.gateway,
provider: SingleAgentProvider.openclaw,
),
onUpdate: (_) {},
);
expect(capture.authorizationHeader, 'Bearer bridge-token');
expect(capture.acceptHeader, 'text/event-stream, application/json');
expect(capture.requestPath, '/acp/rpc');
expect(capture.requestPath, isNot(contains('/acp-server')));
expect(capture.requestPath, isNot(contains('/acp-server/gateway')));
expect(capture.requestPath, isNot(contains('/gateway/openclaw')));
final params = _lastRequestParams(capture);
final routing = params['routing'] as Map<String, dynamic>;
expect(params.containsKey('gatewayProvider'), isFalse);
expect(params.containsKey('gatewayProviderId'), isFalse);
expect(params['executionTarget'], 'gateway');
expect(params['requestedExecutionTarget'], 'gateway');
expect(routing['preferredGatewayProviderId'], 'openclaw');
expect(routing['explicitExecutionTarget'], 'gateway');
expect(routing.containsKey('explicitProviderId'), isFalse);
expect(capture.requestBody, contains('"method":"session.start"'));
expect(capture.requestBody, isNot(contains('"method":"thread/start"')));
});
test('desktop OpenClaw follow-up routes through bridge RPC', () async {
final capture = await _startAcpHttpServer();
addTearDown(capture.close);
final client = GatewayAcpClient(
endpointResolver: () => capture.baseEndpoint,
authorizationResolver: (_) async => 'bridge-token',
);
final transport = ExternalCodeAgentAcpDesktopTransport(
client: client,
endpointResolver: (_) => capture.baseEndpoint,
taskEndpointResolver: (_) => capture.baseEndpoint,
);
await transport.executeTask(
_taskRequest(
target: AssistantExecutionTarget.gateway,
provider: SingleAgentProvider.openclaw,
resumeSession: true,
),
onUpdate: (_) {},
);
expect(capture.acceptHeader, 'text/event-stream, application/json');
expect(capture.requestPath, '/acp/rpc');
expect(capture.requestPath, isNot(contains('/gateway/openclaw')));
expect(capture.requestBody, contains('"method":"session.message"'));
});
test(
'bridge RPC session methods use the standard HTTP response timeout',
() {
final openClawEndpoint = Uri.parse(
'https://xworkmate-bridge.svc.plus/gateway/openclaw',
);
final acpEndpoint = Uri.parse(
'https://xworkmate-bridge.svc.plus/acp/rpc',
'desktop task execution rejects OpenClaw gateway path for non-task methods',
() async {
final capture = await _startAcpHttpServer();
addTearDown(capture.close);
final client = GatewayAcpClient(
endpointResolver: () =>
capture.baseEndpoint.replace(path: '/gateway/openclaw'),
authorizationResolver: (_) async => 'bridge-token',
);
expect(
gatewayAcpHttpResponseTimeoutFor(openClawEndpoint, 'session.start'),
const Duration(seconds: 120),
);
expect(
gatewayAcpHttpResponseTimeoutFor(openClawEndpoint, 'session.message'),
const Duration(seconds: 120),
);
expect(
gatewayAcpHttpResponseTimeoutFor(acpEndpoint, 'session.start'),
const Duration(seconds: 120),
);
expect(
gatewayAcpHttpResponseTimeoutFor(
openClawEndpoint,
'acp.capabilities',
await expectLater(
client.request(
method: 'acp.capabilities',
params: const <String, dynamic>{},
),
throwsA(
isA<GatewayAcpException>().having(
(error) => error.code,
'code',
'ACP_HTTP_ENDPOINT_MISSING',
),
),
const Duration(seconds: 120),
);
expect(capture.requestBodies, isEmpty);
},
);
test('desktop controller resolves task requests to the bridge origin', () {
final controller = AppController(
environmentOverride: const <String, String>{},
);
addTearDown(controller.dispose);
test(
'desktop task execution routes OpenClaw through dedicated bridge gateway path',
() async {
final capture = await _startAcpHttpServer();
addTearDown(capture.close);
final client = GatewayAcpClient(
endpointResolver: () => capture.baseEndpoint,
authorizationResolver: (_) async => 'bridge-token',
);
final openClawStart = controller
.resolveExternalAcpEndpointForRequestInternal(
_taskRequest(
target: AssistantExecutionTarget.gateway,
provider: SingleAgentProvider.openclaw,
),
);
final openClawFollowUp = controller
.resolveExternalAcpEndpointForRequestInternal(
_taskRequest(
target: AssistantExecutionTarget.gateway,
provider: SingleAgentProvider.openclaw,
resumeSession: true,
),
);
final unspecifiedGateway = controller
.resolveExternalAcpEndpointForRequestInternal(
_taskRequest(
target: AssistantExecutionTarget.gateway,
provider: SingleAgentProvider.unspecified,
),
);
final multiAgentGateway = controller
.resolveExternalAcpEndpointForRequestInternal(
_taskRequest(
target: AssistantExecutionTarget.gateway,
provider: SingleAgentProvider.openclaw,
multiAgent: true,
),
);
final agentTask = controller.resolveExternalAcpEndpointForRequestInternal(
_taskRequest(
target: AssistantExecutionTarget.agent,
provider: SingleAgentProvider.codex,
),
final transport = ExternalCodeAgentAcpDesktopTransport(
client: client,
endpointResolver: (_) => capture.baseEndpoint,
taskEndpointResolver: (_) =>
capture.baseEndpoint.replace(path: '/gateway/openclaw'),
);
await transport.executeTask(
_taskRequest(
target: AssistantExecutionTarget.gateway,
provider: SingleAgentProvider.openclaw,
),
onUpdate: (_) {},
);
expect(capture.authorizationHeader, 'Bearer bridge-token');
expect(capture.acceptHeader, 'application/json');
expect(capture.requestPath, '/gateway/openclaw');
expect(capture.requestPath, isNot(contains('/acp-server')));
expect(capture.requestPath, isNot(contains('/acp-server/gateway')));
final params = _lastRequestParams(capture);
final routing = params['routing'] as Map<String, dynamic>;
expect(params.containsKey('gatewayProvider'), isFalse);
expect(params.containsKey('gatewayProviderId'), isFalse);
expect(params['executionTarget'], 'gateway');
expect(params['requestedExecutionTarget'], 'gateway');
expect(routing['preferredGatewayProviderId'], 'openclaw');
expect(routing['explicitExecutionTarget'], 'gateway');
expect(routing.containsKey('explicitProviderId'), isFalse);
expect(capture.requestBody, contains('"method":"session.start"'));
expect(capture.requestBody, isNot(contains('"method":"thread/start"')));
},
);
test(
'desktop OpenClaw follow-up routes through dedicated bridge gateway path',
() async {
final capture = await _startAcpHttpServer();
addTearDown(capture.close);
final client = GatewayAcpClient(
endpointResolver: () => capture.baseEndpoint,
authorizationResolver: (_) async => 'bridge-token',
);
final transport = ExternalCodeAgentAcpDesktopTransport(
client: client,
endpointResolver: (_) => capture.baseEndpoint,
taskEndpointResolver: (_) =>
capture.baseEndpoint.replace(path: '/gateway/openclaw'),
);
await transport.executeTask(
_taskRequest(
target: AssistantExecutionTarget.gateway,
provider: SingleAgentProvider.openclaw,
resumeSession: true,
),
onUpdate: (_) {},
);
expect(capture.acceptHeader, 'application/json');
expect(capture.requestPath, '/gateway/openclaw');
expect(capture.requestBody, contains('"method":"session.message"'));
},
);
test('OpenClaw task submit uses extended HTTP response timeout', () {
final openClawEndpoint = Uri.parse(
'https://xworkmate-bridge.svc.plus/gateway/openclaw',
);
final acpEndpoint = Uri.parse(
'https://xworkmate-bridge.svc.plus/acp/rpc',
);
expect(openClawStart?.path, '');
expect(openClawFollowUp?.path, '');
expect(unspecifiedGateway?.path, '');
expect(multiAgentGateway?.path, '');
expect(agentTask?.path, '');
expect(
gatewayAcpHttpResponseTimeoutFor(openClawEndpoint, 'session.start'),
const Duration(minutes: 10),
);
expect(
gatewayAcpHttpResponseTimeoutFor(openClawEndpoint, 'session.message'),
const Duration(minutes: 10),
);
expect(
gatewayAcpHttpResponseTimeoutFor(acpEndpoint, 'session.start'),
const Duration(seconds: 120),
);
expect(
gatewayAcpHttpResponseTimeoutFor(openClawEndpoint, 'acp.capabilities'),
const Duration(seconds: 120),
);
});
test(
'desktop controller does not expose OpenClaw gateway path as task endpoint',
'desktop controller only uses gateway path for OpenClaw task submit',
() {
final controller = AppController(
environmentOverride: const <String, String>{},
);
addTearDown(controller.dispose);
final openClawStart = controller
.resolveExternalAcpEndpointForRequestInternal(
_taskRequest(
target: AssistantExecutionTarget.gateway,
provider: SingleAgentProvider.openclaw,
),
);
final openClawFollowUp = controller
.resolveExternalAcpEndpointForRequestInternal(
_taskRequest(
target: AssistantExecutionTarget.gateway,
provider: SingleAgentProvider.openclaw,
resumeSession: true,
),
);
final unspecifiedGateway = controller
.resolveExternalAcpEndpointForRequestInternal(
_taskRequest(
target: AssistantExecutionTarget.gateway,
provider: SingleAgentProvider.unspecified,
),
);
final multiAgentGateway = controller
.resolveExternalAcpEndpointForRequestInternal(
_taskRequest(
target: AssistantExecutionTarget.gateway,
provider: SingleAgentProvider.openclaw,
multiAgent: true,
),
);
final agentTask = controller
.resolveExternalAcpEndpointForRequestInternal(
_taskRequest(
target: AssistantExecutionTarget.agent,
provider: SingleAgentProvider.codex,
),
);
expect(openClawStart?.path, '/gateway/openclaw');
expect(openClawFollowUp?.path, '/gateway/openclaw');
expect(unspecifiedGateway?.path, '');
expect(multiAgentGateway?.path, '');
expect(agentTask?.path, '');
},
);
test(
'desktop controller resolves OpenClaw gateway submit on managed bridge origin',
() {
final controller = AppController(
environmentOverride: const <String, String>{},
@ -855,9 +888,12 @@ void main() {
),
);
expect(endpoint.toString(), 'https://xworkmate-bridge.svc.plus');
expect(
endpoint.toString(),
'https://xworkmate-bridge.svc.plus/gateway/openclaw',
);
expect(endpoint, isNotNull);
expect(endpoint!.path, isNot('/gateway/openclaw'));
expect(endpoint!.path, isNot('/acp/rpc'));
},
);