Refactor bridge runtime routing

This commit is contained in:
Haitao Pan 2026-04-21 16:28:26 +08:00
parent 7186fa3c5d
commit 99796e238c
14 changed files with 200 additions and 1037 deletions

View 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.

View File

@ -2,7 +2,7 @@
## 1. 架构概览 (Unified Routing Architecture)
当前系统采用 `xworkmate-bridge.svc.plus` 作为统一入口,通过 Caddy 进行流量分发与强制鉴权
当前系统采用 `xworkmate-bridge.svc.plus` 作为统一入口。App 侧只通过 managed bridge ACP 主入口发送任务provider / gateway 的 public mapping 由 bridge 后端拥有
```mermaid
graph TD
@ -14,42 +14,41 @@ graph TD
Bridge_Domain["https://xworkmate-bridge.svc.plus"]
end
subgraph "Backend Services (Localhost)"
ManagedBridge["Managed Bridge Core<br/>(Port 8787 / Docker)"]
CodexProvider["Codex ACP Server<br/>(Port 9010 / Systemd)"]
OpenCodeProvider["OpenCode ACP Server<br/>(Port 3910 / Systemd)"]
GeminiAdapter["Gemini ACP Adapter<br/>(Port 8791 / Systemd)"]
OpenClawGateway["OpenClaw Gateway<br/>(Port 18789 / Process)"]
subgraph "Bridge-owned Routing"
ManagedBridge["Managed Bridge ACP<br/>/acp/rpc"]
CodexProvider["Codex map<br/>/acp-server/codex"]
OpenCodeProvider["OpenCode map<br/>/acp-server/opencode"]
GeminiAdapter["Gemini map<br/>/acp-server/gemini"]
OpenClawGateway["OpenClaw map<br/>/gateway/openclaw"]
end
%% Routing Rules
Client -->|HTTPS/WSS| Bridge_Domain
Bridge_Domain -->|/| ManagedBridge
Bridge_Domain -->|/acp-server/codex/| CodexProvider
Bridge_Domain -->|/acp-server/opencode/| OpenCodeProvider
Bridge_Domain -->|/acp-server/gemini/| GeminiAdapter
Bridge_Domain -->|/gateway/openclaw/| OpenClawGateway
Bridge_Domain -->|/acp/rpc| ManagedBridge
ManagedBridge -->|provider routing| CodexProvider
ManagedBridge -->|provider routing| OpenCodeProvider
ManagedBridge -->|provider routing| GeminiAdapter
ManagedBridge -->|gateway routing| OpenClawGateway
%% Service Connections
ManagedBridge -.->|Capabilities Discovery| Client
OpenClawGateway <-->|WSS| Client
```
## 2. 路由分发规则
| 统一路径 | 转发目标 | 协议类型 | 备注 |
| :--- | :--- | :--- | :--- |
| `/` | `127.0.0.1:8787` | REST/RPC | Managed Bridge 核心,提供能力发现 |
| `/acp-server/codex/` | `127.0.0.1:9010` | JSON-RPC (SSE) | 映射至 Codex Provider |
| `/acp-server/opencode/` | `127.0.0.1:3910` | JSON-RPC (SSE) | 映射至 OpenCode Provider |
| `/acp-server/gemini/` | `127.0.0.1:8791` | JSON-RPC (SSE) | 映射至 Gemini Adapter |
| `/gateway/openclaw/` | `127.0.0.1:18789` | WSS / RPC | 映射至 OpenClaw Gateway |
| Bridge-owned mapping | App 侧行为 | 备注 |
| :--- | :--- | :--- |
| `/acp/rpc` | 直接调用 | Managed Bridge ACP 主入口,提供能力发现与任务发送 |
| `/acp-server/codex` | 不直连 | Bridge 后端映射至 Codex Provider |
| `/acp-server/opencode` | 不直连 | Bridge 后端映射至 OpenCode Provider |
| `/acp-server/gemini` | 不直连 | Bridge 后端映射至 Gemini Adapter |
| `/gateway/openclaw` | 不直连 | Bridge 后端映射至 OpenClaw Gateway |
## 3. 运维配置优化
### 3.1 统一鉴权
所有通过 `xworkmate-bridge.svc.plus` 域名访问的请求(除 Caddy 内部 handle 外)均由 Caddy 强制校验
App 发往 `xworkmate-bridge.svc.plus/acp/rpc` 的请求必须携带
- **Header**: `Authorization: Bearer <bridge-auth-token>`
- **未授权响应**: `401 Unauthorized`
@ -62,9 +61,8 @@ graph TD
- **容器路径**: `/app/logs`
- **轮转策略**: 单文件 50MB保留最近 3 个文件。
## 4. 后端服务启动参考
## 4. App 侧不变量
- **Codex**: `/usr/local/bin/xworkmate-go-core serve --listen 127.0.0.1:9010`
- **OpenCode**: `/usr/local/bin/xworkmate-go-core serve --listen 127.0.0.1:3910`
- **Gemini**: `/usr/local/bin/xworkmate-go-core gemini-acp-adapter --listen 127.0.0.1:8791 ...`
- **Gateway**: `openclaw-gateway run` (Port 18789)
- App 不写入或拼接本地 provider endpoint。
- App 不直接调用 `/acp-server/*``/gateway/openclaw`
- `acp.capabilities` 是 provider catalog、gateway catalog、available execution targets 的唯一来源。

