Refactor bridge runtime routing
This commit is contained in:
parent
7186fa3c5d
commit
99796e238c
47
docs/architecture/bridge-runtime-routing-map.md
Normal file
47
docs/architecture/bridge-runtime-routing-map.md
Normal file
@ -0,0 +1,47 @@
|
||||
# Bridge Runtime Routing Map
|
||||
|
||||
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 public mapping 是 bridge-owned 后端事实,用于说明 bridge 如何把 catalog / routing 解析到实际 provider 或 gateway。
|
||||
|
||||
## App Runtime Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Assistant send"] --> B["acp.capabilities"]
|
||||
B --> C["providerCatalog"]
|
||||
B --> D["gatewayProviders"]
|
||||
B --> E["availableExecutionTargets"]
|
||||
|
||||
C --> F["Hermes"]
|
||||
C --> G["Codex"]
|
||||
C --> H["OpenCode"]
|
||||
C --> I["Gemini"]
|
||||
D --> J["OpenClaw"]
|
||||
|
||||
A --> P["POST https://xworkmate-bridge.svc.plus/acp/rpc"]
|
||||
P --> Q["Authorization: Bearer token"]
|
||||
P --> R["provider / requestedExecutionTarget params"]
|
||||
R --> S["bridge-owned routing"]
|
||||
|
||||
S --> K["Hermes map<br/>https://xworkmate-bridge.svc.plus/acp-server/hermes"]
|
||||
S --> L["Codex map<br/>https://xworkmate-bridge.svc.plus/acp-server/codex"]
|
||||
S --> M["OpenCode map<br/>https://xworkmate-bridge.svc.plus/acp-server/opencode"]
|
||||
S --> N["Gemini map<br/>https://xworkmate-bridge.svc.plus/acp-server/gemini"]
|
||||
S --> O["OpenClaw map<br/>https://xworkmate-bridge.svc.plus/gateway/openclaw"]
|
||||
```
|
||||
|
||||
## Routing Rules
|
||||
|
||||
- App runtime requests use `https://xworkmate-bridge.svc.plus/acp/rpc`.
|
||||
- Provider and gateway selection are passed as request params, including `provider`, `routing`, and `requestedExecutionTarget`.
|
||||
- Bridge-owned mapping:
|
||||
- `Hermes` -> `https://xworkmate-bridge.svc.plus/acp-server/hermes`
|
||||
- `Codex` -> `https://xworkmate-bridge.svc.plus/acp-server/codex`
|
||||
- `OpenCode` -> `https://xworkmate-bridge.svc.plus/acp-server/opencode`
|
||||
- `Gemini` -> `https://xworkmate-bridge.svc.plus/acp-server/gemini`
|
||||
- `OpenClaw` -> `https://xworkmate-bridge.svc.plus/gateway/openclaw`
|
||||
- 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/openclaw` URLs.
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
## 1. 架构概览 (Unified Routing Architecture)
|
||||
|
||||
当前系统采用 `xworkmate-bridge.svc.plus` 作为统一入口,通过 Caddy 进行流量分发与强制鉴权。
|
||||
当前系统采用 `xworkmate-bridge.svc.plus` 作为统一入口。App 侧只通过 managed bridge ACP 主入口发送任务,provider / gateway 的 public mapping 由 bridge 后端拥有。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
@ -14,42 +14,41 @@ graph TD
|
||||
Bridge_Domain["https://xworkmate-bridge.svc.plus"]
|
||||
end
|
||||
|
||||
subgraph "Backend Services (Localhost)"
|
||||
ManagedBridge["Managed Bridge Core<br/>(Port 8787 / Docker)"]
|
||||
CodexProvider["Codex ACP Server<br/>(Port 9010 / Systemd)"]
|
||||
OpenCodeProvider["OpenCode ACP Server<br/>(Port 3910 / Systemd)"]
|
||||
GeminiAdapter["Gemini ACP Adapter<br/>(Port 8791 / Systemd)"]
|
||||
OpenClawGateway["OpenClaw Gateway<br/>(Port 18789 / Process)"]
|
||||
subgraph "Bridge-owned Routing"
|
||||
ManagedBridge["Managed Bridge ACP<br/>/acp/rpc"]
|
||||
CodexProvider["Codex map<br/>/acp-server/codex"]
|
||||
OpenCodeProvider["OpenCode map<br/>/acp-server/opencode"]
|
||||
GeminiAdapter["Gemini map<br/>/acp-server/gemini"]
|
||||
OpenClawGateway["OpenClaw map<br/>/gateway/openclaw"]
|
||||
end
|
||||
|
||||
%% Routing Rules
|
||||
Client -->|HTTPS/WSS| Bridge_Domain
|
||||
|
||||
Bridge_Domain -->|/| ManagedBridge
|
||||
Bridge_Domain -->|/acp-server/codex/| CodexProvider
|
||||
Bridge_Domain -->|/acp-server/opencode/| OpenCodeProvider
|
||||
Bridge_Domain -->|/acp-server/gemini/| GeminiAdapter
|
||||
Bridge_Domain -->|/gateway/openclaw/| OpenClawGateway
|
||||
Bridge_Domain -->|/acp/rpc| ManagedBridge
|
||||
ManagedBridge -->|provider routing| CodexProvider
|
||||
ManagedBridge -->|provider routing| OpenCodeProvider
|
||||
ManagedBridge -->|provider routing| GeminiAdapter
|
||||
ManagedBridge -->|gateway routing| OpenClawGateway
|
||||
|
||||
%% Service Connections
|
||||
ManagedBridge -.->|Capabilities Discovery| Client
|
||||
OpenClawGateway <-->|WSS| Client
|
||||
```
|
||||
|
||||
## 2. 路由分发规则
|
||||
|
||||
| 统一路径 | 转发目标 | 协议类型 | 备注 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `/` | `127.0.0.1:8787` | REST/RPC | Managed Bridge 核心,提供能力发现 |
|
||||
| `/acp-server/codex/` | `127.0.0.1:9010` | JSON-RPC (SSE) | 映射至 Codex Provider |
|
||||
| `/acp-server/opencode/` | `127.0.0.1:3910` | JSON-RPC (SSE) | 映射至 OpenCode Provider |
|
||||
| `/acp-server/gemini/` | `127.0.0.1:8791` | JSON-RPC (SSE) | 映射至 Gemini Adapter |
|
||||
| `/gateway/openclaw/` | `127.0.0.1:18789` | WSS / RPC | 映射至 OpenClaw Gateway |
|
||||
| Bridge-owned mapping | App 侧行为 | 备注 |
|
||||
| :--- | :--- | :--- |
|
||||
| `/acp/rpc` | 直接调用 | Managed Bridge ACP 主入口,提供能力发现与任务发送 |
|
||||
| `/acp-server/codex` | 不直连 | Bridge 后端映射至 Codex Provider |
|
||||
| `/acp-server/opencode` | 不直连 | Bridge 后端映射至 OpenCode Provider |
|
||||
| `/acp-server/gemini` | 不直连 | Bridge 后端映射至 Gemini Adapter |
|
||||
| `/gateway/openclaw` | 不直连 | Bridge 后端映射至 OpenClaw Gateway |
|
||||
|
||||
## 3. 运维配置优化
|
||||
|
||||
### 3.1 统一鉴权
|
||||
所有通过 `xworkmate-bridge.svc.plus` 域名访问的请求(除 Caddy 内部 handle 外)均由 Caddy 强制校验:
|
||||
App 发往 `xworkmate-bridge.svc.plus/acp/rpc` 的请求必须携带:
|
||||
- **Header**: `Authorization: Bearer <bridge-auth-token>`
|
||||
- **未授权响应**: `401 Unauthorized`
|
||||
|
||||
@ -62,9 +61,8 @@ graph TD
|
||||
- **容器路径**: `/app/logs`
|
||||
- **轮转策略**: 单文件 50MB,保留最近 3 个文件。
|
||||
|
||||
## 4. 后端服务启动参考
|
||||
## 4. App 侧不变量
|
||||
|
||||
- **Codex**: `/usr/local/bin/xworkmate-go-core serve --listen 127.0.0.1:9010`
|
||||
- **OpenCode**: `/usr/local/bin/xworkmate-go-core serve --listen 127.0.0.1:3910`
|
||||
- **Gemini**: `/usr/local/bin/xworkmate-go-core gemini-acp-adapter --listen 127.0.0.1:8791 ...`
|
||||
- **Gateway**: `openclaw-gateway run` (Port 18789)
|
||||
- App 不写入或拼接本地 provider endpoint。
|
||||
- App 不直接调用 `/acp-server/*` 或 `/gateway/openclaw`。
|
||||
- `acp.capabilities` 是 provider catalog、gateway catalog、available execution targets 的唯一来源。
|
||||
|
||||
@ -60,20 +60,20 @@
|
||||
- 测试连接结果摘要
|
||||
- 截图点:测试连接结果
|
||||
|
||||
### `MANUAL-ACP-003` local ACP / local 模式接入
|
||||
### `MANUAL-ACP-003` managed bridge ACP 接入
|
||||
|
||||
- 前置条件
|
||||
- 本机已有 local / loopback ACP 服务
|
||||
- 确认监听地址与端口
|
||||
- 账号同步已返回 managed bridge endpoint
|
||||
- bridge token 已配置
|
||||
- 操作步骤
|
||||
1. 输入 loopback endpoint,例如 `http://127.0.0.1:9001/opencode`
|
||||
1. 登录 svc.plus 并同步 bridge profile
|
||||
2. 点击 `测试连接`
|
||||
3. 保存并生效
|
||||
4. 关闭设置页后重新进入确认仍然显示 local endpoint
|
||||
4. 关闭设置页后重新进入确认仍然显示 managed bridge endpoint
|
||||
- 期望结果
|
||||
- local / loopback 非 TLS 允许通过
|
||||
- 页面明确显示当前为本地配置
|
||||
- 不会把 local endpoint 错误识别为 remote insecure endpoint
|
||||
- App 侧任务发送只使用 managed bridge ACP 主入口
|
||||
- provider catalog 与 gateway provider 来自 `acp.capabilities`
|
||||
- 不会写入或拼接 local / loopback provider endpoint
|
||||
- 建议记录项
|
||||
- 当前模式
|
||||
- loopback endpoint
|
||||
|
||||
@ -1,129 +0,0 @@
|
||||
# 外部 ACP Endpoint 预配置脚本
|
||||
|
||||
这个工具是一个**外置 pre 动作**:
|
||||
|
||||
```bash
|
||||
dart tool/configure_external_acp.dart
|
||||
```
|
||||
|
||||
它只负责生成或更新 XWorkmate `settings.yaml` 里的 `externalAcpEndpoints`。
|
||||
|
||||
它**不**做这些事:
|
||||
|
||||
- 不修改 Flutter runtime 代码
|
||||
- 不往 `.app` bundle、DMG 或打包脚本写任何内容
|
||||
- 不启动任何外部 provider、bridge、daemon 或 CLI
|
||||
- 不写入 token、password、API key 等 secrets
|
||||
|
||||
## App Store 对齐边界
|
||||
|
||||
当前脚本按 App Store 边界收敛为“纯配置助手”:
|
||||
|
||||
- 脚本在 app 外运行
|
||||
- 只写用户态配置文件
|
||||
- 不再内置或推荐任何第三方 bridge 依赖
|
||||
- Claude / Gemini 在这里只是 endpoint 槽位,不绑定特定实现
|
||||
|
||||
如果某个 provider 以后要接入,要求是:
|
||||
|
||||
- 由你自行准备一个兼容的外部 endpoint
|
||||
- XWorkmate 只消费 endpoint,不负责拉起依赖
|
||||
|
||||
## 默认 provider 槽位
|
||||
|
||||
| Provider | 默认 endpoint |
|
||||
| --- | --- |
|
||||
| Codex | `ws://127.0.0.1:9001` |
|
||||
| OpenCode | `http://127.0.0.1:4096` |
|
||||
| Claude | `ws://127.0.0.1:9011` |
|
||||
| Gemini | `ws://127.0.0.1:9012` |
|
||||
|
||||
说明:
|
||||
|
||||
- 这些值只是默认槽位,不代表脚本会安装或启动任何 provider。
|
||||
- `Codex` / `OpenCode` 的本地地址被保留为示例默认值。
|
||||
- `Claude` / `Gemini` 仅保留 endpoint 占位,不再绑定第三方桥接包说明。
|
||||
- ACP contract 的规范路径统一为 `/acp` 与 `/acp/rpc`。
|
||||
- local / loopback 可使用 `ws://` 或 `http://`。
|
||||
- remote endpoint 必须使用 `wss://` 或 `https://`,不能静默降级到非 TLS。
|
||||
|
||||
## macOS 路径策略
|
||||
|
||||
macOS 默认增加了 App Sandbox 感知:
|
||||
|
||||
- `--settings-scope auto`
|
||||
优先写 `~/Library/Containers/plus.svc.xworkmate/Data/Library/Application Support/xworkmate/config/settings.yaml`
|
||||
如果容器目录还不存在,再退回 `~/Library/Application Support/xworkmate/config/settings.yaml`
|
||||
- `--settings-scope sandbox`
|
||||
强制写 App Sandbox 容器路径
|
||||
- `--settings-scope user`
|
||||
强制写非沙盒用户目录路径
|
||||
|
||||
这让脚本既能服务 Mac App Store 安装版,也保留非沙盒构建的旧路径。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 在仓库根目录执行
|
||||
- 首次在新 clone 上使用前,先跑一次 `flutter pub get`
|
||||
|
||||
## 常用命令
|
||||
|
||||
查看将使用哪个配置文件,以及要写入哪些 endpoint:
|
||||
|
||||
```bash
|
||||
dart tool/configure_external_acp.dart print
|
||||
```
|
||||
|
||||
按自动路径策略写入:
|
||||
|
||||
```bash
|
||||
dart tool/configure_external_acp.dart apply
|
||||
```
|
||||
|
||||
强制写入 Mac App Store 容器路径:
|
||||
|
||||
```bash
|
||||
dart tool/configure_external_acp.dart apply --settings-scope sandbox
|
||||
```
|
||||
|
||||
强制写入旧的用户目录路径:
|
||||
|
||||
```bash
|
||||
dart tool/configure_external_acp.dart apply --settings-scope user
|
||||
```
|
||||
|
||||
指定自定义 endpoint:
|
||||
|
||||
```bash
|
||||
dart tool/configure_external_acp.dart apply \
|
||||
--codex-endpoint ws://127.0.0.1:9001 \
|
||||
--opencode-endpoint http://127.0.0.1:4096 \
|
||||
--claude-endpoint ws://127.0.0.1:19111 \
|
||||
--gemini-endpoint ws://127.0.0.1:19112
|
||||
```
|
||||
|
||||
协议边界:
|
||||
|
||||
- 如果你提供的是 base URL,运行时应派生:
|
||||
- websocket endpoint:`/acp`
|
||||
- RPC endpoint:`/acp/rpc`
|
||||
- 如果你提供的 URL 已经包含 `/acp` 或 `/acp/rpc`,运行时不得重复拼接。
|
||||
|
||||
只打印结果 YAML,不落盘:
|
||||
|
||||
```bash
|
||||
dart tool/configure_external_acp.dart apply --dry-run
|
||||
```
|
||||
|
||||
禁用某个槽位:
|
||||
|
||||
```bash
|
||||
dart tool/configure_external_acp.dart apply --disable-claude
|
||||
```
|
||||
|
||||
## 兼容性边界
|
||||
|
||||
- 这个脚本只负责 `externalAcpEndpoints`
|
||||
- 它会保留非内置 custom provider 条目
|
||||
- 它不会判断某个 endpoint 背后是否真的可用
|
||||
- 它不会绕过 XWorkmate 在 App Store 构建里对外部 CLI / 本地 runtime 的禁用策略
|
||||
@ -599,15 +599,7 @@ class AppController extends ChangeNotifier {
|
||||
if (p.providerId == normalizedId) return p;
|
||||
}
|
||||
|
||||
// If not in catalog but we have an ID, return a synthetic provider to allow routing
|
||||
return SingleAgentProvider(
|
||||
providerId: normalizedId,
|
||||
label: providerFallbackLabelInternal(normalizedId),
|
||||
badge: providerFallbackBadgeInternal(
|
||||
providerId: normalizedId,
|
||||
label: providerFallbackLabelInternal(normalizedId),
|
||||
),
|
||||
);
|
||||
return SingleAgentProvider.unspecified;
|
||||
}
|
||||
return (defaultToCatalog && catalog.isNotEmpty)
|
||||
? catalog.first
|
||||
|
||||
@ -638,14 +638,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
Uri? resolveBridgeAcpEndpointInternal() {
|
||||
final modeConfig = settings.acpBridgeServerModeConfig;
|
||||
|
||||
// Prioritize BRIDGE_SERVER_URL from environment or override
|
||||
final envEndpoint = runtimeEnvironmentValueInternal('BRIDGE_SERVER_URL');
|
||||
if (envEndpoint != null && isSupportedExternalAcpEndpoint(envEndpoint)) {
|
||||
final uri = Uri.tryParse(envEndpoint);
|
||||
if (uri != null) return uri.replace(query: null, fragment: null);
|
||||
}
|
||||
|
||||
// Prioritize the cloud endpoint if available or if we're connected to svc.plus
|
||||
final cloudEndpoint = _activeCloudSyncedBridgeEndpointInternal();
|
||||
if (cloudEndpoint.isNotEmpty) {
|
||||
final uri = Uri.tryParse(cloudEndpoint);
|
||||
@ -657,7 +649,8 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
if (candidate.isNotEmpty) {
|
||||
final uri = Uri.tryParse(candidate);
|
||||
final scheme = uri?.scheme.trim().toLowerCase() ?? '';
|
||||
if (uri != null && kSupportedExternalAcpEndpointSchemes.contains(scheme)) {
|
||||
if (uri != null &&
|
||||
kSupportedExternalAcpEndpointSchemes.contains(scheme)) {
|
||||
return uri.replace(query: null, fragment: null);
|
||||
}
|
||||
}
|
||||
@ -681,32 +674,23 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
Uri? resolveExternalAcpEndpointForRequestInternal(
|
||||
GoTaskServiceRequest request,
|
||||
) {
|
||||
final bridgeEndpoint = resolveBridgeAcpEndpointInternal();
|
||||
final providerId = request.target.isGateway
|
||||
? kCanonicalGatewayProviderId
|
||||
: request.provider.providerId.trim();
|
||||
if (providerId.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return resolveBridgeProviderBaseEndpoint(
|
||||
bridgeEndpoint,
|
||||
providerId: providerId,
|
||||
gateway: request.target.isGateway,
|
||||
);
|
||||
return resolveBridgeAcpEndpointInternal();
|
||||
}
|
||||
|
||||
String _activeCloudSyncedBridgeEndpointInternal() {
|
||||
final syncState = settingsControllerInternal.accountSyncState;
|
||||
final syncedEndpoint = syncState?.syncedDefaults.bridgeServerUrl.trim() ?? '';
|
||||
final syncedEndpoint =
|
||||
syncState?.syncedDefaults.bridgeServerUrl.trim() ?? '';
|
||||
|
||||
// If sync is ready and configured, use it.
|
||||
if (syncState?.syncState.trim().toLowerCase() == 'ready' &&
|
||||
syncState?.tokenConfigured.bridge == true &&
|
||||
syncedEndpoint.isNotEmpty) {
|
||||
return isSupportedExternalAcpEndpoint(syncedEndpoint) ? syncedEndpoint : '';
|
||||
return isSupportedExternalAcpEndpoint(syncedEndpoint)
|
||||
? syncedEndpoint
|
||||
: '';
|
||||
}
|
||||
|
||||
return isSupportedExternalAcpEndpoint(syncedEndpoint) ? syncedEndpoint : '';
|
||||
return '';
|
||||
}
|
||||
|
||||
Uri? gatewayProfileBaseUriInternal(GatewayConnectionProfile profile) {
|
||||
|
||||
@ -77,39 +77,3 @@ Uri? resolveAcpHttpRpcEndpoint(Uri? endpoint) {
|
||||
final paths = AcpEndpointPaths.fromBaseEndpoint(endpoint);
|
||||
return endpoint.replace(path: paths.httpRpcPath, query: null, fragment: null);
|
||||
}
|
||||
|
||||
Uri? resolveBridgeProviderBaseEndpoint(
|
||||
Uri? bridgeBaseEndpoint, {
|
||||
required String providerId,
|
||||
required bool gateway,
|
||||
}) {
|
||||
if (bridgeBaseEndpoint == null || bridgeBaseEndpoint.host.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final normalizedProviderId = providerId.trim().toLowerCase();
|
||||
if (normalizedProviderId.isEmpty) {
|
||||
return bridgeBaseEndpoint.replace(query: null, fragment: null);
|
||||
}
|
||||
|
||||
// Remove trailing slashes and common ACP suffixes from the base path to avoid double-nesting
|
||||
var basePath = bridgeBaseEndpoint.path.trim().replaceFirst(
|
||||
RegExp(r'/+$'),
|
||||
'',
|
||||
);
|
||||
if (basePath.endsWith('/acp/rpc')) {
|
||||
basePath = basePath.substring(0, basePath.length - '/acp/rpc'.length);
|
||||
} else if (basePath.endsWith('/acp')) {
|
||||
basePath = basePath.substring(0, basePath.length - '/acp'.length);
|
||||
}
|
||||
basePath = basePath.replaceFirst(RegExp(r'/+$'), '');
|
||||
|
||||
final providerPath = gateway
|
||||
? '$basePath/acp-server/gateway/$normalizedProviderId'
|
||||
: '$basePath/acp-server/$normalizedProviderId';
|
||||
|
||||
return bridgeBaseEndpoint.replace(
|
||||
path: providerPath.replaceFirst(RegExp(r'^//+'), '/'),
|
||||
query: null,
|
||||
fragment: null,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,11 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
class ArisLlmChatClient {
|
||||
ArisLlmChatClient({
|
||||
Duration rpcTimeout = const Duration(minutes: 2),
|
||||
}) : _rpcTimeout = rpcTimeout;
|
||||
|
||||
final Duration _rpcTimeout;
|
||||
ArisLlmChatClient({Duration rpcTimeout = const Duration(minutes: 2)});
|
||||
|
||||
Future<String> chat({
|
||||
required String endpoint,
|
||||
@ -41,7 +37,7 @@ class ArisLlmChatClient {
|
||||
}) async {
|
||||
// Local Go core execution is deprecated in favor of bridge-mediated execution.
|
||||
throw UnsupportedError(
|
||||
'Local Go core execution is disabled. Use bridge endpoints like /acp-server/hermes instead.',
|
||||
'Local Go core execution is disabled. Use the managed bridge ACP runtime instead.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,8 +4,6 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../app/app_metadata.dart';
|
||||
import 'embedded_agent_launch_policy.dart';
|
||||
import 'platform_environment.dart';
|
||||
|
||||
/// Codex sandbox mode for controlling file system access.
|
||||
@ -353,176 +351,6 @@ class CodexRuntime extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
static String _lookupExecutableProgram({String? operatingSystem}) {
|
||||
return detectRuntimeHostPlatform(operatingSystem: operatingSystem) ==
|
||||
RuntimeHostPlatform.windows
|
||||
? 'where'
|
||||
: 'which';
|
||||
}
|
||||
|
||||
static List<String> _lookupExecutableArguments() {
|
||||
return const <String>['codex'];
|
||||
}
|
||||
|
||||
void _setupStdioStreams() {
|
||||
final process = _process!;
|
||||
final stdoutLines = <String>[];
|
||||
final stderrLines = <String>[];
|
||||
|
||||
// stdout: JSON-RPC message stream (may have interleaved log lines)
|
||||
_stdoutSubscription = process.stdout
|
||||
.transform(utf8.decoder)
|
||||
.transform(LineSplitter())
|
||||
.listen(
|
||||
(line) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isEmpty) return;
|
||||
|
||||
// Try to parse as JSON-RPC
|
||||
if (trimmed.startsWith('{')) {
|
||||
_handleMessage(trimmed);
|
||||
} else {
|
||||
// Non-JSON output, emit as log
|
||||
stdoutLines.add(trimmed);
|
||||
if (stdoutLines.length > 100) stdoutLines.removeAt(0);
|
||||
_events.add(
|
||||
CodexLogEvent(
|
||||
level: 'debug',
|
||||
message: trimmed,
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
_events.add(
|
||||
CodexLogEvent(
|
||||
level: 'error',
|
||||
message: 'stdout error: $error',
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// stderr: Log output
|
||||
_stderrSubscription = process.stderr
|
||||
.transform(utf8.decoder)
|
||||
.transform(LineSplitter())
|
||||
.listen(
|
||||
(line) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isEmpty) return;
|
||||
|
||||
stderrLines.add(trimmed);
|
||||
if (stderrLines.length > 100) stderrLines.removeAt(0);
|
||||
|
||||
_events.add(
|
||||
CodexLogEvent(
|
||||
level: 'info',
|
||||
message: trimmed,
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (error) {
|
||||
_events.add(
|
||||
CodexLogEvent(
|
||||
level: 'error',
|
||||
message: 'stderr error: $error',
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Handle process exit
|
||||
process.exitCode.then((exitCode) {
|
||||
_events.add(
|
||||
CodexLogEvent(
|
||||
level: exitCode == 0 ? 'info' : 'warn',
|
||||
message: 'Codex exited with code $exitCode',
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
_process = null;
|
||||
_state = CodexConnectionState.disconnected;
|
||||
_isInitialized = false;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
_state = CodexConnectionState.initializing;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final result = await request(
|
||||
'initialize',
|
||||
params: {
|
||||
'clientInfo': {'name': 'xworkmate', 'version': kAppVersion},
|
||||
'capabilities': {'optOutNotificationMethods': []},
|
||||
},
|
||||
);
|
||||
|
||||
// Store any account info from response
|
||||
if (result.containsKey('account')) {
|
||||
_account = CodexAccount.fromJson(
|
||||
result['account'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
// Send initialized notification
|
||||
await _sendNotification('initialized', params: {});
|
||||
|
||||
_isInitialized = true;
|
||||
_state = CodexConnectionState.ready;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_state = CodexConnectionState.error;
|
||||
_lastError = e.toString();
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleMessage(String line) {
|
||||
try {
|
||||
final json = jsonDecode(line) as Map<String, dynamic>;
|
||||
|
||||
if (json.containsKey('id') && json.containsKey('result')) {
|
||||
// Success response
|
||||
final id = json['id'].toString();
|
||||
final completer = _pendingRequests.remove(id);
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.complete(json['result'] as Map<String, dynamic>);
|
||||
}
|
||||
} else if (json.containsKey('id') && json.containsKey('error')) {
|
||||
// Error response
|
||||
final id = json['id'].toString();
|
||||
final completer = _pendingRequests.remove(id);
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.completeError(
|
||||
CodexRpcError.fromJson(json['error'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
} else if (json.containsKey('method')) {
|
||||
// Notification
|
||||
final method = json['method'] as String;
|
||||
final params = json['params'] as Map<String, dynamic>? ?? {};
|
||||
_events.add(CodexNotificationEvent(method: method, params: params));
|
||||
}
|
||||
} catch (e) {
|
||||
_events.add(
|
||||
CodexLogEvent(
|
||||
level: 'warn',
|
||||
message: 'Failed to parse message: $e',
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send RPC request and wait for response.
|
||||
Future<Map<String, dynamic>> request(
|
||||
String method, {
|
||||
@ -556,25 +384,6 @@ class CodexRuntime extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
/// Send notification (no response expected).
|
||||
Future<void> _sendNotification(
|
||||
String method, {
|
||||
required Map<String, dynamic> params,
|
||||
}) async {
|
||||
final process = _process;
|
||||
if (process == null) {
|
||||
throw StateError('Codex not running');
|
||||
}
|
||||
|
||||
final message = jsonEncode({
|
||||
'jsonrpc': '2.0',
|
||||
'method': method,
|
||||
'params': params,
|
||||
});
|
||||
|
||||
process.stdin.writeln(message);
|
||||
}
|
||||
|
||||
/// Create a new thread.
|
||||
Future<CodexThread> startThread({
|
||||
required String cwd,
|
||||
|
||||
@ -1,6 +1,3 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'codex_config_bridge.dart';
|
||||
import 'multi_agent_mount_resolver.dart';
|
||||
import 'opencode_config_bridge.dart';
|
||||
@ -54,10 +51,7 @@ class MultiAgentMountManager {
|
||||
if (resolved != null) {
|
||||
return resolved;
|
||||
}
|
||||
return _reconcileLocally(
|
||||
config: config,
|
||||
aiGatewayUrl: aiGatewayUrl,
|
||||
);
|
||||
return _reconcileLocally(config: config, aiGatewayUrl: aiGatewayUrl);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
@ -82,10 +76,7 @@ class MultiAgentMountManager {
|
||||
final states = <ManagedMountTargetState>[];
|
||||
for (final adapter in _adapters) {
|
||||
states.add(
|
||||
await adapter.reconcile(
|
||||
config: config,
|
||||
aiGatewayUrl: aiGatewayUrl,
|
||||
),
|
||||
await adapter.reconcile(config: config, aiGatewayUrl: aiGatewayUrl),
|
||||
);
|
||||
}
|
||||
return config.copyWith(
|
||||
@ -119,9 +110,7 @@ abstract class CliMountAdapter {
|
||||
}
|
||||
|
||||
class CodexMountAdapter extends CliMountAdapter {
|
||||
CodexMountAdapter(this._bridge);
|
||||
|
||||
final CodexConfigBridge _bridge;
|
||||
CodexMountAdapter(CodexConfigBridge bridge);
|
||||
|
||||
@override
|
||||
String get targetId => 'codex';
|
||||
@ -156,7 +145,8 @@ class CodexMountAdapter extends CliMountAdapter {
|
||||
available: false,
|
||||
discoveryState: 'missing',
|
||||
syncState: 'missing',
|
||||
detail: 'Local CLI interaction is disabled. Use bridge for orchestration.',
|
||||
detail:
|
||||
'Local CLI interaction is disabled. Use bridge for orchestration.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -240,9 +230,7 @@ class GeminiMountAdapter extends CliMountAdapter {
|
||||
}
|
||||
|
||||
class OpencodeMountAdapter extends CliMountAdapter {
|
||||
OpencodeMountAdapter(this._bridge);
|
||||
|
||||
final OpencodeConfigBridge _bridge;
|
||||
OpencodeMountAdapter(OpencodeConfigBridge bridge);
|
||||
|
||||
@override
|
||||
String get targetId => 'opencode';
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/app/app_controller.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
|
||||
void main() {
|
||||
group('Assistant connection state', () {
|
||||
test(
|
||||
'keeps signed-out sessions disconnected even when provider catalogs exist',
|
||||
() async {
|
||||
final controller = AppController(
|
||||
final controller = await _isolatedController(
|
||||
initialBridgeProviderCatalog: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.codex,
|
||||
],
|
||||
@ -29,12 +32,12 @@ void main() {
|
||||
final state = controller.currentAssistantConnectionState;
|
||||
expect(state.connected, isFalse);
|
||||
expect(state.status, RuntimeConnectionStatus.offline);
|
||||
expect(state.detailLabel, 'xworkmate-bridge 未连接');
|
||||
expect(state.detailLabel, '请先登录 svc.plus');
|
||||
},
|
||||
);
|
||||
|
||||
test('keeps signed-out generic runtime failures disconnected', () async {
|
||||
final controller = AppController();
|
||||
final controller = await _isolatedController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.sessionsController.switchSession('session-1');
|
||||
@ -56,12 +59,12 @@ void main() {
|
||||
|
||||
final state = controller.currentAssistantConnectionState;
|
||||
expect(state.status, RuntimeConnectionStatus.offline);
|
||||
expect(state.primaryLabel, '离线');
|
||||
expect(state.detailLabel, 'xworkmate-bridge 未连接');
|
||||
expect(state.primaryLabel, '已退出登录');
|
||||
expect(state.detailLabel, '请先登录 svc.plus');
|
||||
});
|
||||
|
||||
test('keeps true offline state as bridge not connected', () async {
|
||||
final controller = AppController();
|
||||
final controller = await _isolatedController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.sessionsController.switchSession('session-1');
|
||||
@ -76,14 +79,14 @@ void main() {
|
||||
|
||||
final state = controller.currentAssistantConnectionState;
|
||||
expect(state.status, RuntimeConnectionStatus.offline);
|
||||
expect(state.primaryLabel, '离线');
|
||||
expect(state.detailLabel, 'xworkmate-bridge 未连接');
|
||||
expect(state.primaryLabel, '已退出登录');
|
||||
expect(state.detailLabel, '请先登录 svc.plus');
|
||||
});
|
||||
|
||||
test(
|
||||
'keeps signed-out generic failures without address disconnected',
|
||||
() async {
|
||||
final controller = AppController();
|
||||
final controller = await _isolatedController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.sessionsController.switchSession('session-1');
|
||||
@ -105,15 +108,15 @@ void main() {
|
||||
|
||||
final state = controller.currentAssistantConnectionState;
|
||||
expect(state.status, RuntimeConnectionStatus.offline);
|
||||
expect(state.primaryLabel, '离线');
|
||||
expect(state.detailLabel, 'xworkmate-bridge 未连接');
|
||||
expect(state.primaryLabel, '已退出登录');
|
||||
expect(state.detailLabel, '请先登录 svc.plus');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'keeps gateway token missing as dedicated app-visible state',
|
||||
() async {
|
||||
final controller = AppController();
|
||||
final controller = await _isolatedController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.sessionsController.switchSession('session-1');
|
||||
@ -135,15 +138,15 @@ void main() {
|
||||
|
||||
final state = controller.currentAssistantConnectionState;
|
||||
expect(state.status, RuntimeConnectionStatus.offline);
|
||||
expect(state.primaryLabel, '离线');
|
||||
expect(state.detailLabel, 'xworkmate-bridge 未连接');
|
||||
expect(state.primaryLabel, '已退出登录');
|
||||
expect(state.detailLabel, '请先登录 svc.plus');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'treats missing endpoint as true offline instead of bridge failure',
|
||||
() async {
|
||||
final controller = AppController();
|
||||
final controller = await _isolatedController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.sessionsController.switchSession('session-1');
|
||||
@ -164,13 +167,13 @@ void main() {
|
||||
|
||||
final state = controller.currentAssistantConnectionState;
|
||||
expect(state.status, RuntimeConnectionStatus.offline);
|
||||
expect(state.primaryLabel, '离线');
|
||||
expect(state.detailLabel, 'xworkmate-bridge 未连接');
|
||||
expect(state.primaryLabel, '已退出登录');
|
||||
expect(state.detailLabel, '请先登录 svc.plus');
|
||||
},
|
||||
);
|
||||
|
||||
test('desktop snapshot uses derived assistant connection labels', () async {
|
||||
final controller = AppController();
|
||||
final controller = await _isolatedController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.sessionsController.switchSession('session-1');
|
||||
@ -191,7 +194,39 @@ void main() {
|
||||
|
||||
final snapshot = controller.desktopStatusSnapshot();
|
||||
expect(snapshot['connectionStatus'], 'disconnected');
|
||||
expect(snapshot['connectionLabel'], '离线');
|
||||
expect(snapshot['connectionLabel'], '已退出登录');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<AppController> _isolatedController({
|
||||
List<SingleAgentProvider>? initialBridgeProviderCatalog,
|
||||
List<SingleAgentProvider>? initialGatewayProviderCatalog,
|
||||
List<AssistantExecutionTarget>? initialAvailableExecutionTargets,
|
||||
}) async {
|
||||
final storeRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-assistant-connection-state-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await storeRoot.exists()) {
|
||||
try {
|
||||
await storeRoot.delete(recursive: true);
|
||||
} on FileSystemException {
|
||||
// Temp cleanup is best effort here.
|
||||
}
|
||||
}
|
||||
});
|
||||
final store = SecureConfigStore(
|
||||
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
|
||||
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
|
||||
supportRootPathResolver: () async => '${storeRoot.path}/support',
|
||||
enableSecureStorage: false,
|
||||
);
|
||||
await store.initialize();
|
||||
return AppController(
|
||||
store: store,
|
||||
initialBridgeProviderCatalog: initialBridgeProviderCatalog,
|
||||
initialGatewayProviderCatalog: initialGatewayProviderCatalog,
|
||||
initialAvailableExecutionTargets: initialAvailableExecutionTargets,
|
||||
);
|
||||
}
|
||||
|
||||
@ -121,7 +121,7 @@ void main() {
|
||||
executionTarget: AssistantExecutionTarget.agent,
|
||||
);
|
||||
|
||||
expect(unavailableProvider, SingleAgentProvider.unspecified);
|
||||
expect(unavailableProvider.isUnspecified, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
@ -142,7 +142,7 @@ void main() {
|
||||
executionTarget: AssistantExecutionTarget.gateway,
|
||||
);
|
||||
|
||||
expect(provider, SingleAgentProvider.unspecified);
|
||||
expect(provider.isUnspecified, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
@ -304,7 +304,9 @@ void main() {
|
||||
|
||||
expect(controller.assistantProviderCatalog, isEmpty);
|
||||
expect(capture.requestCount, lessThanOrEqualTo(requestCountBefore + 2));
|
||||
if (capture.requestCount > requestCountBefore) {
|
||||
expect(capture.lastAuthorizationHeader, 'Bearer bridge-token');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@ -312,8 +314,30 @@ void main() {
|
||||
'sendChatMessage fails locally without bridge sync token and does not execute ACP task',
|
||||
() async {
|
||||
final fakeGoTaskService = _RecordingGoTaskServiceClient();
|
||||
final storeRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-missing-bridge-token-send-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await storeRoot.exists()) {
|
||||
try {
|
||||
await storeRoot.delete(recursive: true);
|
||||
} on FileSystemException {
|
||||
// Temp cleanup is best effort here.
|
||||
}
|
||||
}
|
||||
});
|
||||
final store = SecureConfigStore(
|
||||
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
|
||||
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
|
||||
supportRootPathResolver: () async => '${storeRoot.path}/support',
|
||||
enableSecureStorage: false,
|
||||
);
|
||||
await store.initialize();
|
||||
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
goTaskServiceClient: fakeGoTaskService,
|
||||
environmentOverride: const <String, String>{},
|
||||
initialBridgeProviderCatalog: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.codex,
|
||||
],
|
||||
|
||||
@ -317,7 +317,7 @@ void main() {
|
||||
);
|
||||
|
||||
test(
|
||||
'desktop task execution routes Hermes through provider public endpoint',
|
||||
'desktop task execution routes Hermes through bridge RPC with provider params',
|
||||
() async {
|
||||
final capture = await _startAcpHttpServer();
|
||||
addTearDown(capture.close);
|
||||
@ -343,12 +343,19 @@ void main() {
|
||||
);
|
||||
|
||||
expect(capture.authorizationHeader, 'Bearer bridge-token');
|
||||
expect(capture.requestPath, '/acp-server/hermes/acp/rpc');
|
||||
expect(capture.requestPath, '/acp/rpc');
|
||||
expect(capture.requestPath, isNot(contains('/acp-server')));
|
||||
expect(capture.requestPath, isNot(contains('/gateway/openclaw')));
|
||||
expect(capture.requestBody, contains('"provider":"hermes"'));
|
||||
expect(
|
||||
capture.requestBody,
|
||||
contains('"requestedExecutionTarget":"agent"'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'desktop task execution routes OpenClaw through gateway public endpoint',
|
||||
'desktop task execution routes OpenClaw through bridge RPC with gateway params',
|
||||
() async {
|
||||
final capture = await _startAcpHttpServer();
|
||||
addTearDown(capture.close);
|
||||
@ -374,7 +381,15 @@ void main() {
|
||||
);
|
||||
|
||||
expect(capture.authorizationHeader, 'Bearer bridge-token');
|
||||
expect(capture.requestPath, '/gateway/openclaw/acp/rpc');
|
||||
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')));
|
||||
expect(capture.requestBody, contains('"provider":"openclaw"'));
|
||||
expect(
|
||||
capture.requestBody,
|
||||
contains('"requestedExecutionTarget":"gateway"'),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@ -454,6 +469,7 @@ Future<_CapturedAcpHttpServer> _startAcpHttpServer() async {
|
||||
request.headers.value(HttpHeaders.authorizationHeader) ?? '';
|
||||
capture.requestPath = request.uri.path;
|
||||
final body = await utf8.decoder.bind(request).join();
|
||||
capture.requestBody = body;
|
||||
final id = _decodeRequestId(body);
|
||||
request.response.headers.contentType = ContentType.json;
|
||||
request.response.write(
|
||||
@ -483,6 +499,7 @@ class _CapturedAcpHttpServer {
|
||||
final Uri baseEndpoint;
|
||||
String authorizationHeader = '';
|
||||
String requestPath = '';
|
||||
String requestBody = '';
|
||||
|
||||
Future<void> close() => _server.close(force: true);
|
||||
}
|
||||
|
||||
@ -1,562 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
const String _macosBundleIdentifier = 'plus.svc.xworkmate';
|
||||
|
||||
const Map<String, String> _providerLabels = <String, String>{
|
||||
'codex': 'Codex',
|
||||
'opencode': 'OpenCode',
|
||||
'claude': 'Claude',
|
||||
'gemini': 'Gemini',
|
||||
};
|
||||
|
||||
const Map<String, String> _defaultEndpoints = <String, String>{
|
||||
'codex': 'ws://127.0.0.1:9001',
|
||||
'opencode': 'http://127.0.0.1:4096',
|
||||
'claude': 'ws://127.0.0.1:9011',
|
||||
'gemini': 'ws://127.0.0.1:9012',
|
||||
};
|
||||
|
||||
void main(List<String> args) async {
|
||||
final options = _CliOptions.parse(args);
|
||||
if (options.showHelp) {
|
||||
stdout.write(_usage());
|
||||
exit(0);
|
||||
}
|
||||
|
||||
final settingsFile =
|
||||
options.settingsFile ??
|
||||
_defaultSettingsFile(
|
||||
environment: Platform.environment,
|
||||
operatingSystem: Platform.operatingSystem,
|
||||
scope: options.settingsScope,
|
||||
);
|
||||
final resolvedEndpoints = <String, String>{
|
||||
for (final entry in _defaultEndpoints.entries)
|
||||
entry.key: options.endpoints[entry.key] ?? entry.value,
|
||||
};
|
||||
|
||||
if (options.command == _Command.printPlan) {
|
||||
stdout.write(
|
||||
_renderPlan(
|
||||
settingsFile: settingsFile,
|
||||
endpoints: resolvedEndpoints,
|
||||
modeLabel: 'print-only',
|
||||
settingsScope: options.settingsScope,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final existing = await _readExistingSettings(settingsFile);
|
||||
final updated = _mergeExternalAcpEndpoints(
|
||||
existing,
|
||||
endpoints: resolvedEndpoints,
|
||||
enableProviders: options.enableProviders,
|
||||
);
|
||||
|
||||
if (options.dryRun) {
|
||||
stdout.write(encodeYamlDocument(updated));
|
||||
return;
|
||||
}
|
||||
|
||||
await settingsFile.parent.create(recursive: true);
|
||||
if (await settingsFile.exists() && options.backup) {
|
||||
final backupFile = File(
|
||||
'${settingsFile.path}.bak.${DateTime.now().toUtc().millisecondsSinceEpoch}',
|
||||
);
|
||||
await settingsFile.copy(backupFile.path);
|
||||
stdout.writeln('Backup written: ${backupFile.path}');
|
||||
}
|
||||
|
||||
await settingsFile.writeAsString(encodeYamlDocument(updated));
|
||||
stdout.writeln('Updated: ${settingsFile.path}');
|
||||
stdout.write(
|
||||
_renderPlan(
|
||||
settingsFile: settingsFile,
|
||||
endpoints: resolvedEndpoints,
|
||||
modeLabel: 'applied',
|
||||
settingsScope: options.settingsScope,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _usage() {
|
||||
return '''
|
||||
Usage:
|
||||
dart tool/configure_external_acp.dart apply [options]
|
||||
dart tool/configure_external_acp.dart print [options]
|
||||
|
||||
Commands:
|
||||
apply Update XWorkmate settings.yaml externalAcpEndpoints.
|
||||
print Print the resolved endpoint plan.
|
||||
|
||||
Options:
|
||||
--settings-file <path> Override settings.yaml path.
|
||||
--settings-scope <scope> macOS only: auto | sandbox | user.
|
||||
--codex-endpoint <url> Default: ${_defaultEndpoints['codex']}
|
||||
--opencode-endpoint <url> Default: ${_defaultEndpoints['opencode']}
|
||||
--claude-endpoint <url> Default: ${_defaultEndpoints['claude']}
|
||||
--gemini-endpoint <url> Default: ${_defaultEndpoints['gemini']}
|
||||
--disable-codex Mark the Codex slot as disabled.
|
||||
--disable-opencode Mark the OpenCode slot as disabled.
|
||||
--disable-claude Mark the Claude slot as disabled.
|
||||
--disable-gemini Mark the Gemini slot as disabled.
|
||||
--no-backup Skip settings.yaml backup on apply.
|
||||
--dry-run Print the resulting YAML instead of writing it.
|
||||
--help Show this help.
|
||||
|
||||
Notes:
|
||||
- This tool only updates the externalAcpEndpoints block and preserves all
|
||||
other settings keys.
|
||||
- This is a pre-config tool. Starting external providers is out of scope.
|
||||
- App Store-safe usage means running this tool outside the shipped app bundle.
|
||||
- macOS path selection with --settings-scope auto:
|
||||
~/Library/Containers/$_macosBundleIdentifier/Data/Library/Application Support/xworkmate/config/settings.yaml
|
||||
falls back to ~/Library/Application Support/xworkmate/config/settings.yaml
|
||||
- Default Linux settings path:
|
||||
~/.config/xworkmate/config/settings.yaml
|
||||
''';
|
||||
}
|
||||
|
||||
String _renderPlan({
|
||||
required File settingsFile,
|
||||
required Map<String, String> endpoints,
|
||||
required String modeLabel,
|
||||
required _SettingsScope settingsScope,
|
||||
}) {
|
||||
final buffer = StringBuffer()
|
||||
..writeln()
|
||||
..writeln('Settings file: ${settingsFile.path}')
|
||||
..writeln('Mode: $modeLabel')
|
||||
..writeln('Settings scope: ${settingsScope.name}')
|
||||
..writeln('Provider endpoint plan:');
|
||||
|
||||
for (final provider in _providerLabels.keys) {
|
||||
buffer.writeln('- ${_providerLabels[provider]}: ${endpoints[provider]}');
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln('Scope notes:')
|
||||
..writeln(
|
||||
'- This tool configures endpoint slots only. Provider launch and bridge orchestration stay external to the app.',
|
||||
)
|
||||
..writeln(
|
||||
'- On macOS, auto scope prefers the App Sandbox container after the app has launched at least once.',
|
||||
)
|
||||
..writeln(
|
||||
'- App Store alignment: no external runtime binary is bundled or auto-started by this tool.',
|
||||
)
|
||||
..writeln(
|
||||
'- Claude and Gemini remain plain endpoint slots; this tool no longer prescribes any third-party bridge package.',
|
||||
)
|
||||
..writeln(
|
||||
'- Codex and OpenCode defaults are retained as local endpoint examples.',
|
||||
);
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _mergeExternalAcpEndpoints(
|
||||
Map<String, dynamic> existing, {
|
||||
required Map<String, String> endpoints,
|
||||
required Map<String, bool> enableProviders,
|
||||
}) {
|
||||
final updated = Map<String, dynamic>.from(existing);
|
||||
final incomingProfiles = (existing['externalAcpEndpoints'] is List)
|
||||
? List<Object?>.from(existing['externalAcpEndpoints'] as List)
|
||||
: <Object?>[];
|
||||
|
||||
final byKey = <String, Map<String, dynamic>>{};
|
||||
final extras = <Map<String, dynamic>>[];
|
||||
|
||||
for (final item in incomingProfiles) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
final profile = item.map(
|
||||
(Object? key, Object? value) => MapEntry(key.toString(), value),
|
||||
);
|
||||
final providerKey =
|
||||
profile['providerKey']?.toString().trim().toLowerCase() ?? '';
|
||||
if (_providerLabels.containsKey(providerKey)) {
|
||||
byKey[providerKey] = profile;
|
||||
} else if (providerKey.isNotEmpty) {
|
||||
extras.add(profile);
|
||||
}
|
||||
}
|
||||
|
||||
final builtins = <Map<String, dynamic>>[
|
||||
for (final provider in _providerLabels.keys)
|
||||
<String, dynamic>{
|
||||
...?byKey[provider],
|
||||
'providerKey': provider,
|
||||
'label': _providerLabels[provider],
|
||||
'endpoint': endpoints[provider] ?? '',
|
||||
'enabled': enableProviders[provider] ?? true,
|
||||
},
|
||||
];
|
||||
|
||||
updated['externalAcpEndpoints'] = <Object>[...builtins, ...extras];
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _readExistingSettings(File settingsFile) async {
|
||||
if (!await settingsFile.exists()) {
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
try {
|
||||
final raw = await settingsFile.readAsString();
|
||||
final decoded = decodeYamlDocument(raw);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return decoded;
|
||||
}
|
||||
if (decoded is Map) {
|
||||
return decoded.map(
|
||||
(Object? key, Object? value) => MapEntry(key.toString(), value),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
stderr.writeln(
|
||||
'Warning: failed to parse ${settingsFile.path}; starting from an empty map. $error',
|
||||
);
|
||||
}
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
|
||||
File _defaultSettingsFile({
|
||||
required Map<String, String> environment,
|
||||
required String operatingSystem,
|
||||
required _SettingsScope scope,
|
||||
}) {
|
||||
final home = environment['HOME']?.trim() ?? '';
|
||||
if (operatingSystem == 'macos' && home.isNotEmpty) {
|
||||
final sandboxContainer = Directory(
|
||||
'$home/Library/Containers/$_macosBundleIdentifier',
|
||||
);
|
||||
final sandboxed = File(
|
||||
'${sandboxContainer.path}/Data/Library/Application Support/xworkmate/config/settings.yaml',
|
||||
);
|
||||
final userScoped = File(
|
||||
'$home/Library/Application Support/xworkmate/config/settings.yaml',
|
||||
);
|
||||
return switch (scope) {
|
||||
_SettingsScope.sandbox => sandboxed,
|
||||
_SettingsScope.user => userScoped,
|
||||
_SettingsScope.auto =>
|
||||
sandboxContainer.existsSync() ? sandboxed : userScoped,
|
||||
};
|
||||
}
|
||||
if (operatingSystem == 'linux' && home.isNotEmpty) {
|
||||
final xdgConfigHome = environment['XDG_CONFIG_HOME']?.trim() ?? '';
|
||||
final base = xdgConfigHome.isNotEmpty ? xdgConfigHome : '$home/.config';
|
||||
return File('$base/xworkmate/config/settings.yaml');
|
||||
}
|
||||
if (operatingSystem == 'windows') {
|
||||
final appData = environment['APPDATA']?.trim() ?? '';
|
||||
if (appData.isNotEmpty) {
|
||||
return File('$appData\\xworkmate\\config\\settings.yaml');
|
||||
}
|
||||
final userProfile = environment['USERPROFILE']?.trim() ?? '';
|
||||
if (userProfile.isNotEmpty) {
|
||||
return File('$userProfile\\.xworkmate\\config\\settings.yaml');
|
||||
}
|
||||
}
|
||||
if (home.isNotEmpty) {
|
||||
return File('$home/.xworkmate/config/settings.yaml');
|
||||
}
|
||||
return File('settings.yaml');
|
||||
}
|
||||
|
||||
enum _Command { apply, printPlan }
|
||||
|
||||
enum _SettingsScope { auto, sandbox, user }
|
||||
|
||||
class _CliOptions {
|
||||
const _CliOptions({
|
||||
required this.command,
|
||||
required this.showHelp,
|
||||
required this.dryRun,
|
||||
required this.backup,
|
||||
required this.settingsFile,
|
||||
required this.settingsScope,
|
||||
required this.endpoints,
|
||||
required this.enableProviders,
|
||||
});
|
||||
|
||||
final _Command command;
|
||||
final bool showHelp;
|
||||
final bool dryRun;
|
||||
final bool backup;
|
||||
final File? settingsFile;
|
||||
final _SettingsScope settingsScope;
|
||||
final Map<String, String> endpoints;
|
||||
final Map<String, bool> enableProviders;
|
||||
|
||||
static _CliOptions parse(List<String> args) {
|
||||
if (args.isEmpty) {
|
||||
return _CliOptions(
|
||||
command: _Command.apply,
|
||||
showHelp: true,
|
||||
dryRun: false,
|
||||
backup: true,
|
||||
settingsFile: null,
|
||||
settingsScope: _SettingsScope.auto,
|
||||
endpoints: const <String, String>{},
|
||||
enableProviders: const <String, bool>{},
|
||||
);
|
||||
}
|
||||
|
||||
final normalizedCommand = switch (args.first.trim().toLowerCase()) {
|
||||
'apply' => _Command.apply,
|
||||
'print' => _Command.printPlan,
|
||||
'--help' || '-h' || 'help' => _Command.apply,
|
||||
_ => _Command.apply,
|
||||
};
|
||||
final showHelp = <String>{
|
||||
'--help',
|
||||
'-h',
|
||||
'help',
|
||||
}.contains(args.first.trim().toLowerCase());
|
||||
final rest = showHelp ? args.skip(1).toList(growable: false) : args.skip(1);
|
||||
|
||||
var dryRun = false;
|
||||
var backup = true;
|
||||
File? settingsFile;
|
||||
var settingsScope = _SettingsScope.auto;
|
||||
final endpoints = <String, String>{};
|
||||
final enableProviders = <String, bool>{};
|
||||
|
||||
final values = rest.toList(growable: false);
|
||||
for (var index = 0; index < values.length; index += 1) {
|
||||
final argument = values[index].trim();
|
||||
if (argument.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
if (argument == '--help' || argument == '-h') {
|
||||
return _CliOptions(
|
||||
command: normalizedCommand,
|
||||
showHelp: true,
|
||||
dryRun: dryRun,
|
||||
backup: backup,
|
||||
settingsFile: settingsFile,
|
||||
settingsScope: settingsScope,
|
||||
endpoints: endpoints,
|
||||
enableProviders: enableProviders,
|
||||
);
|
||||
}
|
||||
if (argument == '--dry-run') {
|
||||
dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (argument == '--no-backup') {
|
||||
backup = false;
|
||||
continue;
|
||||
}
|
||||
if (argument.startsWith('--disable-')) {
|
||||
final provider = argument.substring('--disable-'.length).trim();
|
||||
if (_providerLabels.containsKey(provider)) {
|
||||
enableProviders[provider] = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!argument.startsWith('--')) {
|
||||
stderr.writeln('Ignoring unexpected argument: $argument');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index + 1 >= values.length) {
|
||||
throw ArgumentError('Missing value for $argument');
|
||||
}
|
||||
|
||||
final value = values[index + 1].trim();
|
||||
index += 1;
|
||||
switch (argument) {
|
||||
case '--settings-file':
|
||||
settingsFile = File(value);
|
||||
break;
|
||||
case '--settings-scope':
|
||||
settingsScope = switch (value.trim().toLowerCase()) {
|
||||
'sandbox' => _SettingsScope.sandbox,
|
||||
'user' => _SettingsScope.user,
|
||||
_ => _SettingsScope.auto,
|
||||
};
|
||||
break;
|
||||
case '--codex-endpoint':
|
||||
endpoints['codex'] = value;
|
||||
break;
|
||||
case '--opencode-endpoint':
|
||||
endpoints['opencode'] = value;
|
||||
break;
|
||||
case '--claude-endpoint':
|
||||
endpoints['claude'] = value;
|
||||
break;
|
||||
case '--gemini-endpoint':
|
||||
endpoints['gemini'] = value;
|
||||
break;
|
||||
default:
|
||||
stderr.writeln('Ignoring unknown option: $argument');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return _CliOptions(
|
||||
command: normalizedCommand,
|
||||
showHelp: showHelp,
|
||||
dryRun: dryRun,
|
||||
backup: backup,
|
||||
settingsFile: settingsFile,
|
||||
settingsScope: settingsScope,
|
||||
endpoints: endpoints,
|
||||
enableProviders: enableProviders,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Object? decodeYamlDocument(String raw) {
|
||||
final trimmed = raw.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return _yamlToObject(loadYaml(trimmed));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Object? _yamlToObject(Object? value) {
|
||||
if (value is YamlMap) {
|
||||
return value.map(
|
||||
(Object? key, Object? item) =>
|
||||
MapEntry(key?.toString() ?? '', _yamlToObject(item)),
|
||||
);
|
||||
}
|
||||
if (value is YamlList) {
|
||||
return value.map(_yamlToObject).toList(growable: false);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
String encodeYamlDocument(Object? value) {
|
||||
final buffer = StringBuffer('---\n');
|
||||
_writeYamlValue(buffer, value, 0, listItem: false);
|
||||
if (!buffer.toString().endsWith('\n')) {
|
||||
buffer.writeln();
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
void _writeYamlValue(
|
||||
StringBuffer buffer,
|
||||
Object? value,
|
||||
int indent, {
|
||||
required bool listItem,
|
||||
}) {
|
||||
final prefix = ' ' * indent;
|
||||
if (value is Map) {
|
||||
if (value.isEmpty) {
|
||||
if (listItem) {
|
||||
buffer.writeln('{}');
|
||||
} else {
|
||||
buffer.writeln('$prefix{}');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (listItem) {
|
||||
buffer.writeln();
|
||||
}
|
||||
for (final entry in value.entries) {
|
||||
final key = entry.key.toString();
|
||||
final item = entry.value;
|
||||
if (_isInlineYamlValue(item)) {
|
||||
buffer.writeln('$prefix$key: ${_yamlInlineValue(item)}');
|
||||
} else if (item is String && item.contains('\n')) {
|
||||
buffer.writeln('$prefix$key: |-');
|
||||
for (final line in item.split('\n')) {
|
||||
buffer.writeln('${' ' * (indent + 1)}$line');
|
||||
}
|
||||
} else {
|
||||
buffer.writeln('$prefix$key:');
|
||||
_writeYamlValue(buffer, item, indent + 1, listItem: false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value is List) {
|
||||
if (value.isEmpty) {
|
||||
if (listItem) {
|
||||
buffer.writeln('[]');
|
||||
} else {
|
||||
buffer.writeln('$prefix[]');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (listItem) {
|
||||
buffer.writeln();
|
||||
}
|
||||
for (final item in value) {
|
||||
if (_isInlineYamlValue(item)) {
|
||||
buffer.writeln('$prefix- ${_yamlInlineValue(item)}');
|
||||
} else if (item is String && item.contains('\n')) {
|
||||
buffer.writeln('$prefix- |-');
|
||||
for (final line in item.split('\n')) {
|
||||
buffer.writeln('${' ' * (indent + 1)}$line');
|
||||
}
|
||||
} else {
|
||||
buffer.writeln('$prefix-');
|
||||
_writeYamlValue(buffer, item, indent + 1, listItem: false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (listItem) {
|
||||
buffer.writeln(_yamlInlineValue(value));
|
||||
return;
|
||||
}
|
||||
buffer.writeln('$prefix${_yamlInlineValue(value)}');
|
||||
}
|
||||
|
||||
bool _isInlineYamlValue(Object? value) {
|
||||
if (value == null || value is bool || value is num) {
|
||||
return true;
|
||||
}
|
||||
if (value is String) {
|
||||
return !value.contains('\n');
|
||||
}
|
||||
if (value is List) {
|
||||
return value.isEmpty;
|
||||
}
|
||||
if (value is Map) {
|
||||
return value.isEmpty;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
String _yamlInlineValue(Object? value) {
|
||||
if (value == null) {
|
||||
return 'null';
|
||||
}
|
||||
if (value is bool || value is num) {
|
||||
return value.toString();
|
||||
}
|
||||
if (value is List && value.isEmpty) {
|
||||
return '[]';
|
||||
}
|
||||
if (value is Map && value.isEmpty) {
|
||||
return '{}';
|
||||
}
|
||||
final stringValue = value.toString();
|
||||
if (stringValue.isEmpty) {
|
||||
return "''";
|
||||
}
|
||||
final safe = RegExp(r'^[A-Za-z0-9_./:@+%-]+$');
|
||||
final reserved = <String>{'null', 'true', 'false', '~'};
|
||||
if (safe.hasMatch(stringValue) && !reserved.contains(stringValue)) {
|
||||
return stringValue;
|
||||
}
|
||||
final escaped = stringValue.replaceAll("'", "''");
|
||||
return "'$escaped'";
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user