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)
|
## 1. 架构概览 (Unified Routing Architecture)
|
||||||
|
|
||||||
当前系统采用 `xworkmate-bridge.svc.plus` 作为统一入口,通过 Caddy 进行流量分发与强制鉴权。
|
当前系统采用 `xworkmate-bridge.svc.plus` 作为统一入口。App 侧只通过 managed bridge ACP 主入口发送任务,provider / gateway 的 public mapping 由 bridge 后端拥有。
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
@ -14,42 +14,41 @@ graph TD
|
|||||||
Bridge_Domain["https://xworkmate-bridge.svc.plus"]
|
Bridge_Domain["https://xworkmate-bridge.svc.plus"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Backend Services (Localhost)"
|
subgraph "Bridge-owned Routing"
|
||||||
ManagedBridge["Managed Bridge Core<br/>(Port 8787 / Docker)"]
|
ManagedBridge["Managed Bridge ACP<br/>/acp/rpc"]
|
||||||
CodexProvider["Codex ACP Server<br/>(Port 9010 / Systemd)"]
|
CodexProvider["Codex map<br/>/acp-server/codex"]
|
||||||
OpenCodeProvider["OpenCode ACP Server<br/>(Port 3910 / Systemd)"]
|
OpenCodeProvider["OpenCode map<br/>/acp-server/opencode"]
|
||||||
GeminiAdapter["Gemini ACP Adapter<br/>(Port 8791 / Systemd)"]
|
GeminiAdapter["Gemini map<br/>/acp-server/gemini"]
|
||||||
OpenClawGateway["OpenClaw Gateway<br/>(Port 18789 / Process)"]
|
OpenClawGateway["OpenClaw map<br/>/gateway/openclaw"]
|
||||||
end
|
end
|
||||||
|
|
||||||
%% Routing Rules
|
%% Routing Rules
|
||||||
Client -->|HTTPS/WSS| Bridge_Domain
|
Client -->|HTTPS/WSS| Bridge_Domain
|
||||||
|
|
||||||
Bridge_Domain -->|/| ManagedBridge
|
Bridge_Domain -->|/acp/rpc| ManagedBridge
|
||||||
Bridge_Domain -->|/acp-server/codex/| CodexProvider
|
ManagedBridge -->|provider routing| CodexProvider
|
||||||
Bridge_Domain -->|/acp-server/opencode/| OpenCodeProvider
|
ManagedBridge -->|provider routing| OpenCodeProvider
|
||||||
Bridge_Domain -->|/acp-server/gemini/| GeminiAdapter
|
ManagedBridge -->|provider routing| GeminiAdapter
|
||||||
Bridge_Domain -->|/gateway/openclaw/| OpenClawGateway
|
ManagedBridge -->|gateway routing| OpenClawGateway
|
||||||
|
|
||||||
%% Service Connections
|
%% Service Connections
|
||||||
ManagedBridge -.->|Capabilities Discovery| Client
|
ManagedBridge -.->|Capabilities Discovery| Client
|
||||||
OpenClawGateway <-->|WSS| Client
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. 路由分发规则
|
## 2. 路由分发规则
|
||||||
|
|
||||||
| 统一路径 | 转发目标 | 协议类型 | 备注 |
|
| Bridge-owned mapping | App 侧行为 | 备注 |
|
||||||
| :--- | :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
| `/` | `127.0.0.1:8787` | REST/RPC | Managed Bridge 核心,提供能力发现 |
|
| `/acp/rpc` | 直接调用 | Managed Bridge ACP 主入口,提供能力发现与任务发送 |
|
||||||
| `/acp-server/codex/` | `127.0.0.1:9010` | JSON-RPC (SSE) | 映射至 Codex Provider |
|
| `/acp-server/codex` | 不直连 | Bridge 后端映射至 Codex Provider |
|
||||||
| `/acp-server/opencode/` | `127.0.0.1:3910` | JSON-RPC (SSE) | 映射至 OpenCode Provider |
|
| `/acp-server/opencode` | 不直连 | Bridge 后端映射至 OpenCode Provider |
|
||||||
| `/acp-server/gemini/` | `127.0.0.1:8791` | JSON-RPC (SSE) | 映射至 Gemini Adapter |
|
| `/acp-server/gemini` | 不直连 | Bridge 后端映射至 Gemini Adapter |
|
||||||
| `/gateway/openclaw/` | `127.0.0.1:18789` | WSS / RPC | 映射至 OpenClaw Gateway |
|
| `/gateway/openclaw` | 不直连 | Bridge 后端映射至 OpenClaw Gateway |
|
||||||
|
|
||||||
## 3. 运维配置优化
|
## 3. 运维配置优化
|
||||||
|
|
||||||
### 3.1 统一鉴权
|
### 3.1 统一鉴权
|
||||||
所有通过 `xworkmate-bridge.svc.plus` 域名访问的请求(除 Caddy 内部 handle 外)均由 Caddy 强制校验:
|
App 发往 `xworkmate-bridge.svc.plus/acp/rpc` 的请求必须携带:
|
||||||
- **Header**: `Authorization: Bearer <bridge-auth-token>`
|
- **Header**: `Authorization: Bearer <bridge-auth-token>`
|
||||||
- **未授权响应**: `401 Unauthorized`
|
- **未授权响应**: `401 Unauthorized`
|
||||||
|
|
||||||
@ -62,9 +61,8 @@ graph TD
|
|||||||
- **容器路径**: `/app/logs`
|
- **容器路径**: `/app/logs`
|
||||||
- **轮转策略**: 单文件 50MB,保留最近 3 个文件。
|
- **轮转策略**: 单文件 50MB,保留最近 3 个文件。
|
||||||
|
|
||||||
## 4. 后端服务启动参考
|
## 4. App 侧不变量
|
||||||
|
|
||||||
- **Codex**: `/usr/local/bin/xworkmate-go-core serve --listen 127.0.0.1:9010`
|
- App 不写入或拼接本地 provider endpoint。
|
||||||
- **OpenCode**: `/usr/local/bin/xworkmate-go-core serve --listen 127.0.0.1:3910`
|
- App 不直接调用 `/acp-server/*` 或 `/gateway/openclaw`。
|
||||||
- **Gemini**: `/usr/local/bin/xworkmate-go-core gemini-acp-adapter --listen 127.0.0.1:8791 ...`
|
- `acp.capabilities` 是 provider catalog、gateway catalog、available execution targets 的唯一来源。
|
||||||
- **Gateway**: `openclaw-gateway run` (Port 18789)
|
|
||||||
|
|||||||
@ -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. 点击 `测试连接`
|
2. 点击 `测试连接`
|
||||||
3. 保存并生效
|
3. 保存并生效
|
||||||
4. 关闭设置页后重新进入确认仍然显示 local endpoint
|
4. 关闭设置页后重新进入确认仍然显示 managed bridge endpoint
|
||||||
- 期望结果
|
- 期望结果
|
||||||
- local / loopback 非 TLS 允许通过
|
- App 侧任务发送只使用 managed bridge ACP 主入口
|
||||||
- 页面明确显示当前为本地配置
|
- provider catalog 与 gateway provider 来自 `acp.capabilities`
|
||||||
- 不会把 local endpoint 错误识别为 remote insecure endpoint
|
- 不会写入或拼接 local / loopback provider endpoint
|
||||||
- 建议记录项
|
- 建议记录项
|
||||||
- 当前模式
|
- 当前模式
|
||||||
- loopback 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 (p.providerId == normalizedId) return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not in catalog but we have an ID, return a synthetic provider to allow routing
|
return SingleAgentProvider.unspecified;
|
||||||
return SingleAgentProvider(
|
|
||||||
providerId: normalizedId,
|
|
||||||
label: providerFallbackLabelInternal(normalizedId),
|
|
||||||
badge: providerFallbackBadgeInternal(
|
|
||||||
providerId: normalizedId,
|
|
||||||
label: providerFallbackLabelInternal(normalizedId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return (defaultToCatalog && catalog.isNotEmpty)
|
return (defaultToCatalog && catalog.isNotEmpty)
|
||||||
? catalog.first
|
? catalog.first
|
||||||
|
|||||||
@ -637,15 +637,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
|||||||
|
|
||||||
Uri? resolveBridgeAcpEndpointInternal() {
|
Uri? resolveBridgeAcpEndpointInternal() {
|
||||||
final modeConfig = settings.acpBridgeServerModeConfig;
|
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();
|
final cloudEndpoint = _activeCloudSyncedBridgeEndpointInternal();
|
||||||
if (cloudEndpoint.isNotEmpty) {
|
if (cloudEndpoint.isNotEmpty) {
|
||||||
final uri = Uri.tryParse(cloudEndpoint);
|
final uri = Uri.tryParse(cloudEndpoint);
|
||||||
@ -657,7 +649,8 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
|||||||
if (candidate.isNotEmpty) {
|
if (candidate.isNotEmpty) {
|
||||||
final uri = Uri.tryParse(candidate);
|
final uri = Uri.tryParse(candidate);
|
||||||
final scheme = uri?.scheme.trim().toLowerCase() ?? '';
|
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);
|
return uri.replace(query: null, fragment: null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -681,32 +674,23 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
|||||||
Uri? resolveExternalAcpEndpointForRequestInternal(
|
Uri? resolveExternalAcpEndpointForRequestInternal(
|
||||||
GoTaskServiceRequest request,
|
GoTaskServiceRequest request,
|
||||||
) {
|
) {
|
||||||
final bridgeEndpoint = resolveBridgeAcpEndpointInternal();
|
return 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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _activeCloudSyncedBridgeEndpointInternal() {
|
String _activeCloudSyncedBridgeEndpointInternal() {
|
||||||
final syncState = settingsControllerInternal.accountSyncState;
|
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' &&
|
if (syncState?.syncState.trim().toLowerCase() == 'ready' &&
|
||||||
syncState?.tokenConfigured.bridge == true &&
|
syncState?.tokenConfigured.bridge == true &&
|
||||||
syncedEndpoint.isNotEmpty) {
|
syncedEndpoint.isNotEmpty) {
|
||||||
return isSupportedExternalAcpEndpoint(syncedEndpoint) ? syncedEndpoint : '';
|
return isSupportedExternalAcpEndpoint(syncedEndpoint)
|
||||||
|
? syncedEndpoint
|
||||||
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return isSupportedExternalAcpEndpoint(syncedEndpoint) ? syncedEndpoint : '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
Uri? gatewayProfileBaseUriInternal(GatewayConnectionProfile profile) {
|
Uri? gatewayProfileBaseUriInternal(GatewayConnectionProfile profile) {
|
||||||
|
|||||||
@ -77,39 +77,3 @@ Uri? resolveAcpHttpRpcEndpoint(Uri? endpoint) {
|
|||||||
final paths = AcpEndpointPaths.fromBaseEndpoint(endpoint);
|
final paths = AcpEndpointPaths.fromBaseEndpoint(endpoint);
|
||||||
return endpoint.replace(path: paths.httpRpcPath, query: null, fragment: null);
|
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';
|
import 'dart:async';
|
||||||
|
|
||||||
class ArisLlmChatClient {
|
class ArisLlmChatClient {
|
||||||
ArisLlmChatClient({
|
ArisLlmChatClient({Duration rpcTimeout = const Duration(minutes: 2)});
|
||||||
Duration rpcTimeout = const Duration(minutes: 2),
|
|
||||||
}) : _rpcTimeout = rpcTimeout;
|
|
||||||
|
|
||||||
final Duration _rpcTimeout;
|
|
||||||
|
|
||||||
Future<String> chat({
|
Future<String> chat({
|
||||||
required String endpoint,
|
required String endpoint,
|
||||||
@ -41,7 +37,7 @@ class ArisLlmChatClient {
|
|||||||
}) async {
|
}) async {
|
||||||
// Local Go core execution is deprecated in favor of bridge-mediated execution.
|
// Local Go core execution is deprecated in favor of bridge-mediated execution.
|
||||||
throw UnsupportedError(
|
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 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../app/app_metadata.dart';
|
|
||||||
import 'embedded_agent_launch_policy.dart';
|
|
||||||
import 'platform_environment.dart';
|
import 'platform_environment.dart';
|
||||||
|
|
||||||
/// Codex sandbox mode for controlling file system access.
|
/// 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.
|
/// Send RPC request and wait for response.
|
||||||
Future<Map<String, dynamic>> request(
|
Future<Map<String, dynamic>> request(
|
||||||
String method, {
|
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.
|
/// Create a new thread.
|
||||||
Future<CodexThread> startThread({
|
Future<CodexThread> startThread({
|
||||||
required String cwd,
|
required String cwd,
|
||||||
|
|||||||
@ -1,6 +1,3 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'codex_config_bridge.dart';
|
import 'codex_config_bridge.dart';
|
||||||
import 'multi_agent_mount_resolver.dart';
|
import 'multi_agent_mount_resolver.dart';
|
||||||
import 'opencode_config_bridge.dart';
|
import 'opencode_config_bridge.dart';
|
||||||
@ -54,10 +51,7 @@ class MultiAgentMountManager {
|
|||||||
if (resolved != null) {
|
if (resolved != null) {
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
return _reconcileLocally(
|
return _reconcileLocally(config: config, aiGatewayUrl: aiGatewayUrl);
|
||||||
config: config,
|
|
||||||
aiGatewayUrl: aiGatewayUrl,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
@ -82,10 +76,7 @@ class MultiAgentMountManager {
|
|||||||
final states = <ManagedMountTargetState>[];
|
final states = <ManagedMountTargetState>[];
|
||||||
for (final adapter in _adapters) {
|
for (final adapter in _adapters) {
|
||||||
states.add(
|
states.add(
|
||||||
await adapter.reconcile(
|
await adapter.reconcile(config: config, aiGatewayUrl: aiGatewayUrl),
|
||||||
config: config,
|
|
||||||
aiGatewayUrl: aiGatewayUrl,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return config.copyWith(
|
return config.copyWith(
|
||||||
@ -119,9 +110,7 @@ abstract class CliMountAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class CodexMountAdapter extends CliMountAdapter {
|
class CodexMountAdapter extends CliMountAdapter {
|
||||||
CodexMountAdapter(this._bridge);
|
CodexMountAdapter(CodexConfigBridge bridge);
|
||||||
|
|
||||||
final CodexConfigBridge _bridge;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get targetId => 'codex';
|
String get targetId => 'codex';
|
||||||
@ -156,7 +145,8 @@ class CodexMountAdapter extends CliMountAdapter {
|
|||||||
available: false,
|
available: false,
|
||||||
discoveryState: 'missing',
|
discoveryState: 'missing',
|
||||||
syncState: '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 {
|
class OpencodeMountAdapter extends CliMountAdapter {
|
||||||
OpencodeMountAdapter(this._bridge);
|
OpencodeMountAdapter(OpencodeConfigBridge bridge);
|
||||||
|
|
||||||
final OpencodeConfigBridge _bridge;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get targetId => 'opencode';
|
String get targetId => 'opencode';
|
||||||
|
|||||||
@ -1,13 +1,16 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:xworkmate/app/app_controller.dart';
|
import 'package:xworkmate/app/app_controller.dart';
|
||||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||||
|
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('Assistant connection state', () {
|
group('Assistant connection state', () {
|
||||||
test(
|
test(
|
||||||
'keeps signed-out sessions disconnected even when provider catalogs exist',
|
'keeps signed-out sessions disconnected even when provider catalogs exist',
|
||||||
() async {
|
() async {
|
||||||
final controller = AppController(
|
final controller = await _isolatedController(
|
||||||
initialBridgeProviderCatalog: const <SingleAgentProvider>[
|
initialBridgeProviderCatalog: const <SingleAgentProvider>[
|
||||||
SingleAgentProvider.codex,
|
SingleAgentProvider.codex,
|
||||||
],
|
],
|
||||||
@ -29,12 +32,12 @@ void main() {
|
|||||||
final state = controller.currentAssistantConnectionState;
|
final state = controller.currentAssistantConnectionState;
|
||||||
expect(state.connected, isFalse);
|
expect(state.connected, isFalse);
|
||||||
expect(state.status, RuntimeConnectionStatus.offline);
|
expect(state.status, RuntimeConnectionStatus.offline);
|
||||||
expect(state.detailLabel, 'xworkmate-bridge 未连接');
|
expect(state.detailLabel, '请先登录 svc.plus');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test('keeps signed-out generic runtime failures disconnected', () async {
|
test('keeps signed-out generic runtime failures disconnected', () async {
|
||||||
final controller = AppController();
|
final controller = await _isolatedController();
|
||||||
addTearDown(controller.dispose);
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
await controller.sessionsController.switchSession('session-1');
|
await controller.sessionsController.switchSession('session-1');
|
||||||
@ -56,12 +59,12 @@ void main() {
|
|||||||
|
|
||||||
final state = controller.currentAssistantConnectionState;
|
final state = controller.currentAssistantConnectionState;
|
||||||
expect(state.status, RuntimeConnectionStatus.offline);
|
expect(state.status, RuntimeConnectionStatus.offline);
|
||||||
expect(state.primaryLabel, '离线');
|
expect(state.primaryLabel, '已退出登录');
|
||||||
expect(state.detailLabel, 'xworkmate-bridge 未连接');
|
expect(state.detailLabel, '请先登录 svc.plus');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('keeps true offline state as bridge not connected', () async {
|
test('keeps true offline state as bridge not connected', () async {
|
||||||
final controller = AppController();
|
final controller = await _isolatedController();
|
||||||
addTearDown(controller.dispose);
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
await controller.sessionsController.switchSession('session-1');
|
await controller.sessionsController.switchSession('session-1');
|
||||||
@ -76,14 +79,14 @@ void main() {
|
|||||||
|
|
||||||
final state = controller.currentAssistantConnectionState;
|
final state = controller.currentAssistantConnectionState;
|
||||||
expect(state.status, RuntimeConnectionStatus.offline);
|
expect(state.status, RuntimeConnectionStatus.offline);
|
||||||
expect(state.primaryLabel, '离线');
|
expect(state.primaryLabel, '已退出登录');
|
||||||
expect(state.detailLabel, 'xworkmate-bridge 未连接');
|
expect(state.detailLabel, '请先登录 svc.plus');
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'keeps signed-out generic failures without address disconnected',
|
'keeps signed-out generic failures without address disconnected',
|
||||||
() async {
|
() async {
|
||||||
final controller = AppController();
|
final controller = await _isolatedController();
|
||||||
addTearDown(controller.dispose);
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
await controller.sessionsController.switchSession('session-1');
|
await controller.sessionsController.switchSession('session-1');
|
||||||
@ -105,15 +108,15 @@ void main() {
|
|||||||
|
|
||||||
final state = controller.currentAssistantConnectionState;
|
final state = controller.currentAssistantConnectionState;
|
||||||
expect(state.status, RuntimeConnectionStatus.offline);
|
expect(state.status, RuntimeConnectionStatus.offline);
|
||||||
expect(state.primaryLabel, '离线');
|
expect(state.primaryLabel, '已退出登录');
|
||||||
expect(state.detailLabel, 'xworkmate-bridge 未连接');
|
expect(state.detailLabel, '请先登录 svc.plus');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'keeps gateway token missing as dedicated app-visible state',
|
'keeps gateway token missing as dedicated app-visible state',
|
||||||
() async {
|
() async {
|
||||||
final controller = AppController();
|
final controller = await _isolatedController();
|
||||||
addTearDown(controller.dispose);
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
await controller.sessionsController.switchSession('session-1');
|
await controller.sessionsController.switchSession('session-1');
|
||||||
@ -135,15 +138,15 @@ void main() {
|
|||||||
|
|
||||||
final state = controller.currentAssistantConnectionState;
|
final state = controller.currentAssistantConnectionState;
|
||||||
expect(state.status, RuntimeConnectionStatus.offline);
|
expect(state.status, RuntimeConnectionStatus.offline);
|
||||||
expect(state.primaryLabel, '离线');
|
expect(state.primaryLabel, '已退出登录');
|
||||||
expect(state.detailLabel, 'xworkmate-bridge 未连接');
|
expect(state.detailLabel, '请先登录 svc.plus');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'treats missing endpoint as true offline instead of bridge failure',
|
'treats missing endpoint as true offline instead of bridge failure',
|
||||||
() async {
|
() async {
|
||||||
final controller = AppController();
|
final controller = await _isolatedController();
|
||||||
addTearDown(controller.dispose);
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
await controller.sessionsController.switchSession('session-1');
|
await controller.sessionsController.switchSession('session-1');
|
||||||
@ -164,13 +167,13 @@ void main() {
|
|||||||
|
|
||||||
final state = controller.currentAssistantConnectionState;
|
final state = controller.currentAssistantConnectionState;
|
||||||
expect(state.status, RuntimeConnectionStatus.offline);
|
expect(state.status, RuntimeConnectionStatus.offline);
|
||||||
expect(state.primaryLabel, '离线');
|
expect(state.primaryLabel, '已退出登录');
|
||||||
expect(state.detailLabel, 'xworkmate-bridge 未连接');
|
expect(state.detailLabel, '请先登录 svc.plus');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test('desktop snapshot uses derived assistant connection labels', () async {
|
test('desktop snapshot uses derived assistant connection labels', () async {
|
||||||
final controller = AppController();
|
final controller = await _isolatedController();
|
||||||
addTearDown(controller.dispose);
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
await controller.sessionsController.switchSession('session-1');
|
await controller.sessionsController.switchSession('session-1');
|
||||||
@ -191,7 +194,39 @@ void main() {
|
|||||||
|
|
||||||
final snapshot = controller.desktopStatusSnapshot();
|
final snapshot = controller.desktopStatusSnapshot();
|
||||||
expect(snapshot['connectionStatus'], 'disconnected');
|
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,
|
executionTarget: AssistantExecutionTarget.agent,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(unavailableProvider, SingleAgentProvider.unspecified);
|
expect(unavailableProvider.isUnspecified, isTrue);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -142,7 +142,7 @@ void main() {
|
|||||||
executionTarget: AssistantExecutionTarget.gateway,
|
executionTarget: AssistantExecutionTarget.gateway,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(provider, SingleAgentProvider.unspecified);
|
expect(provider.isUnspecified, isTrue);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -304,7 +304,9 @@ void main() {
|
|||||||
|
|
||||||
expect(controller.assistantProviderCatalog, isEmpty);
|
expect(controller.assistantProviderCatalog, isEmpty);
|
||||||
expect(capture.requestCount, lessThanOrEqualTo(requestCountBefore + 2));
|
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',
|
'sendChatMessage fails locally without bridge sync token and does not execute ACP task',
|
||||||
() async {
|
() async {
|
||||||
final fakeGoTaskService = _RecordingGoTaskServiceClient();
|
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(
|
final controller = AppController(
|
||||||
|
store: store,
|
||||||
goTaskServiceClient: fakeGoTaskService,
|
goTaskServiceClient: fakeGoTaskService,
|
||||||
|
environmentOverride: const <String, String>{},
|
||||||
initialBridgeProviderCatalog: const <SingleAgentProvider>[
|
initialBridgeProviderCatalog: const <SingleAgentProvider>[
|
||||||
SingleAgentProvider.codex,
|
SingleAgentProvider.codex,
|
||||||
],
|
],
|
||||||
|
|||||||
@ -317,7 +317,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'desktop task execution routes Hermes through provider public endpoint',
|
'desktop task execution routes Hermes through bridge RPC with provider params',
|
||||||
() async {
|
() async {
|
||||||
final capture = await _startAcpHttpServer();
|
final capture = await _startAcpHttpServer();
|
||||||
addTearDown(capture.close);
|
addTearDown(capture.close);
|
||||||
@ -343,12 +343,19 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(capture.authorizationHeader, 'Bearer bridge-token');
|
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(
|
test(
|
||||||
'desktop task execution routes OpenClaw through gateway public endpoint',
|
'desktop task execution routes OpenClaw through bridge RPC with gateway params',
|
||||||
() async {
|
() async {
|
||||||
final capture = await _startAcpHttpServer();
|
final capture = await _startAcpHttpServer();
|
||||||
addTearDown(capture.close);
|
addTearDown(capture.close);
|
||||||
@ -374,7 +381,15 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(capture.authorizationHeader, 'Bearer bridge-token');
|
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) ?? '';
|
request.headers.value(HttpHeaders.authorizationHeader) ?? '';
|
||||||
capture.requestPath = request.uri.path;
|
capture.requestPath = request.uri.path;
|
||||||
final body = await utf8.decoder.bind(request).join();
|
final body = await utf8.decoder.bind(request).join();
|
||||||
|
capture.requestBody = body;
|
||||||
final id = _decodeRequestId(body);
|
final id = _decodeRequestId(body);
|
||||||
request.response.headers.contentType = ContentType.json;
|
request.response.headers.contentType = ContentType.json;
|
||||||
request.response.write(
|
request.response.write(
|
||||||
@ -483,6 +499,7 @@ class _CapturedAcpHttpServer {
|
|||||||
final Uri baseEndpoint;
|
final Uri baseEndpoint;
|
||||||
String authorizationHeader = '';
|
String authorizationHeader = '';
|
||||||
String requestPath = '';
|
String requestPath = '';
|
||||||
|
String requestBody = '';
|
||||||
|
|
||||||
Future<void> close() => _server.close(force: true);
|
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