View File

@ -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

View File

@ -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 的禁用策略

View File

@ -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

View File

@ -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) {

View File

@ -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,
);
}

View File

@ -1,11 +1,7 @@
import 'dart:async';
class ArisLlmChatClient {
ArisLlmChatClient({
Duration rpcTimeout = const Duration(minutes: 2),
}) : _rpcTimeout = rpcTimeout;
final Duration _rpcTimeout;
ArisLlmChatClient({Duration rpcTimeout = const Duration(minutes: 2)});
Future<String> chat({
required String endpoint,
@ -41,7 +37,7 @@ class ArisLlmChatClient {
}) async {
// Local Go core execution is deprecated in favor of bridge-mediated execution.
throw UnsupportedError(
'Local Go core execution is disabled. Use bridge endpoints like /acp-server/hermes instead.',
'Local Go core execution is disabled. Use the managed bridge ACP runtime instead.',
);
}
}

View File

@ -4,8 +4,6 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import '../app/app_metadata.dart';
import 'embedded_agent_launch_policy.dart';
import 'platform_environment.dart';
/// Codex sandbox mode for controlling file system access.
@ -353,176 +351,6 @@ class CodexRuntime extends ChangeNotifier {
);
}
static String _lookupExecutableProgram({String? operatingSystem}) {
return detectRuntimeHostPlatform(operatingSystem: operatingSystem) ==
RuntimeHostPlatform.windows
? 'where'
: 'which';
}
static List<String> _lookupExecutableArguments() {
return const <String>['codex'];
}
void _setupStdioStreams() {
final process = _process!;
final stdoutLines = <String>[];
final stderrLines = <String>[];
// stdout: JSON-RPC message stream (may have interleaved log lines)
_stdoutSubscription = process.stdout
.transform(utf8.decoder)
.transform(LineSplitter())
.listen(
(line) {
final trimmed = line.trim();
if (trimmed.isEmpty) return;
// Try to parse as JSON-RPC
if (trimmed.startsWith('{')) {
_handleMessage(trimmed);
} else {
// Non-JSON output, emit as log
stdoutLines.add(trimmed);
if (stdoutLines.length > 100) stdoutLines.removeAt(0);
_events.add(
CodexLogEvent(
level: 'debug',
message: trimmed,
timestamp: DateTime.now(),
),
);
}
},
onError: (error) {
_events.add(
CodexLogEvent(
level: 'error',
message: 'stdout error: $error',
timestamp: DateTime.now(),
),
);
},
);
// stderr: Log output
_stderrSubscription = process.stderr
.transform(utf8.decoder)
.transform(LineSplitter())
.listen(
(line) {
final trimmed = line.trim();
if (trimmed.isEmpty) return;
stderrLines.add(trimmed);
if (stderrLines.length > 100) stderrLines.removeAt(0);
_events.add(
CodexLogEvent(
level: 'info',
message: trimmed,
timestamp: DateTime.now(),
),
);
},
onError: (error) {
_events.add(
CodexLogEvent(
level: 'error',
message: 'stderr error: $error',
timestamp: DateTime.now(),
),
);
},
);
// Handle process exit
process.exitCode.then((exitCode) {
_events.add(
CodexLogEvent(
level: exitCode == 0 ? 'info' : 'warn',
message: 'Codex exited with code $exitCode',
timestamp: DateTime.now(),
),
);
_process = null;
_state = CodexConnectionState.disconnected;
_isInitialized = false;
notifyListeners();
});
}
Future<void> _initialize() async {
_state = CodexConnectionState.initializing;
notifyListeners();
try {
final result = await request(
'initialize',
params: {
'clientInfo': {'name': 'xworkmate', 'version': kAppVersion},
'capabilities': {'optOutNotificationMethods': []},
},
);
// Store any account info from response
if (result.containsKey('account')) {
_account = CodexAccount.fromJson(
result['account'] as Map<String, dynamic>,
);
}
// Send initialized notification
await _sendNotification('initialized', params: {});
_isInitialized = true;
_state = CodexConnectionState.ready;
notifyListeners();
} catch (e) {
_state = CodexConnectionState.error;
_lastError = e.toString();
notifyListeners();
rethrow;
}
}
void _handleMessage(String line) {
try {
final json = jsonDecode(line) as Map<String, dynamic>;
if (json.containsKey('id') && json.containsKey('result')) {
// Success response
final id = json['id'].toString();
final completer = _pendingRequests.remove(id);
if (completer != null && !completer.isCompleted) {
completer.complete(json['result'] as Map<String, dynamic>);
}
} else if (json.containsKey('id') && json.containsKey('error')) {
// Error response
final id = json['id'].toString();
final completer = _pendingRequests.remove(id);
if (completer != null && !completer.isCompleted) {
completer.completeError(
CodexRpcError.fromJson(json['error'] as Map<String, dynamic>),
);
}
} else if (json.containsKey('method')) {
// Notification
final method = json['method'] as String;
final params = json['params'] as Map<String, dynamic>? ?? {};
_events.add(CodexNotificationEvent(method: method, params: params));
}
} catch (e) {
_events.add(
CodexLogEvent(
level: 'warn',
message: 'Failed to parse message: $e',
timestamp: DateTime.now(),
),
);
}
}
/// Send RPC request and wait for response.
Future<Map<String, dynamic>> request(
String method, {
@ -556,25 +384,6 @@ class CodexRuntime extends ChangeNotifier {
);
}
/// Send notification (no response expected).
Future<void> _sendNotification(
String method, {
required Map<String, dynamic> params,
}) async {
final process = _process;
if (process == null) {
throw StateError('Codex not running');
}
final message = jsonEncode({
'jsonrpc': '2.0',
'method': method,
'params': params,
});
process.stdin.writeln(message);
}
/// Create a new thread.
Future<CodexThread> startThread({
required String cwd,

View File

@ -1,6 +1,3 @@
import 'dart:convert';
import 'dart:io';
import 'codex_config_bridge.dart';
import 'multi_agent_mount_resolver.dart';
import 'opencode_config_bridge.dart';
@ -54,10 +51,7 @@ class MultiAgentMountManager {
if (resolved != null) {
return resolved;
}
return _reconcileLocally(
config: config,
aiGatewayUrl: aiGatewayUrl,
);
return _reconcileLocally(config: config, aiGatewayUrl: aiGatewayUrl);
}
Future<void> dispose() async {
@ -82,10 +76,7 @@ class MultiAgentMountManager {
final states = <ManagedMountTargetState>[];
for (final adapter in _adapters) {
states.add(
await adapter.reconcile(
config: config,
aiGatewayUrl: aiGatewayUrl,
),
await adapter.reconcile(config: config, aiGatewayUrl: aiGatewayUrl),
);
}
return config.copyWith(
@ -119,9 +110,7 @@ abstract class CliMountAdapter {
}
class CodexMountAdapter extends CliMountAdapter {
CodexMountAdapter(this._bridge);
final CodexConfigBridge _bridge;
CodexMountAdapter(CodexConfigBridge bridge);
@override
String get targetId => 'codex';
@ -156,7 +145,8 @@ class CodexMountAdapter extends CliMountAdapter {
available: false,
discoveryState: 'missing',
syncState: 'missing',
detail: 'Local CLI interaction is disabled. Use bridge for orchestration.',
detail:
'Local CLI interaction is disabled. Use bridge for orchestration.',
);
}
}
@ -240,9 +230,7 @@ class GeminiMountAdapter extends CliMountAdapter {
}
class OpencodeMountAdapter extends CliMountAdapter {
OpencodeMountAdapter(this._bridge);
final OpencodeConfigBridge _bridge;
OpencodeMountAdapter(OpencodeConfigBridge bridge);
@override
String get targetId => 'opencode';

View File

@ -1,13 +1,16 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
void main() {
group('Assistant connection state', () {
test(
'keeps signed-out sessions disconnected even when provider catalogs exist',
() async {
final controller = AppController(
final controller = await _isolatedController(
initialBridgeProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
@ -29,12 +32,12 @@ void main() {
final state = controller.currentAssistantConnectionState;
expect(state.connected, isFalse);
expect(state.status, RuntimeConnectionStatus.offline);
expect(state.detailLabel, 'xworkmate-bridge 未连接');
expect(state.detailLabel, '请先登录 svc.plus');
},
);
test('keeps signed-out generic runtime failures disconnected', () async {
final controller = AppController();
final controller = await _isolatedController();
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
@ -56,12 +59,12 @@ void main() {
final state = controller.currentAssistantConnectionState;
expect(state.status, RuntimeConnectionStatus.offline);
expect(state.primaryLabel, '离线');
expect(state.detailLabel, 'xworkmate-bridge 未连接');
expect(state.primaryLabel, '已退出登录');
expect(state.detailLabel, '请先登录 svc.plus');
});
test('keeps true offline state as bridge not connected', () async {
final controller = AppController();
final controller = await _isolatedController();
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
@ -76,14 +79,14 @@ void main() {
final state = controller.currentAssistantConnectionState;
expect(state.status, RuntimeConnectionStatus.offline);
expect(state.primaryLabel, '离线');
expect(state.detailLabel, 'xworkmate-bridge 未连接');
expect(state.primaryLabel, '已退出登录');
expect(state.detailLabel, '请先登录 svc.plus');
});
test(
'keeps signed-out generic failures without address disconnected',
() async {
final controller = AppController();
final controller = await _isolatedController();
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
@ -105,15 +108,15 @@ void main() {
final state = controller.currentAssistantConnectionState;
expect(state.status, RuntimeConnectionStatus.offline);
expect(state.primaryLabel, '离线');
expect(state.detailLabel, 'xworkmate-bridge 未连接');
expect(state.primaryLabel, '已退出登录');
expect(state.detailLabel, '请先登录 svc.plus');
},
);
test(
'keeps gateway token missing as dedicated app-visible state',
() async {
final controller = AppController();
final controller = await _isolatedController();
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
@ -135,15 +138,15 @@ void main() {
final state = controller.currentAssistantConnectionState;
expect(state.status, RuntimeConnectionStatus.offline);
expect(state.primaryLabel, '离线');
expect(state.detailLabel, 'xworkmate-bridge 未连接');
expect(state.primaryLabel, '已退出登录');
expect(state.detailLabel, '请先登录 svc.plus');
},
);
test(
'treats missing endpoint as true offline instead of bridge failure',
() async {
final controller = AppController();
final controller = await _isolatedController();
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
@ -164,13 +167,13 @@ void main() {
final state = controller.currentAssistantConnectionState;
expect(state.status, RuntimeConnectionStatus.offline);
expect(state.primaryLabel, '离线');
expect(state.detailLabel, 'xworkmate-bridge 未连接');
expect(state.primaryLabel, '已退出登录');
expect(state.detailLabel, '请先登录 svc.plus');
},
);
test('desktop snapshot uses derived assistant connection labels', () async {
final controller = AppController();
final controller = await _isolatedController();
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
@ -191,7 +194,39 @@ void main() {
final snapshot = controller.desktopStatusSnapshot();
expect(snapshot['connectionStatus'], 'disconnected');
expect(snapshot['connectionLabel'], '离线');
expect(snapshot['connectionLabel'], '已退出登录');
});
});
}
Future<AppController> _isolatedController({
List<SingleAgentProvider>? initialBridgeProviderCatalog,
List<SingleAgentProvider>? initialGatewayProviderCatalog,
List<AssistantExecutionTarget>? initialAvailableExecutionTargets,
}) async {
final storeRoot = await Directory.systemTemp.createTemp(
'xworkmate-assistant-connection-state-',
);
addTearDown(() async {
if (await storeRoot.exists()) {
try {
await storeRoot.delete(recursive: true);
} on FileSystemException {
// Temp cleanup is best effort here.
}
}
});
final store = SecureConfigStore(
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
supportRootPathResolver: () async => '${storeRoot.path}/support',
enableSecureStorage: false,
);
await store.initialize();
return AppController(
store: store,
initialBridgeProviderCatalog: initialBridgeProviderCatalog,
initialGatewayProviderCatalog: initialGatewayProviderCatalog,
initialAvailableExecutionTargets: initialAvailableExecutionTargets,
);
}

View File

@ -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 <String, String>{},
initialBridgeProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],

View File

@ -317,7 +317,7 @@ void main() {
);
test(
'desktop task execution routes Hermes through provider public endpoint',
'desktop task execution routes Hermes through bridge RPC with provider params',
() async {
final capture = await _startAcpHttpServer();
addTearDown(capture.close);
@ -343,12 +343,19 @@ void main() {
);
expect(capture.authorizationHeader, 'Bearer bridge-token');
expect(capture.requestPath, '/acp-server/hermes/acp/rpc');
expect(capture.requestPath, '/acp/rpc');
expect(capture.requestPath, isNot(contains('/acp-server')));
expect(capture.requestPath, isNot(contains('/gateway/openclaw')));
expect(capture.requestBody, contains('"provider":"hermes"'));
expect(
capture.requestBody,
contains('"requestedExecutionTarget":"agent"'),
);
},
);
test(
'desktop task execution routes OpenClaw through gateway public endpoint',
'desktop task execution routes OpenClaw through bridge RPC with gateway params',
() async {
final capture = await _startAcpHttpServer();
addTearDown(capture.close);
@ -374,7 +381,15 @@ void main() {
);
expect(capture.authorizationHeader, 'Bearer bridge-token');
expect(capture.requestPath, '/gateway/openclaw/acp/rpc');
expect(capture.requestPath, '/acp/rpc');
expect(capture.requestPath, isNot(contains('/acp-server')));
expect(capture.requestPath, isNot(contains('/acp-server/gateway')));
expect(capture.requestPath, isNot(contains('/gateway/openclaw')));
expect(capture.requestBody, contains('"provider":"openclaw"'));
expect(
capture.requestBody,
contains('"requestedExecutionTarget":"gateway"'),
);
},
);
});
@ -454,6 +469,7 @@ Future<_CapturedAcpHttpServer> _startAcpHttpServer() async {
request.headers.value(HttpHeaders.authorizationHeader) ?? '';
capture.requestPath = request.uri.path;
final body = await utf8.decoder.bind(request).join();
capture.requestBody = body;
final id = _decodeRequestId(body);
request.response.headers.contentType = ContentType.json;
request.response.write(
@ -483,6 +499,7 @@ class _CapturedAcpHttpServer {
final Uri baseEndpoint;
String authorizationHeader = '';
String requestPath = '';
String requestBody = '';
Future<void> close() => _server.close(force: true);
}

View File

@ -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'";
}