From 99796e238cd0cc9b65e42d9610a3664977b0700b Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 21 Apr 2026 16:28:26 +0800 Subject: [PATCH] Refactor bridge runtime routing --- .../bridge-runtime-routing-map.md | 47 ++ .../unified-routing-architecture.md | 50 +- docs/cases/core-integration-manual-cases.md | 16 +- docs/howto/external-acp-bridge-config.md | 129 ---- lib/app/app_controller_desktop_core.dart | 10 +- ...pp_controller_desktop_runtime_helpers.dart | 36 +- lib/runtime/acp_endpoint_paths.dart | 36 -- lib/runtime/aris_llm_chat_client.dart | 8 +- lib/runtime/codex_runtime.dart | 191 ------ lib/runtime/multi_agent_mounts.dart | 24 +- .../assistant_connection_state_test.dart | 73 ++- .../assistant_execution_target_test.dart | 30 +- .../runtime/gateway_acp_client_auth_test.dart | 25 +- tool/configure_external_acp.dart | 562 ------------------ 14 files changed, 200 insertions(+), 1037 deletions(-) create mode 100644 docs/architecture/bridge-runtime-routing-map.md delete mode 100644 docs/howto/external-acp-bridge-config.md delete mode 100644 tool/configure_external_acp.dart diff --git a/docs/architecture/bridge-runtime-routing-map.md b/docs/architecture/bridge-runtime-routing-map.md new file mode 100644 index 00000000..575cb89c --- /dev/null +++ b/docs/architecture/bridge-runtime-routing-map.md @@ -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
https://xworkmate-bridge.svc.plus/acp-server/hermes"] + S --> L["Codex map
https://xworkmate-bridge.svc.plus/acp-server/codex"] + S --> M["OpenCode map
https://xworkmate-bridge.svc.plus/acp-server/opencode"] + S --> N["Gemini map
https://xworkmate-bridge.svc.plus/acp-server/gemini"] + S --> O["OpenClaw map
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. diff --git a/docs/architecture/unified-routing-architecture.md b/docs/architecture/unified-routing-architecture.md index dd5527ac..2ae2739a 100644 --- a/docs/architecture/unified-routing-architecture.md +++ b/docs/architecture/unified-routing-architecture.md @@ -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
(Port 8787 / Docker)"] - CodexProvider["Codex ACP Server
(Port 9010 / Systemd)"] - OpenCodeProvider["OpenCode ACP Server
(Port 3910 / Systemd)"] - GeminiAdapter["Gemini ACP Adapter
(Port 8791 / Systemd)"] - OpenClawGateway["OpenClaw Gateway
(Port 18789 / Process)"] + subgraph "Bridge-owned Routing" + ManagedBridge["Managed Bridge ACP
/acp/rpc"] + CodexProvider["Codex map
/acp-server/codex"] + OpenCodeProvider["OpenCode map
/acp-server/opencode"] + GeminiAdapter["Gemini map
/acp-server/gemini"] + OpenClawGateway["OpenClaw map
/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 ` - **未授权响应**: `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 的唯一来源。 diff --git a/docs/cases/core-integration-manual-cases.md b/docs/cases/core-integration-manual-cases.md index 90bb76bb..b1fa7ac0 100644 --- a/docs/cases/core-integration-manual-cases.md +++ b/docs/cases/core-integration-manual-cases.md @@ -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 diff --git a/docs/howto/external-acp-bridge-config.md b/docs/howto/external-acp-bridge-config.md deleted file mode 100644 index 5a200e6d..00000000 --- a/docs/howto/external-acp-bridge-config.md +++ /dev/null @@ -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 的禁用策略 diff --git a/lib/app/app_controller_desktop_core.dart b/lib/app/app_controller_desktop_core.dart index 9899b44e..f84513c9 100644 --- a/lib/app/app_controller_desktop_core.dart +++ b/lib/app/app_controller_desktop_core.dart @@ -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 diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 700bc504..c5b30df3 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -637,15 +637,7 @@ 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() ?? ''; - - // If sync is ready and configured, use it. + final syncedEndpoint = + syncState?.syncedDefaults.bridgeServerUrl.trim() ?? ''; + 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) { diff --git a/lib/runtime/acp_endpoint_paths.dart b/lib/runtime/acp_endpoint_paths.dart index 8631d03d..b16142b9 100644 --- a/lib/runtime/acp_endpoint_paths.dart +++ b/lib/runtime/acp_endpoint_paths.dart @@ -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, - ); -} diff --git a/lib/runtime/aris_llm_chat_client.dart b/lib/runtime/aris_llm_chat_client.dart index 52486227..cc8c0bfe 100644 --- a/lib/runtime/aris_llm_chat_client.dart +++ b/lib/runtime/aris_llm_chat_client.dart @@ -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 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.', ); } } diff --git a/lib/runtime/codex_runtime.dart b/lib/runtime/codex_runtime.dart index f13e6af8..ac08ca89 100644 --- a/lib/runtime/codex_runtime.dart +++ b/lib/runtime/codex_runtime.dart @@ -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 _lookupExecutableArguments() { - return const ['codex']; - } - - void _setupStdioStreams() { - final process = _process!; - final stdoutLines = []; - final stderrLines = []; - - // 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 _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, - ); - } - - // 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; - - 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); - } - } 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), - ); - } - } else if (json.containsKey('method')) { - // Notification - final method = json['method'] as String; - final params = json['params'] as Map? ?? {}; - _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> request( String method, { @@ -556,25 +384,6 @@ class CodexRuntime extends ChangeNotifier { ); } - /// Send notification (no response expected). - Future _sendNotification( - String method, { - required Map 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 startThread({ required String cwd, diff --git a/lib/runtime/multi_agent_mounts.dart b/lib/runtime/multi_agent_mounts.dart index b26998f9..7280894b 100644 --- a/lib/runtime/multi_agent_mounts.dart +++ b/lib/runtime/multi_agent_mounts.dart @@ -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 dispose() async { @@ -82,10 +76,7 @@ class MultiAgentMountManager { final states = []; 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'; diff --git a/test/runtime/assistant_connection_state_test.dart b/test/runtime/assistant_connection_state_test.dart index f5f2f2ee..cb5738c6 100644 --- a/test/runtime/assistant_connection_state_test.dart +++ b/test/runtime/assistant_connection_state_test.dart @@ -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.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 _isolatedController({ + List? initialBridgeProviderCatalog, + List? initialGatewayProviderCatalog, + List? 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, + ); +} diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index eab2b79e..23e1a609 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -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)); - expect(capture.lastAuthorizationHeader, 'Bearer bridge-token'); + 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 {}, initialBridgeProviderCatalog: const [ SingleAgentProvider.codex, ], diff --git a/test/runtime/gateway_acp_client_auth_test.dart b/test/runtime/gateway_acp_client_auth_test.dart index 23855b8f..0e98c817 100644 --- a/test/runtime/gateway_acp_client_auth_test.dart +++ b/test/runtime/gateway_acp_client_auth_test.dart @@ -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 close() => _server.close(force: true); } diff --git a/tool/configure_external_acp.dart b/tool/configure_external_acp.dart deleted file mode 100644 index ade6b45a..00000000 --- a/tool/configure_external_acp.dart +++ /dev/null @@ -1,562 +0,0 @@ -import 'dart:io'; - -import 'package:yaml/yaml.dart'; - -const String _macosBundleIdentifier = 'plus.svc.xworkmate'; - -const Map _providerLabels = { - 'codex': 'Codex', - 'opencode': 'OpenCode', - 'claude': 'Claude', - 'gemini': 'Gemini', -}; - -const Map _defaultEndpoints = { - '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 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 = { - 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 Override settings.yaml path. - --settings-scope macOS only: auto | sandbox | user. - --codex-endpoint Default: ${_defaultEndpoints['codex']} - --opencode-endpoint Default: ${_defaultEndpoints['opencode']} - --claude-endpoint Default: ${_defaultEndpoints['claude']} - --gemini-endpoint 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 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 _mergeExternalAcpEndpoints( - Map existing, { - required Map endpoints, - required Map enableProviders, -}) { - final updated = Map.from(existing); - final incomingProfiles = (existing['externalAcpEndpoints'] is List) - ? List.from(existing['externalAcpEndpoints'] as List) - : []; - - final byKey = >{}; - final extras = >[]; - - 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 = >[ - for (final provider in _providerLabels.keys) - { - ...?byKey[provider], - 'providerKey': provider, - 'label': _providerLabels[provider], - 'endpoint': endpoints[provider] ?? '', - 'enabled': enableProviders[provider] ?? true, - }, - ]; - - updated['externalAcpEndpoints'] = [...builtins, ...extras]; - return updated; -} - -Future> _readExistingSettings(File settingsFile) async { - if (!await settingsFile.exists()) { - return {}; - } - try { - final raw = await settingsFile.readAsString(); - final decoded = decodeYamlDocument(raw); - if (decoded is Map) { - 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 {}; -} - -File _defaultSettingsFile({ - required Map 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 endpoints; - final Map enableProviders; - - static _CliOptions parse(List args) { - if (args.isEmpty) { - return _CliOptions( - command: _Command.apply, - showHelp: true, - dryRun: false, - backup: true, - settingsFile: null, - settingsScope: _SettingsScope.auto, - endpoints: const {}, - enableProviders: const {}, - ); - } - - final normalizedCommand = switch (args.first.trim().toLowerCase()) { - 'apply' => _Command.apply, - 'print' => _Command.printPlan, - '--help' || '-h' || 'help' => _Command.apply, - _ => _Command.apply, - }; - final showHelp = { - '--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 = {}; - final enableProviders = {}; - - 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 = {'null', 'true', 'false', '~'}; - if (safe.hasMatch(stringValue) && !reserved.contains(stringValue)) { - return stringValue; - } - final escaped = stringValue.replaceAll("'", "''"); - return "'$escaped'"; -}