From cc8cb7509c3e92ca12c23c7188931e489fbed2b2 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 25 Mar 2026 17:37:02 +0800 Subject: [PATCH] tooling: prune external acp bridge assumptions --- docs/howto/external-acp-bridge-config.md | 223 +++++++---------------- tool/configure_external_acp.dart | 73 +++++--- 2 files changed, 117 insertions(+), 179 deletions(-) diff --git a/docs/howto/external-acp-bridge-config.md b/docs/howto/external-acp-bridge-config.md index 4563e55d..120126c0 100644 --- a/docs/howto/external-acp-bridge-config.md +++ b/docs/howto/external-acp-bridge-config.md @@ -1,210 +1,119 @@ -# 外部 ACP 接入配置脚本 +# 外部 ACP Endpoint 预配置脚本 -这个 how-to 对应仓库内的独立工具: +这个工具是一个**外置 pre 动作**: ```bash dart tool/configure_external_acp.dart ``` -它只做一件事:生成或更新 XWorkmate 本地 `settings.yaml` 里的 `externalAcpEndpoints`,不改 Flutter 运行时代码,不碰 secrets。 +它只负责生成或更新 XWorkmate `settings.yaml` 里的 `externalAcpEndpoints`。 -前提: +它**不**做这些事: -- 在仓库根目录执行。 -- 首次在新 clone 上使用前,先跑一次 `flutter pub get`,确保 `.dart_tool/package_config.json` 已生成。 +- 不修改 Flutter runtime 代码 +- 不往 `.app` bundle、DMG 或打包脚本写任何内容 +- 不启动任何外部 provider、bridge、daemon 或 CLI +- 不写入 token、password、API key 等 secrets -## App Store 对齐约束 +## App Store 对齐边界 -这次工具链按 App Store 分发边界设计,约束如下: +当前脚本按 App Store 边界收敛为“纯配置助手”: -- 这是仓库外置脚本,不是 app bundle 内功能。 -- `codex`、`opencode`、`supergateway`、`mcp-bridge`、`gemini-bridge` 都必须作为用户侧或运维侧前置依赖,不能打包进 XWorkmate 的 App Store 构建产物。 -- 脚本只改用户态 `settings.yaml`,不改 entitlements、不改打包脚本、不往 DMG / `.app` bundle 写入任何第三方二进制或 secrets。 -- 外部 bridge 进程必须由用户在安装后手动启动;这次方案不要求、也不允许 App Store 包内自动拉起这些进程。 +- 脚本在 app 外运行 +- 只写用户态配置文件 +- 不再内置或推荐任何第三方 bridge 依赖 +- Claude / Gemini 在这里只是 endpoint 槽位,不绑定特定实现 -## 默认 provider 映射 +如果某个 provider 以后要接入,要求是: -脚本默认会把 4 个内置 provider 写成下面的 endpoint: +- 由你自行准备一个兼容的外部 endpoint +- XWorkmate 只消费 endpoint,不负责拉起依赖 -| Provider | Endpoint | 约定启动方式 | -| --- | --- | --- | -| Codex | `ws://127.0.0.1:9001` | `codex app-server --listen ws://127.0.0.1:9001` | -| OpenCode | `http://127.0.0.1:4096` | `opencode serve --port 4096` | -| Claude | `ws://127.0.0.1:9011` | 本地 bridge 槽位 | -| Gemini | `ws://127.0.0.1:9012` | 本地 bridge 槽位 | +## 默认 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` | -- 这里把 OpenCode 固定写成 `127.0.0.1:4096`。如果你之前记成 `27.0.0.1:4096`,这里应为 loopback 地址 `127.0.0.1:4096`。 -- 当前 XWorkmate external ACP 路径保存的是 provider endpoint;脚本负责“写配置”,不负责替你守护进程。 -- `settings.yaml` 是非敏感配置源。token、password、API key 仍不应该写进去。 +说明: -## 默认路径 +- 这些值只是默认槽位,不代表脚本会安装或启动任何 provider。 +- `Codex` / `OpenCode` 的本地地址被保留为示例默认值。 +- `Claude` / `Gemini` 仅保留 endpoint 占位,不再绑定第三方桥接包说明。 -脚本默认按宿主平台定位 `settings.yaml`: +## macOS 路径策略 -- macOS: `~/Library/Application Support/xworkmate/config/settings.yaml` -- Linux: `${XDG_CONFIG_HOME:-~/.config}/xworkmate/config/settings.yaml` -- Windows: `%APPDATA%\\xworkmate\\config\\settings.yaml` +macOS 默认增加了 App Sandbox 感知: -也可以显式传 `--settings-file` 覆盖。 +- `--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 ``` -按默认 endpoint 落盘,并自动备份原文件: +按自动路径策略写入: ```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 ``` -只打印将要写入的 YAML,不真正写文件: +只打印结果 YAML,不落盘: ```bash dart tool/configure_external_acp.dart apply --dry-run ``` -禁用某个 provider 槽位: +禁用某个槽位: ```bash dart tool/configure_external_acp.dart apply --disable-claude ``` -## 原生 provider - -### Codex - -启动: - -```bash -codex app-server --listen ws://127.0.0.1:9001 -``` - -写配置: - -```bash -dart tool/configure_external_acp.dart apply \ - --codex-endpoint ws://127.0.0.1:9001 -``` - -### OpenCode - -启动: - -```bash -opencode serve --port 4096 -``` - -写配置: - -```bash -dart tool/configure_external_acp.dart apply \ - --opencode-endpoint http://127.0.0.1:4096 -``` - -说明: - -- 这是按当前约定写入的原生 OpenCode endpoint。 -- XWorkmate 当前 single-agent 运行时会把 `http/https` endpoint 归一化成 `ws/wss` 连接再发 JSON-RPC 请求,所以 OpenCode 端是否完全兼容,取决于你前面的桥是否提供了 XWorkmate 当前期望的方法集。 -- 这次变更不改项目代码,因此这里只负责把槽位写好,不额外实现 OpenCode 协议适配。 - -## 桥接 provider - -### Gemini - -你给出的要求是: - -1. 本地起一个 `stdio` MCP server -2. 再把它桥到 XWorkmate 使用的本地 endpoint - -推荐的最小链路: - -1. 安装 Gemini CLI 并登录 -2. 安装 `gemini-bridge` -3. 用 `supergateway` 把本地 `stdio` MCP server 暴露到 WebSocket 端口 - -参考命令: - -```bash -npm install -g @google/gemini-cli -gemini auth login -pip install gemini-bridge -npx -y supergateway \ - --stdio "uvx gemini-bridge" \ - --outputTransport ws \ - --port 9012 \ - --messagePath / -``` - -然后写入: - -```bash -dart tool/configure_external_acp.dart apply \ - --gemini-endpoint ws://127.0.0.1:9012 -``` - -### Claude - -你给出的要求同样是: - -1. 本地起一个 `stdio` bridge -2. 再桥到 XWorkmate 的本地 endpoint - -按这次文档约定,Claude 槽位使用: - -- 本地 bridge 命令:`mcp-bridge` -- WebSocket 暴露:`supergateway` -- 本地 endpoint:`ws://127.0.0.1:9011` - -参考命令: - -```bash -pip install mcp-bridge -mcp-bridge init -``` - -编辑: - -```text -~/.config/mcp-bridge/config.json -``` - -填入你的远程 MCP URL 和认证信息后,再启动本地桥: - -```bash -npx -y supergateway \ - --stdio "mcp-bridge" \ - --outputTransport ws \ - --port 9011 \ - --messagePath / -``` - -然后写入: - -```bash -dart tool/configure_external_acp.dart apply \ - --claude-endpoint ws://127.0.0.1:9011 -``` - ## 兼容性边界 -这次提交只补“配置脚本 + 使用说明”,不改 XWorkmate runtime,所以边界需要说清楚: - -- 脚本保证 `externalAcpEndpoints` 写法正确,并保留非内置 custom provider 条目。 -- 脚本不会自动拉起 `codex`、`opencode`、`supergateway`、`mcp-bridge` 或 `gemini-bridge`。 -- 脚本不会写入任何 secret。 -- Claude / Gemini 这里走的是“本地 bridge endpoint 槽位”方案。是否能被当前 XWorkmate runtime 直接消费,取决于桥后的协议是否与当前 external ACP 路径兼容。 -- 如果后续要把 Claude / Gemini 做成真正的开箱即用 provider,需要再补一层项目内协议适配;这次明确不做。 +- 这个脚本只负责 `externalAcpEndpoints` +- 它会保留非内置 custom provider 条目 +- 它不会判断某个 endpoint 背后是否真的可用 +- 它不会绕过 XWorkmate 在 App Store 构建里对外部 CLI / 本地 runtime 的禁用策略 diff --git a/tool/configure_external_acp.dart b/tool/configure_external_acp.dart index 9e810623..ade6b45a 100644 --- a/tool/configure_external_acp.dart +++ b/tool/configure_external_acp.dart @@ -2,6 +2,8 @@ import 'dart:io'; import 'package:yaml/yaml.dart'; +const String _macosBundleIdentifier = 'plus.svc.xworkmate'; + const Map _providerLabels = { 'codex': 'Codex', 'opencode': 'OpenCode', @@ -16,15 +18,6 @@ const Map _defaultEndpoints = { 'gemini': 'ws://127.0.0.1:9012', }; -const Map _defaultLaunchCommands = { - 'codex': 'codex app-server --listen ws://127.0.0.1:9001', - 'opencode': 'opencode serve --port 4096', - 'claude': - 'npx -y supergateway --stdio "mcp-bridge" --outputTransport ws --port 9011 --messagePath /', - 'gemini': - 'npx -y supergateway --stdio "uvx gemini-bridge" --outputTransport ws --port 9012 --messagePath /', -}; - void main(List args) async { final options = _CliOptions.parse(args); if (options.showHelp) { @@ -37,6 +30,7 @@ void main(List args) async { _defaultSettingsFile( environment: Platform.environment, operatingSystem: Platform.operatingSystem, + scope: options.settingsScope, ); final resolvedEndpoints = { for (final entry in _defaultEndpoints.entries) @@ -49,6 +43,7 @@ void main(List args) async { settingsFile: settingsFile, endpoints: resolvedEndpoints, modeLabel: 'print-only', + settingsScope: options.settingsScope, ), ); return; @@ -82,6 +77,7 @@ void main(List args) async { settingsFile: settingsFile, endpoints: resolvedEndpoints, modeLabel: 'applied', + settingsScope: options.settingsScope, ), ); } @@ -94,10 +90,11 @@ Usage: Commands: apply Update XWorkmate settings.yaml externalAcpEndpoints. - print Print the resolved endpoint plan and suggested launch commands. + print Print the resolved endpoint plan. Options: --settings-file Override settings.yaml path. + --settings-scope macOS only: auto | sandbox | user. --codex-endpoint Default: ${_defaultEndpoints['codex']} --opencode-endpoint Default: ${_defaultEndpoints['opencode']} --claude-endpoint Default: ${_defaultEndpoints['claude']} @@ -113,11 +110,11 @@ Options: Notes: - This tool only updates the externalAcpEndpoints block and preserves all other settings keys. - - App Store-safe usage means running this tool outside the shipped app bundle - and keeping Codex/OpenCode/Supergateway/MCP bridge binaries outside the - bundle as user-managed prerequisites. - - Default macOS settings path: - ~/Library/Application Support/xworkmate/config/settings.yaml + - 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 '''; @@ -127,32 +124,36 @@ String _renderPlan({ required File settingsFile, required Map endpoints, required String modeLabel, + required _SettingsScope settingsScope, }) { final buffer = StringBuffer() ..writeln() ..writeln('Settings file: ${settingsFile.path}') ..writeln('Mode: $modeLabel') + ..writeln('Settings scope: ${settingsScope.name}') ..writeln('Provider endpoint plan:'); for (final provider in _providerLabels.keys) { buffer.writeln('- ${_providerLabels[provider]}: ${endpoints[provider]}'); - buffer.writeln(' launch: ${_defaultLaunchCommands[provider]}'); } buffer ..writeln() - ..writeln('Bridge notes:') + ..writeln('Scope notes:') ..writeln( - '- Codex is configured for native app-server WebSocket on port 9001.', + '- This tool configures endpoint slots only. Provider launch and bridge orchestration stay external to the app.', ) ..writeln( - '- OpenCode is configured to the native HTTP server on port 4096.', + '- On macOS, auto scope prefers the App Sandbox container after the app has launched at least once.', ) ..writeln( - '- Claude and Gemini are assigned local bridge slots; see docs/howto/external-acp-bridge-config.md for install and compatibility notes.', + '- App Store alignment: no external runtime binary is bundled or auto-started by this tool.', ) ..writeln( - '- App Store alignment: all bridge binaries stay outside the app bundle and must be started manually after install.', + '- 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(); } @@ -227,12 +228,25 @@ Future> _readExistingSettings(File settingsFile) async { File _defaultSettingsFile({ required Map environment, required String operatingSystem, + required _SettingsScope scope, }) { final home = environment['HOME']?.trim() ?? ''; if (operatingSystem == 'macos' && home.isNotEmpty) { - return File( + 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() ?? ''; @@ -257,6 +271,8 @@ File _defaultSettingsFile({ enum _Command { apply, printPlan } +enum _SettingsScope { auto, sandbox, user } + class _CliOptions { const _CliOptions({ required this.command, @@ -264,6 +280,7 @@ class _CliOptions { required this.dryRun, required this.backup, required this.settingsFile, + required this.settingsScope, required this.endpoints, required this.enableProviders, }); @@ -273,6 +290,7 @@ class _CliOptions { final bool dryRun; final bool backup; final File? settingsFile; + final _SettingsScope settingsScope; final Map endpoints; final Map enableProviders; @@ -284,6 +302,7 @@ class _CliOptions { dryRun: false, backup: true, settingsFile: null, + settingsScope: _SettingsScope.auto, endpoints: const {}, enableProviders: const {}, ); @@ -305,6 +324,7 @@ class _CliOptions { var dryRun = false; var backup = true; File? settingsFile; + var settingsScope = _SettingsScope.auto; final endpoints = {}; final enableProviders = {}; @@ -321,6 +341,7 @@ class _CliOptions { dryRun: dryRun, backup: backup, settingsFile: settingsFile, + settingsScope: settingsScope, endpoints: endpoints, enableProviders: enableProviders, ); @@ -356,6 +377,13 @@ class _CliOptions { 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; @@ -380,6 +408,7 @@ class _CliOptions { dryRun: dryRun, backup: backup, settingsFile: settingsFile, + settingsScope: settingsScope, endpoints: endpoints, enableProviders: enableProviders, );