@yufengtadian/freedom-cli 1.13.2 → 1.14.0-preview

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +40 -0
  2. package/README.md +74 -21
  3. package/lib/agents.js +182 -52
  4. package/lib/build.js +119 -42
  5. package/lib/cli.js +106 -9
  6. package/lib/release.js +22 -6
  7. package/lib/security.js +525 -45
  8. package/lib/shell.js +288 -64
  9. package/lib/toolchain.js +339 -0
  10. package/lib/utils.js +51 -0
  11. package/lib/verify.js +85 -30
  12. package/lib/webkit.js +45 -0
  13. package/package.json +4 -3
  14. package/shell/darwin-arm64/freedom-shell +0 -0
  15. package/shell/linux-x64/freedom-shell +0 -0
  16. package/shell/win-x64/freedom-shell.exe +0 -0
  17. package/skill/freedom/SKILL.md +3 -3
  18. package/templates/desktop/backend/desktop.mjs +16 -6
  19. package/templates/desktop/freedom.config.js +4 -1
  20. package/templates/go/go.mod +3 -0
  21. package/templates/go/pkg/freedom/anti_debug_linux.go +69 -0
  22. package/templates/go/pkg/freedom/anti_debug_other.go +5 -3
  23. package/templates/go/pkg/freedom/anti_debug_windows.go +136 -12
  24. package/templates/go/pkg/freedom/assets_embed.go +1 -0
  25. package/templates/go/pkg/freedom/backend_proc.go +5 -5
  26. package/templates/go/pkg/freedom/events_windows.go +5 -5
  27. package/templates/go/pkg/freedom/freedom.go +17 -3
  28. package/templates/go/pkg/freedom/msgwindow_windows.go +8 -8
  29. package/templates/go/pkg/freedom/resources.go +59 -1
  30. package/templates/go/pkg/freedom/security.go +475 -41
  31. package/templates/go/pkg/freedom/shutdown.go +55 -0
  32. package/templates/go/pkg/freedom/shutdown_windows.go +47 -0
  33. package/templates/go/pkg/freedom/store.go +3 -3
  34. package/templates/go/pkg/freedom/syscap_windows.go +33 -33
  35. package/templates/go/pkg/freedom/sysint_windows.go +2 -2
  36. package/templates/go/pkg/freedom/tray_windows.go +18 -18
  37. package/templates/go/pkg/freedom/updater.go +3 -3
  38. package/templates/go/third_party/webview_go/CHANGELOG.md +15 -0
  39. package/templates/go/third_party/webview_go/FREEDOM-PATCH.md +32 -0
  40. package/templates/go/third_party/webview_go/LICENSE +22 -0
  41. package/templates/go/third_party/webview_go/README.md +50 -0
  42. package/templates/go/third_party/webview_go/glue.c +36 -0
  43. package/templates/go/third_party/webview_go/go.mod +3 -0
  44. package/templates/go/third_party/webview_go/go.sum +0 -0
  45. package/templates/go/third_party/webview_go/libs/mswebview2/LICENSE +27 -0
  46. package/templates/go/third_party/webview_go/libs/mswebview2/include/WebView2.h +23568 -0
  47. package/templates/go/third_party/webview_go/libs/mswebview2/include/vendor.go +2 -0
  48. package/templates/go/third_party/webview_go/libs/mswebview2/vendor.go +2 -0
  49. package/templates/go/third_party/webview_go/libs/mswebview2/version.txt +1 -0
  50. package/templates/go/third_party/webview_go/libs/webview/LICENSE +22 -0
  51. package/templates/go/third_party/webview_go/libs/webview/include/vendor.go +2 -0
  52. package/templates/go/third_party/webview_go/libs/webview/include/webview.h +3598 -0
  53. package/templates/go/third_party/webview_go/libs/webview/vendor.go +2 -0
  54. package/templates/go/third_party/webview_go/libs/webview/version.txt +1 -0
  55. package/templates/go/third_party/webview_go/webkit2_40.go +21 -0
  56. package/templates/go/third_party/webview_go/webkit2_41.go +8 -0
  57. package/templates/go/third_party/webview_go/webview.cc +1 -0
  58. package/templates/go/third_party/webview_go/webview.go +332 -0
package/LICENSE ADDED
@@ -0,0 +1,40 @@
1
+ Freedom —— 专有软件许可(Proprietary License)
2
+
3
+ Copyright (c) 2026 YUfeng12TA(邮箱与 GitHub 账号见仓库 https://github.com/YUfeng12TA/freedom )
4
+
5
+ 本仓库(下称「本软件」)为闭源专有软件:**不适用 MIT、Apache、GPL 等任何开源许可**。
6
+ 本文件同时随 npm 包 `@yufengtadian/freedom-cli` 分发(包目录内 `LICENSE` 与本文件同文)。
7
+
8
+ ## 1. 授权范围(你可以做什么)
9
+
10
+ - 安装与使用本软件(CLI、Freedom Desktop、预编译壳)于任意数量的个人 / 团队 / 商用设备。
11
+ - 用本软件打包、签名、分发、销售**你自己的应用**:你的前端代码、后端代码、`resources/`
12
+ 内容与应用数据归你所有,无需就产物向许可方付费、开源或署名。
13
+ - 出于自身部署、审计与安全研究目的**阅读**随包分发的源码(见第 4 条的边界)。
14
+
15
+ ## 2. 限制(未经许可方书面同意不得做什么)
16
+
17
+ - 再分发本软件源码、预编译壳、npm 包内容物,或任何衍生 / 修改版本(第 4 条的第三方组件除外)。
18
+ - 移除、遮盖或改动版权声明、许可文本与完整性校验信息。
19
+ - 将本软件或其副本作为独立产品对外销售、托管、转许可或用于构建竞争性打包工具。
20
+ - 绕过 FRDM2 加密容器与反调试机制,对他人分发的产物提取源码,或将该成果对外发布
21
+ (安全研究请走 `SECURITY.md` 的披露通道)。
22
+
23
+ ## 3. 第三方组件不受本文件改写
24
+
25
+ - `third_party/webview_go/`(及其在 `freedom-cli/templates/go/` 下的镜像副本)为
26
+ [webview_go](https://github.com/webview/webview_go) 的上游补丁副本,沿用其 **MIT** 许可
27
+ (原文见该目录 `LICENSE`),改动记录见同目录 `FREEDOM-PATCH.md`。
28
+ - Go、Node.js、WebView2 SDK、WebKitGTK/GTK、NSIS、rcedit 等各自按其许可证使用。
29
+ - 本文件不对这些组件施加任何额外限制。
30
+
31
+ ## 4. 免责与无担保
32
+
33
+ 本软件按「现状」提供,不含任何明示或暗示的担保。FRDM2 容器、完整性清单与反调试检测的设计
34
+ 目标是**提高逆向成本**,不是可信执行环境,不构成「不可逆向」的承诺(详见 `SECURITY.md`)。
35
+ 在适用法律允许的最大范围内,许可方不对因使用或无法使用本软件造成的任何损害承担责任。
36
+
37
+ ## 5. 条款变更与联系
38
+
39
+ 许可条款可能在新版本中调整,以你所获得版本随附的本文件为准。商用授权、白标或源码买断等
40
+ 需求,请通过仓库 GitHub Issues 与许可方联系。
package/README.md CHANGED
@@ -1,17 +1,20 @@
1
1
  # freedom-cli
2
2
 
3
- Freedom 桌面壳打包工具:把你的 Web 前端一键打包成跨平台桌面应用(v1.13.2)。
3
+ Freedom 桌面壳打包工具:把你的 Web 前端一键打包成跨平台桌面应用(v1.14.0-preview)。
4
4
 
5
5
  基于自研 Freedom WebView 壳层(对标 Wails / Tauri):前端完全自由、后端可任意语言、渲染复用系统 WebView(Windows WebView2 / macOS WKWebView / Linux WebKitGTK),产物为单个可执行文件 + resources 目录,前端页面内存加载,不占本地端口。
6
6
 
7
+ **v1.14.0-preview 加密代际断代(FRDM2 → FRDM3)+ 发布密钥体系**:① `.integrity` 改为 **ed25519 签名的 v3 清单**,签名对象覆盖 `app.bin` 摘要、应用标识、容器盐与 exe 自身摘要,公钥构建期注入壳——没有发布方私钥就签不出合法清单;② 容器主密钥从"随 npm 包分发的全域常量"改为**每产物随机密钥**(`.freedom/keys/<app>.key`,`freedom keygen` 生成,永不进包),1.13.x 那条"持有 CLI 者可离线解密任意产物"的结构缺陷(台账 B-20260925-054)就此封堵;③ **high 收为 Tier B 专属**——加密产物必须由本机现编的应用专属壳承载(需 Go 工具链、不支持交叉编译),零工具链通用壳(Tier A)请用 `basic`;④ 安全校验失败从"打印告警后返回"改为**退出码 70**,脚本与 CI 从此能区分"拒跑"与"正常退出";⑤ `freedom keygen` 支持 `--dir <项目目录>`(此前静默按 cwd 写,会把密钥落到错误位置)。**FRDM1/FRDM2 旧产物在新壳上明确拒绝运行(不静默降级),须用 1.14.0-preview 重新 `freedom build`**;三平台壳随本版本经 CI 重编发布。
8
+
7
9
  **v1.13.x 框架线回归 + 稳定性收口**(v1.13.0 功能,v1.13.1 为文档勘误发布):
8
10
  - **freedom-cli 源码回归主仓库**:本 CLI 与框架源码同仓维护(`freedom-cli/` 目录,`templates/go` 为框架源码快照),npm 包与 GitHub Release 一一对应,历史"源码丢失停在 1.12.18"的断档已修复;
9
11
  - **三平台通用壳经 tag CI 自动发布**:推送 `vX.Y.Z` tag 即由 `build.yml` 在 win / mac(Apple Silicon)/ linux runner 上编译通用壳并自动创建 GitHub Release,资产命名 `freedom-shell-<plat>`;`freedom shell download <plat>` 直接拉取对应版本资产(默认定位 `v<包版本>`,可用 `FREEDOM_SHELL_TAG` 覆盖),包内另自带三平台壳兜底,均随包分发;
10
- - **运行时资源层回归**:壳从 exe 同目录 `resources/` 读取 `config.json`(窗口 / 后端配置覆盖)与前端页面,high 模式下前端页面、配置与 **`backend/**` 后端源码**统一封进加密 `app.bin`(FRDM2 容器:构建期随机盐 + PBKDF2 60 万次派生 + Encrypt-then-MAC 覆盖头部)+ `.integrity` 清单,磁盘不留明文后端源码;JS 侧构建加密 → Go 壳内存解密已有跨语言黄金向量与端到端互验(改名 / 篡改 / 整体替换即拒绝运行);
12
+ - **运行时资源层回归**:壳从 exe 同目录 `resources/` 读取 `config.json`(窗口 / 后端配置覆盖)与前端页面,high 模式下前端页面、配置与 **`backend/**` 后端源码**统一封进加密 `app.bin`(FRDM3 容器:构建期随机盐 + PBKDF2 60 万次派生 + Encrypt-then-MAC 覆盖头部)+ **ed25519 签名的 `.integrity` 清单**,磁盘不留明文后端源码;JS 侧构建加密 → Go 壳内存解密已有跨语言黄金向量与端到端互验(改名 / 篡改 / 整体替换 / 换公钥文件即拒绝运行,退出码 70)。**边界如实说明**:high 是"每产物专属密钥 + 发布方私钥签名"的加密加固,强度目标是"读不到明文、改不动内容、重打包跑不起来",不是 DRM 也不是访问控制——运行期密钥仍在壳进程内存里,能读内存或愿意自编壳的对手不在防护范围内(详见根仓库 [SECURITY.md](../SECURITY.md) 的 Tier A / Tier B 表);
11
13
  - **多窗口(M2)随 v1.13.0 壳可用**:前端 `window.freedom.window.create / close / list / focus` 开二级窗口,Go 侧 `App.NewWindow / Window.Close`;次级窗口独立消息泵、页面源支持内联 HTML / URL;
12
14
  - **销毁竞态收口**:修复 webview2 在 `Destroy` 中泵出滞留 dispatch 回调导致的随机崩溃(0xc0000005,多窗口 / 快速关闭场景),回收后所有排队回调按拆除旗标自我作废,并配套红绿回归测试;
13
15
  - **v1.13.1**:文档同步(本 README 更新至 v1.13.x 真实现状、壳 CI 章节勘误),无功能与壳二进制变更。
14
16
  - **v1.13.2 反逆向加固(FRDM2)**:容器升 FRDM2(构建期随机盐 + PBKDF2 60 万次 + enc/mac 域分离 + MAC 覆盖容器头部),主密钥改为掩码表运行时组装(壳二进制 `strings` 直取不到),`backend/**` 后端源码一并入容器并在运行期解密到私有临时目录(退出即删、崩溃残留按 PID 回收),前端产物 `sourceMappingURL` 构建期抹除,anti-debug 扩到六道信号,本地与 CI 壳统一 `-trimpath -s -w`。**旧版本 CLI 产出的 FRDM1 资源包在新壳上明确拒绝运行(不静默降级),须重新 `freedom build`**;三平台壳随本版本经 CI 重编发布。
17
+ - **v1.13.3 内置工具链扩展 + 加固与健壮性收口**:新增 `freedom toolchain status|install|optimize|clear`,探测 / 安装 / 优化 C++(MSVC / MinGW / clang)、Rust、Go 三条工具链并按平台映射到 winget / brew / apt;探测结论缓存(绑 PATH 指纹 + 7 天 TTL)**只缓存成功**,即「检测到配置好就不再实探」,刚装好的工具当场可识别,`install` 默认只打印命令、`--apply` 才执行。同轮收口:Windows 第七道反调试信号(`DR0–DR3` 硬件断点)、SIGTERM / `CTRL_CLOSE_EVENT` 退出清扫通道(临时目录不再等下次启动回收)、`.integrity` 缺失即拒绝启动、Linux WebKitGTK 4.1 构建路径自动注入 `-tags=webkit2_41`、`shell build` / `shell download` 成功却非零退出的退出码修正、产物自检不再把 `.integrity` 大小说成 0 B。许可改为闭源专有许可(详见根 `LICENSE`)。
15
18
 
16
19
  **v1.12.17 安全模式全面落地**:三档安全模式 `freedom security <none|basic|high>` 正式随包分发——high 档把 resources 加密为 `app.bin`(AES-256-CTR + HMAC-SHA256 + PBKDF2 密钥派生),配合 `.integrity` 完整性校验、anti-debug 与进程隐藏,磁盘无明文、篡改即拒运行;三平台预编译壳经 CI 重建分发,补齐 v1.12.16 仅重编 win-x64 壳的缺口。
17
20
 
@@ -138,6 +141,15 @@ mac 用户解压 `.app.zip` 即得 `.app`,拖入 `/Applications` 即可直接
138
141
 
139
142
  **预编译壳的来源与 CI**:`webview_go` 依赖各系统自带 WebView 框架,**无法交叉编译**,三平台壳必须在对应平台本机编译。自 v1.13.0 起由仓库 `.github/workflows/build.yml` 统一承担:推送 `vX.Y.Z` tag → 三平台 runner 编译通用壳 → `release` job 自动创建 GitHub Release 并上传资产 `freedom-shell-<plat>`,供 `freedom shell download` 按「`v<包版本>` tag + 同名资产」拉取(Intel Mac 已不支持,见 `nativePlatform()` 的明确报错)。发版顺序:先推 GitHub tag、等 Release 资产就绪,再 `npm publish` 同版本——保证 shell 下载默认源始终可用(紧急时可设 `FREEDOM_SHELL_TAG` 指向既有 tag)。
140
143
 
144
+ **发布代际预检(`npm publish` 前置门)**:`package.json` 的 `prepublishOnly` 会跑 `lib/shell.js` 的 `preflightBundledShells()`——任一随包壳(`shell/<plat>`)的生成时间早于 `templates/go` 内最新框架源即**拒绝发布**。防的是「改了框架源、忘了重新同步壳」这类混合代 tarball:新 CLI 产的 FRDM3 产物会被旧壳直接拒跑(`npm publish --ignore-scripts` 可绕过,仅限确知安全时)。
145
+
146
+ **平台名写法与下载回退**:`--platform` 与 `freedom shell download/build` 接受别名与架构后缀——
147
+ `win` / `windows` / `mac` / `macos` / `osx` / `ubuntu` / `linux-x86_64` / `mac-arm64` 等统一归一到
148
+ `win-x64` / `darwin-arm64` / `linux-x64`;无法支持的组合(`win-x86`、`darwin-x64`、`plan9`)直接报「未知平台」并给出可选值,不做猜测。
149
+ 下载有两条路径:先试直连 `releases/download`,不通(超时 / 代理 / 网络策略)时自动回退 GitHub Release API 资产端点;
150
+ 两条都不通则错误信息分别给出两端点与手工放置目录(`FREEDOM_SHELL_DIR` 可覆写壳目录,私有镜像另见 `FREEDOM_SHELL_REPO` / `FREEDOM_SHELL_BASE` / `FREEDOM_GITHUB_API`),
151
+ GitHub 限流时可带 `FREEDOM_GITHUB_TOKEN`。
152
+
141
153
  产物目录可在 `freedom.config.js` 的 `outDir` 中调整:默认 `'dist'`,设为 `'.'` 则直接输出到项目根目录(dist 的上级),设为任意相对 / 绝对路径亦可。输出到项目根目录时会自动跳过 `index.html` 副本,避免覆盖项目源文件。
142
154
 
143
155
  ```bash
@@ -194,7 +206,18 @@ export default {
194
206
  | --- | --- | --- | --- |
195
207
  | `none` | 明文 `index.html` + `config.json`(默认,兼容历史产物) | 解包即读 | 公开页面 / 调试 / 快速分发 |
196
208
  | `basic` | 同上明文,构建时额外输出加固建议 | 低 | 需要提示、暂不加密 |
197
- | `high` | 前端 HTML + 配置 + **后端源码**整体加密为 `resources/app.bin` + `.integrity`,磁盘**无任何明文** | 高(需逆向壳 + 还原派生密钥) | 防源码提取、防资源篡改的正式分发 |
209
+ | `high` | 前端 HTML + 配置 + **后端源码**整体加密为 `resources/app.bin` + **签名** `.integrity`,磁盘**无任何明文** | 高(需逆向壳 + 拿到发布方私钥与每产物主密钥) | 防源码提取、防重打包的正式分发;**仅 Tier B(自编译壳)** |
210
+
211
+ **两档壳,强度不同(1.14.0-preview 起的口径,务必对号入座)**:
212
+
213
+ | 档 | 壳怎么来 | 能跑 `high` 产物吗 | 说明 |
214
+ | --- | --- | --- | --- |
215
+ | **Tier A** | 通用预编译壳(`freedom build` 零工具链,`none` / `basic` 用的就是它:随包分发或按需下载) | ❌ | 壳是公共二进制,没有构建期注入点 ⇒ 每产物主密钥与验签公钥无处安放,硬放进产物等于让攻击者自签自验 |
216
+ | **Tier B** | 本应用自编译壳(`freedom build --security high` 自动走这条:内部调 `freedom shell build` 现编,需 Go 工具链) | ✅ | 主密钥与 ed25519 公钥编译期内嵌进**这个应用专属的壳**,签名清单与它一一绑定 |
217
+
218
+ 所以 `high` 一档自带三个门槛,缺一个即拒绝构建(不静默降级):`.freedom/keys/<app>.key` 每产物主密钥、
219
+ `.freedom/keys/update_ed25519` 签名私钥(两者由 `freedom keygen` 生成)、以及**在本机为目标平台编译**
220
+ (webview_go 依赖目标平台系统 WebView,无法交叉编译,跨平台 high 会直接报明)。
198
221
 
199
222
  **切换方式**(二选一,`--security` 可临时覆盖配置文件):
200
223
 
@@ -204,16 +227,27 @@ freedom build --security high # 单次构建生效(不改配置)
204
227
  freedom build # 读取配置中的 security 值
205
228
  ```
206
229
 
207
- **high 模式原理(FRDM2 容器)**:
208
- - 容器布局:`FRDM2`(5B) + `salt`(16B,**每次构建随机**) + `iv`(16B) + `tag`(16B) + 密文;密钥 = **PBKDF2-HMAC-SHA256**(60 万次迭代,主密钥经字节表掩码内置于壳与 CLI)按「应用可执行文件名 + 容器随机盐」派生,再由固定标签做 **HMAC 域分离**得到独立的加密钥与认证钥;
209
- - 认证为 **Encrypt-then-MAC 且覆盖容器头部**(magic/salt/iv 参与计算),故改盐、改 IV、翻转任意一字节都会认证失败;`.integrity` 清单绑定同一盐值,**整体替换成另一个合法容器**同样拒绝运行;
230
+ **high 模式原理(FRDM3 容器 + 签名清单)**:
231
+ - 容器布局:`FRDM3`(5B) + `salt`(16B,**每次构建随机**) + `iv`(16B) + `tag`(16B) + 密文;密钥 = **PBKDF2-HMAC-SHA256**(60 万次迭代)以「**每产物主密钥**(`.freedom/keys/<app>.key`,32B 随机,`freedom keygen` 生成;不进 npm 包、不以明文落进 `resources/`,只以"本产物专属装配码"的形式编进本应用专属壳(见下))+ 应用标识 + 容器随机盐」派生,再由固定标签做 **HMAC 域分离**得到独立的加密钥与认证钥;
232
+ - 完整性为 **Encrypt-then-MAC 且覆盖容器头部**(magic/salt/iv 参与计算),故改盐、改 IV、翻转任意一字节都会认证失败;`resources/.integrity` 是 **ed25519 签名的 v3 清单**(签名对象是清单里 base64 那段确切的字节,跨语言不做 JSON 规范化), claims 绑定 `app.bin` 摘要、应用标识、容器盐与 **exe 自身摘要**——换内容、换身份、换盐、**给 exe 改名**全部当场拒;
233
+ - **验签顺序不可换**:先比对清单公钥与壳内信任锚(锚必须在被校验对象之外,否则攻击者换对钥匙即自证合法)→ 验签 → 复查 claims 绑定 → exe 自摘要 → 才解密;
210
234
  - **后端源码不再明文落盘**:`backend/**`(含 POSIX 权限位)作为条目进入容器,磁盘上不再有 `resources/backend/`;壳启动解密到仅属主可访问的一次性临时目录(0700 / 文件 0600,执行位按容器记录还原)供子进程执行,进程退出即删除;若被强杀或崩溃来不及删,**下次启动会按目录名内嵌的 PID 自动回收残留**;
211
- - 构建期抹除前端产物中的 `sourceMappingURL` 引用(防 source map 还原源码),壳二进制一律 `-trimpath -ldflags "-s -w"`(Windows 另加 `-H windowsgui`);
212
- - 解密 / 校验失败即拒绝运行(不静默回退明文,防降级攻击;旧版 `FRDM1` 容器明确报"版本不支持");
235
+ - **运行期明文只有一份、派生钥不驻留**:一份产物在进程内**只解密一次**(配置与页面两处加载共用同一份已校验载荷;验签链每次照跑,缓存不参与判定),
236
+ PBKDF2 派生出的加密钥与认证钥**只在解密那几毫秒内存在、返回即抹零**,不写进任何跨调用缓存——
237
+ 否则对手只要做一次内存取样拿到派生钥,就能对磁盘上的 `app.bin` 永久离线解密(容器盐与 IV 都在容器头里,除派生钥外无第二个秘密输入);
238
+ 后端源文写进私有临时目录后,其在内存里的那份副本同样立即抹零。诚实边界:前端页面与配置的明文必须交给 webview,
239
+ **必然常驻内存**,这一层擦不掉;
240
+ - 构建期抹除前端产物中的 `sourceMappingURL` 引用(防 source map 还原源码),壳二进制一律 `-trimpath -ldflags "-s -w"`(Windows 另加 `-H windowsgui`);**主密钥没有 `-X` 通道**:每次 `freedom build --security high` 现场生成一份**只属于本产物**的装配码(`keyslot_<tag>.go`,分片数 / 切法 / 变换 / 参数全随机),经 `go build -overlay` 虚拟进壳的包目录后随编译落入 exe、临时文件即删(`strings` 与十六进制扫描都定位不到那 32 字节,且从 A 产物逆出的提取流程对 B 产物无效);
241
+ - 解密 / 校验失败即**拒绝运行并以退出码 70 结束**(不静默回退明文,防降级攻击;拒跑必须非零,否则脚本与 CI 眼里"拒跑"与"正常退出"同形);旧代际 `FRDM1`/`FRDM2` 容器明确报"旧代际、请重新打包";
213
242
  - 另内置 **anti-debug**:`IsDebuggerPresent`、`CheckRemoteDebuggerPresent`、直读 `PEB.BeingDebugged`、`NtQueryInformationProcess` 的 `ProcessDebugPort` / `ProcessDebugObjectHandle` / `ProcessDebugFlags` 共六道独立信号,任一确证即静默退出(退出码 77),在资源解密前后各检测一次;所有探测遵循"取不到即视为未命中",杜绝误杀;
214
243
  - 进程隐藏加固(`-H windowsgui`,无控制台窗口)。
215
244
 
216
- **加固上限说明**:`high` 大幅提高破解门槛,但**任何客户端可执行程序都无法做到绝对不可破解**——密钥最终存在于壳二进制与运行时内存中,反调试只抬升动态分析成本、不是不可绕过的墙。真正敏感的密钥与业务逻辑仍应留在服务端。
245
+ **加固上限说明**:`high` 大幅提高破解门槛,但**任何客户端可执行程序都无法做到绝对不可破解**——密钥最终存在于壳二进制与运行时内存中,反调试只抬升动态分析成本、不是不可绕过的墙;Tier B 也挡不住"攻击者自换一对密钥再编一份自己的壳"(那已经是整个应用归攻击者,再上一层只有 OS 级出版商信任 / Authenticode)。真正敏感的密钥与业务逻辑仍应留在服务端。
246
+ 两条当前已知边界(如实列出,勿当作"已修"):① 主密钥的装配码在壳里、生成器在公开源里,故静态逆向出某个壳的人可以拼回那把主密钥——但 1.14.0-preview 起装配码**每个产物一份**(分片与变换随机),针对 A 产物得到的脱壳流程不能直接复用到 B 产物,抬的是自动化与批量脱壳的成本,不是"逆向就是拿不到";② 前端页面与配置的明文必须交给 webview,能读进程内存者始终看得到那一份(后端源码已在物化后擦除,见上)。
247
+ **密钥即发布权**:`.freedom/keys/`(每产物主密钥 + ed25519 私钥)丢失后**无法再为该应用产出可运行的 high 产物**(既有产物不受影响),请把它们当作发布证书备份;反之它们一旦外泄,拿到者就能为你的应用签出合法篡改清单——勿入库、勿分发(`freedom keygen` 会自动往 `.gitignore` 补行)。**误报可关**:反调试在 CI、自动化测试、远程桌面、部分虚拟化环境可能把正常运行判成被附加调试器
248
+ (命中即静默退出,码 77)。这类场景设 `FREEDOM_DISABLE_ANTIDEBUG=1`,或在壳层源码里 `Config{DisableAntiDebug: true}`
249
+ 后 `freedom shell build`——只关探测,容器解密与 `.integrity` 完整性校验照旧生效(那两道才是源码保护的本体)。
250
+ `resources/config.json` 里刻意不提供此开关:打包产物里的配置运行期可被第三方替换,给开关等于递刀。
217
251
 
218
252
  **互斥规则**:切换安全模式重新构建时,CLI 会自动清理另一模式的遗留产物(`app.bin`/`.integrity` 与明文 `index.html`/`config.json`/`backend/` 只能存其一),避免壳误加载旧资源。
219
253
 
@@ -231,7 +265,10 @@ window.freedom.window.minimize(); // 窗口控制
231
265
 
232
266
  - Node.js >= 18
233
267
  - 不需要 Go 工具链(壳层为随包分发的预编译二进制)
234
- - 可选:`freedom shell build` 需本机 Go(含 CGO,Windows 需 MSVC 编译环境),仅用于亲自编译壳
268
+ - 可选:`freedom shell build` 需本机 Go(含 CGO,Windows 需 MSVC 编译环境),仅用于亲自编译壳。
269
+ Linux 侧需 WebKitGTK + GTK3 开发库,`4.0` 与 `4.1` 二选一(Ubuntu 24.04+/Debian 13+ 只有 4.1:
270
+ `sudo apt install libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev`);
271
+ 仅有 4.1 时 `freedom shell build` 会自动为编译加 `-tags webkit2_41`,无需手工干预(探测见 `lib/webkit.js`)。
235
272
  - `.dmg` 打包需在 macOS 上执行(依赖系统 hdiutil);`.app.zip` 在任意平台均可直接产出
236
273
 
237
274
  ## 命令
@@ -248,13 +285,17 @@ freedom security <none|basic|high> # 设置安全模式(写入配置)
248
285
  freedom icon <path> # 设置应用图标(Windows 用 .ico,macOS 用 .icns)
249
286
  freedom config [get|set]
250
287
  freedom shell list|download <platform>|build <platform>
288
+ freedom toolchain status [--refresh] [--home <dir>] # 探测 Go / Rust / C++ 工具链(成功结论才缓存,绑 PATH 指纹)
289
+ freedom toolchain install [go,rust,cpp|missing] [--apply] # 默认只打印安装命令,加 --apply 才执行
290
+ freedom toolchain optimize [--apply] # 国内网络迹象下换 Go/Rust 镜像源(改配置前留 .bak)
291
+ freedom toolchain clear # 清空探测缓存(~/.freedom/toolchain-cache.json)
251
292
  freedom dmg [--platform <plat>] # 在 macOS 上把 .app 打包为 .dmg
252
293
  freedom keygen [--force] # 生成应用自更新 ed25519 密钥对(私钥留 .freedom/keys/,公钥进配置)
253
294
  freedom manifest --artifact <产物> --url <下载地址> [--version x] [--notes txt] # 产出签名更新清单 dist/latest.json
254
- freedom agents [--home <dir>] # Agent 集成支持矩阵(本机磁盘证据判定 ready/convention/unknown)
295
+ freedom agents [--home <dir>] # Agent 集成支持矩阵(本机磁盘证据判定「是否安装 + 目标文件状态」)
255
296
  freedom agents install --what <mcp|skill> --agent <key|all> # 等价于下面两条
256
- freedom skill install --agent <key|all> [--dry-run] [--skills-dir <path>]
257
- freedom mcp install --agent <key|all> [--dry-run] [--config <path> --format json|toml|yaml]
297
+ freedom skill install --agent <key|all> [--dry-run] [--force] [--skills-dir <path>]
298
+ freedom mcp install --agent <key|all> [--dry-run] [--force] [--config <path> --format json|toml|toml-aot|yaml]
258
299
  freedom mcp serve # stdio MCP 服务本体(一般由 agent 自动拉起)
259
300
  freedom version # 显示版本并检测最新版本
260
301
  freedom update # 检查新版本并给出升级命令(同 check-update)
@@ -284,16 +325,22 @@ freedom help
284
325
  NDJSON 协议、CLI 命令、坑清单)复制进各 agent 的 skills 目录。
285
326
  - `freedom mcp install --agent <key|all>`:把 `freedom mcp serve` 注册进各 agent 的 MCP 配置。
286
327
  MCP 工具面:`freedom_build / init / verify / config / shell / release / agents / guide`。
287
- - `freedom agents [--home <dir>]`:打印支持矩阵。**是否可写一律按本机磁盘证据判定**——
288
- `ready`(配置文件已在,合并写入)/ `convention`(仅主目录在,按同族约定新建并明确提示)/
289
- `unknown`(本机无足迹,只输出可粘贴片段,绝不凭记忆造路径)。`--home` 换一棵家目录树预览取证结果。
328
+ - `freedom agents [--home <dir>]`:打印支持矩阵。**安装检测门(v1.13.2 起)**——只有本机检出该
329
+ agent 的**安装足迹**(配置文件在 / 注册表声明的专属目录在 / PATH 里有它的可执行)才允许写入;
330
+ 未检出即 `not-installed`,只输出可粘贴片段,**绝不新建目录、绝不凭记忆造路径**。
331
+ 「父目录存在」不算证据(HOME 与 `AppData/Roaming` 恒在,据此写入=往没装的东西里写)。
332
+ `--home` 换一棵家目录树预览取证结果。
333
+ - 逃生通道两条:`--force`(明知该 agent 装着但足迹未被覆盖,强行写入并显式告警)、
334
+ `--config <真实路径>` / `--skills-dir <真实目录>`(按你指定的路径写,绕开候选路径推断)。
290
335
  - `freedom agents install --what <mcp|skill> --agent <key|all>`:上面两条安装入口的合并写法,
291
336
  不带 `--what` 时默认 `mcp`;`agents <其它子命令>` 直接报错退出,不会静默回落成矩阵。
292
337
  - 写入是**幂等合并**:保留既有其它 server 条目,二次安装原地替换不产生重复;改前留 `<file>.bak` 备份;
293
- `--dry-run` 只预览不落盘。未取证的 agent 用 `--config <真实路径> --format <json|toml|yaml>` 覆写。
294
- - 已按本机取证登记的 agent:Claude Code / Claude Desktop / Codex CLI / Qoder / CodeBuddy / Zcode / Cursor /
295
- Hermes(YAML)等;其余(Trae、OpenCode、Gemini CLI、Pi、Tianshu、WorkBuddy、DeepSeek harness、Oh My Pi)
296
- 在无足迹的机器上一律降级为片段 + 覆写通道。
338
+ `--dry-run` 只预览不落盘。
339
+ - 已登记的 agent(17 个):Claude Code / Claude Desktop / Codex CLI / Qoder / CodeBuddy / Zcode / Cursor /
340
+ Hermes(YAML)/ Tianshu / Pi / WorkBuddy / OpenCode / Gemini CLI / DeepSeek Harness / Oh My Pi / Trae /
341
+ **Reasonix**。其中 **Reasonix** 的 MCP 配置是 TOML **数组表** `[[plugins]]`(按 `name = "freedom"` 定位条目,
342
+ 与既有 `computer-use` 等插件共存),格式取自本机 `%APPDATA%/reasonix/config.toml` 实况;
343
+ 技能装在 `~/.reasonix/skills/freedom`。未被登记或无足迹的布局用 `--format <json|toml|toml-aot|yaml>` 覆写。
297
344
 
298
345
  ## 开发热更(freedom dev)
299
346
 
@@ -336,5 +383,11 @@ export default {
336
383
 
337
384
  ## License
338
385
 
339
- MIT
386
+ 本工具与其源码为**闭源专有软件**,完整条款见包内 `LICENSE`(不适用 MIT)。要点:
387
+
388
+ - 你可以自由安装使用,并用它打包 / 销售你自己的应用——**你的前端、后端与产物归你所有**,无需开源或署名;
389
+ - 未经许可方书面同意,不得再分发本包内容物、源码或衍生版本,也不得移除版权与许可声明;
390
+ - 唯一沿用开源许可的部分是内嵌第三方 `webview_go`(MIT,见 `templates/go/third_party/webview_go/LICENSE`)。
391
+
392
+ 商用授权与定制需求请通过仓库 GitHub Issues 联系。
340
393
 
package/lib/agents.js CHANGED
@@ -3,9 +3,14 @@
3
3
  // freedom agents / skill / mcp —— 把 Freedom 使用技能与 MCP 服务装进各家 Agent
4
4
  //
5
5
  // 设计纪律(铁律 19):注册表只登记「候选路径 + 写入格式」,是否可写一律在安装时按磁盘
6
- // 证据判定——配置文件存在 = ready(合并写入,保留既有条目、写前 .bak 备份);目录存在但
7
- // 配置文件缺失 = create(新建后写入);皆无 = unverified,只打印可粘贴片段,绝不凭记忆
8
- // 造路径。未取证的 agent 与任意私有布局经 --config <path> --format <json|toml|yaml> 覆写。
6
+ // 证据判定。安装检测门(R4 起,用户裁定):**只有检出该 agent 确实装在本机**(配置文件在
7
+ // / agent 家目录在 / PATH 里有它的可执行 / 注册表声明的额外足迹)才允许写入;仅凭「父目录
8
+ // 在」不构成证据——HOME 与 AppData/Roaming 恒在,按同族约定新建等于往没装的东西里写。
9
+ // 未检出的 agent 一律 skipped(not-installed),只打印可粘贴片段,绝不凭记忆造路径。
10
+ // 覆写通道:--force(明知在但足迹未覆盖)、--config / --skills-dir(用户指定真实路径)。
11
+ //
12
+ // 写入档位:配置文件存在 = ready(合并写入,保留既有条目、写前 .bak 备份);
13
+ // 目录不存在则按需创建后再合并。
9
14
  //
10
15
  // 幂等:同一 server 名 / 同一 skill 目录重复安装为原地替换,不产生重复条目。
11
16
 
@@ -18,31 +23,91 @@ const { paint, ok, err, warn, dim, bold, section, C } = theme;
18
23
 
19
24
  const SERVER_NAME = 'freedom';
20
25
 
21
- // mcp: { file: 相对 HOME 的配置文件, format: json|toml|yaml, path: JSON/YAML 容器键路径 }
26
+ // mcp: { file: 相对 HOME 的配置文件, format: json|toml|yaml|toml-aot, path: JSON/YAML 容器键路径,
27
+ // section: toml-aot 的数组表名, key: toml-aot 用于定位条目的字段 }
22
28
  // skills: 相对 HOME 的 skill 根目录(每个 agent 一个)
29
+ // home / evidence: 该 agent 的专属目录与额外安装足迹(可选,安装检测门的证据)
23
30
  const AGENTS = [
24
31
  { key: 'claude-code', name: 'Claude Code', mcp: { file: '.claude.json', format: 'json', path: ['mcpServers'] }, skills: '.claude/skills' },
25
- { key: 'claude-desktop', name: 'Claude Desktop', mcp: { file: 'AppData/Roaming/Claude/claude_desktop_config.json', format: 'json', path: ['mcpServers'] }, skills: 'AppData/Roaming/Claude/skills' },
26
- { key: 'codex', name: 'Codex CLI', mcp: { file: '.codex/config.toml', format: 'toml' }, skills: '.codex/skills' },
32
+ { key: 'claude-desktop', name: 'Claude Desktop', home: 'AppData/Roaming/Claude', mcp: { file: 'AppData/Roaming/Claude/claude_desktop_config.json', format: 'json', path: ['mcpServers'] }, skills: 'AppData/Roaming/Claude/skills' },
33
+ { key: 'codex', name: 'Codex CLI', bin: 'codex', mcp: { file: '.codex/config.toml', format: 'toml' }, skills: '.codex/skills' },
27
34
  { key: 'qoder', name: 'Qoder', mcp: { file: '.qoder/mcp.json', format: 'json', path: ['mcpServers'] }, skills: '.qoder/skills' },
28
35
  { key: 'codebuddy', name: 'CodeBuddy', mcp: { file: '.codebuddy/mcp.json', format: 'json', path: ['mcpServers'] }, skills: '.codebuddy/skills' },
29
- { key: 'zcode', name: 'Zcode', mcp: { file: '.zcode/cli/config.json', format: 'json', path: ['mcp', 'servers'] }, skills: '.zcode/skills' },
36
+ { key: 'zcode', name: 'Zcode', home: '.zcode', mcp: { file: '.zcode/cli/config.json', format: 'json', path: ['mcp', 'servers'] }, skills: '.zcode/skills' },
30
37
  { key: 'cursor', name: 'Cursor', mcp: { file: '.cursor/mcp.json', format: 'json', path: ['mcpServers'] }, skills: '.cursor/skills' },
31
38
  { key: 'hermes', name: 'Hermes', mcp: { file: '.hermes/config.yaml', format: 'yaml', path: ['mcp_servers'] }, skills: '.hermes/skills' },
32
39
  { key: 'tianshu', name: 'Tianshu Harness', mcp: { file: '.tianshu/mcp.json', format: 'json', path: ['mcpServers'] }, skills: '.tianshu/skills' },
33
40
  { key: 'pi', name: 'Pi Agent', mcp: { file: '.pi/mcp.json', format: 'json', path: ['mcpServers'] }, skills: '.pi/skills' },
34
41
  { key: 'workbuddy', name: 'WorkBuddy', mcp: { file: '.workbuddy/mcp.json', format: 'json', path: ['mcpServers'] }, skills: '.workbuddy/skills' },
35
- { key: 'opencode', name: 'OpenCode', mcp: { file: '.config/opencode/mcp.json', format: 'json', path: ['mcpServers'] }, skills: '.opencode/skills' },
42
+ { key: 'opencode', name: 'OpenCode', home: '.config/opencode', mcp: { file: '.config/opencode/mcp.json', format: 'json', path: ['mcpServers'] }, skills: '.opencode/skills' },
36
43
  { key: 'gemini-cli', name: 'Gemini CLI', mcp: { file: '.gemini/settings.json', format: 'json', path: ['mcpServers'] }, skills: '.gemini/skills' },
37
44
  { key: 'deepseek-harness', name: 'DeepSeek Harness', mcp: { file: '.deepseek/mcp.json', format: 'json', path: ['mcpServers'] }, skills: '.deepseek/skills' },
38
45
  { key: 'oh-my-pi', name: 'Oh My Pi', mcp: { file: '.oh-my-pi/mcp.json', format: 'json', path: ['mcpServers'] }, skills: '.oh-my-pi/skills' },
39
46
  { key: 'trae', name: 'Trae', mcp: { file: '.trae/mcp.json', format: 'json', path: ['mcpServers'] }, skills: '.trae/skills' },
47
+ // Reasonix(本机取证 2026-09-25:%APPDATA%/reasonix/config.toml 第 288 行起
48
+ // 「External MCP servers …」+ [[plugins]] name/type/command 实例;该文件注释明示
49
+ // command/args/env/url/headers 支持 ${VAR} 展开 → args 字段存在)。
50
+ {
51
+ key: 'reasonix',
52
+ name: 'Reasonix',
53
+ home: 'AppData/Roaming/reasonix',
54
+ mcp: { file: 'AppData/Roaming/reasonix/config.toml', format: 'toml-aot', section: 'plugins', key: 'name' },
55
+ skills: '.reasonix/skills',
56
+ evidence: ['AppData/Roaming/reasonix-studio.exe', '.reasonix'],
57
+ },
40
58
  ];
41
59
 
42
60
  function homeDir() {
43
61
  return process.env.FREEDOM_AGENT_HOME || os.homedir();
44
62
  }
45
63
 
64
+ // PATH 探测(每轮安装只扫一次,缓存于 Map):返回命中的可执行全路径或 null
65
+ function makeWhich() {
66
+ const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
67
+ const exts = process.platform === 'win32'
68
+ ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.PS1').split(';').concat([''])
69
+ : [''];
70
+ const cache = new Map();
71
+ return (names) => {
72
+ for (const n of names) {
73
+ if (cache.has(n)) {
74
+ const hit = cache.get(n);
75
+ if (hit) return hit;
76
+ continue;
77
+ }
78
+ let found = null;
79
+ for (const d of dirs) {
80
+ for (const e of exts) {
81
+ const cand = path.join(d, n + e);
82
+ try { if (fs.statSync(cand).isFile()) { found = cand; break; } } catch (err) { /* 不在该目录 */ }
83
+ }
84
+ if (found) break;
85
+ }
86
+ cache.set(n, found);
87
+ if (found) return found;
88
+ }
89
+ return null;
90
+ };
91
+ }
92
+
93
+ // 安装检测门:该 agent 是否真的装在本机(只认真实足迹,不认「父目录恒在」)
94
+ // home / evidence 为显式声明的专属目录与足迹,不做推断——'AppData/Roaming' 这类公共父目录恒在,
95
+ // 推断即假阳性(未安装也被判为已安装)。
96
+ function detect(agent, which) {
97
+ const home = homeDir();
98
+ const ev = [];
99
+ if (fs.existsSync(path.join(home, agent.mcp.file))) ev.push(`配置文件 ${agent.mcp.file}`);
100
+ if (agent.home && fs.existsSync(path.join(home, agent.home))) ev.push(`目录 ${agent.home}`);
101
+ for (const rel of agent.evidence || []) {
102
+ if (fs.existsSync(path.join(home, rel))) ev.push(`足迹 ${rel}`);
103
+ }
104
+ if (agent.bin) {
105
+ const exe = which([agent.bin]);
106
+ if (exe) ev.push(`PATH ${path.basename(exe)}`);
107
+ }
108
+ return { installed: ev.length > 0, evidence: ev };
109
+ }
110
+
46
111
  function skillSource() {
47
112
  return path.join(packageRoot(), 'skill', 'freedom');
48
113
  }
@@ -73,20 +138,19 @@ function resolveAgentKeys(raw) {
73
138
  }
74
139
 
75
140
  // ---- 状态探测(全部基于本轮磁盘证据) ----
76
- function probe(agent) {
141
+ function probe(agent, which) {
77
142
  const home = homeDir();
78
143
  const mcpFile = path.join(home, agent.mcp.file);
79
144
  const skillsDir = path.join(home, agent.skills);
80
- // ready = 路径本机取证;convention = 仅 agent 主目录在(按同族约定新建,标注提示);unknown = 无足迹(只给片段)
81
- const mcpState = fs.existsSync(mcpFile) ? 'ready'
82
- : (fs.existsSync(path.dirname(mcpFile)) ? 'convention' : 'unknown');
83
- const skillState = fs.existsSync(skillsDir) ? 'ready'
84
- : (fs.existsSync(path.dirname(skillsDir)) ? 'convention' : 'unknown');
85
- return { mcpFile, skillsDir, mcpState, skillState };
145
+ // ready = 目标文件本机取证;create = 目录不在(安装时新建后合并写入)
146
+ const mcpState = fs.existsSync(mcpFile) ? 'ready' : 'create';
147
+ const skillState = fs.existsSync(skillsDir) ? 'ready' : 'create';
148
+ return { mcpFile, skillsDir, mcpState, skillState, detected: detect(agent, which) };
86
149
  }
87
150
 
88
151
  function matrix() {
89
- return AGENTS.map((a) => ({ key: a.key, name: a.name, ...probe(a) }));
152
+ const which = makeWhich();
153
+ return AGENTS.map((a) => ({ key: a.key, name: a.name, ...probe(a, which) }));
90
154
  }
91
155
 
92
156
  // ---- JSON 配置:按键路径 upsert,保留其余条目 ----
@@ -136,6 +200,47 @@ function upsertToml(text, name, def) {
136
200
  return { text: out.join('\n').replace(/\n*$/, '\n'), had };
137
201
  }
138
202
 
203
+ // ---- TOML 数组表:[[section]] 条目按 key 字段定位(Reasonix 的 [[plugins]] 用 name) ----
204
+ function aotBlock(section, def) {
205
+ const lines = [`[[${section}]]`];
206
+ for (const [k, v] of Object.entries(def)) lines.push(`${k} = ${tomlValue(v)}`);
207
+ return lines;
208
+ }
209
+
210
+ function upsertTomlAoT(text, section, keyField, keyVal, def) {
211
+ const lines = text.split(/\r?\n/);
212
+ const header = `[[${section}]]`;
213
+ const starts = [];
214
+ for (let i = 0; i < lines.length; i++) if (lines[i].trim() === header) starts.push(i);
215
+ const blockEnd = (i) => {
216
+ let j = i + 1;
217
+ while (j < lines.length && !/^\s*\[/.test(lines[j])) j++;
218
+ return j;
219
+ };
220
+ const keyRe = new RegExp(`^\\s*${keyField}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|(.+?))\\s*$`);
221
+ for (const s of starts) {
222
+ let hit = false;
223
+ for (let i = s + 1; i < blockEnd(s); i++) {
224
+ const m = lines[i].match(keyRe);
225
+ if (!m) continue;
226
+ const raw = m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3];
227
+ if (String(raw).trim() === keyVal) hit = true;
228
+ }
229
+ if (!hit) continue;
230
+ const out = [...lines.slice(0, s), ...aotBlock(section, def), ...lines.slice(blockEnd(s))];
231
+ return { text: out.join('\n').replace(/\n*$/, '\n'), had: true };
232
+ }
233
+ let out;
234
+ if (starts.length) {
235
+ const last = starts[starts.length - 1];
236
+ const end = blockEnd(last);
237
+ out = [...lines.slice(0, end), '', ...aotBlock(section, def), ...lines.slice(end)];
238
+ } else {
239
+ out = [...lines, '', ...aotBlock(section, def)];
240
+ }
241
+ return { text: out.join('\n').replace(/\n*$/, '\n'), had: false };
242
+ }
243
+
139
244
  // ---- YAML:mcp_servers: 下按缩进 upsert 一个条目 ----
140
245
  function yamlEntry(indent, name, def) {
141
246
  const p = ' '.repeat(indent);
@@ -194,41 +299,59 @@ function backup(file) {
194
299
 
195
300
  function snippet(agent, what) {
196
301
  if (what === 'mcp') {
197
- const body = JSON.stringify({ [SERVER_NAME]: serverDef() }, null, 2);
198
- if (agent.mcp.format === 'toml') {
199
- return [` ${dim('# 无 mcp_servers 块,手工追加到 ~/.codex/config.toml:')}`, ...tomlBlock(SERVER_NAME, serverDef()).trimEnd().split('\n').map((l) => ' ' + dim(l))];
302
+ const fmt = agent.mcp.format;
303
+ if (fmt === 'toml') {
304
+ return [` ${dim(`手工追加到 ${agent.mcp.file}:`)}`, ...tomlBlock(SERVER_NAME, serverDef()).trimEnd().split('\n').map((l) => ' ' + dim(l))];
200
305
  }
306
+ if (fmt === 'toml-aot') {
307
+ const block = aotBlock(agent.mcp.section || 'plugins', { name: SERVER_NAME, type: 'stdio', ...serverDef() });
308
+ return [` ${dim(`手工追加到 ${agent.mcp.file}(数组表按 ${agent.mcp.key || 'name'} 定位条目):`)}`, ...block.map((l) => ' ' + dim(l))];
309
+ }
310
+ const body = JSON.stringify({ [SERVER_NAME]: serverDef() }, null, 2);
201
311
  return [
202
- ` ${dim('目标配置文件未取证,请把以下条目并入你的 MCP 配置(容器键通常是 mcpServers / mcp.servers):')}`,
312
+ ` ${dim('请把以下条目并入你的 MCP 配置(容器键通常是 mcpServers / mcp.servers):')}`,
203
313
  ...body.split('\n').map((l) => ` ${dim(l)}`),
204
314
  ` ${dim(`或指定路径直接写入:freedom mcp install --agent ${agent.key} --config <path> --format json|toml|yaml`)}`,
205
315
  ];
206
316
  }
207
317
  return [
208
- ` ${dim('未发现该 agent 的 skill 目录,手工复制:')}`,
209
- ` ${dim(`${skillSource()} → <agent skills 根目录>/freedom`)}`,
318
+ ` ${dim('手工复制技能目录:')}`,
319
+ ` ${dim(`${skillSource()} → ${path.join(homeDir(), agent.skills)}${'\\'}freedom`)}`,
210
320
  ` ${dim(`或指定目录直接安装:freedom skill install --agent ${agent.key} --skills-dir <path>`)}`,
211
321
  ];
212
322
  }
213
323
 
324
+ // 安装检测门裁定:显式覆写通道 / --force / 足迹命中,三者皆无则不写
325
+ function gateOf(agent, pr, explicit, force) {
326
+ if (explicit) return { pass: true, reason: '' };
327
+ if (force) return { pass: true, reason: `${warn(`未检出安装足迹(${[
328
+ path.join(homeDir(), agent.mcp.file), path.join(homeDir(), agent.skills),
329
+ ].join(' / ')}),按 --force 强行写入;若该 agent 并未安装,请自行删除。`)}` };
330
+ if (pr.detected.installed) return { pass: true, reason: dim(`已检出:${pr.detected.evidence.join(';')}`) };
331
+ return { pass: false, reason: '' };
332
+ }
333
+
214
334
  // ---- 安装主流程 ----
215
- function installMcpOne(agent, { config, format, dryRun }) {
216
- const pr = probe(agent);
335
+ function installMcpOne(agent, { config, format, dryRun, force, which }) {
336
+ const pr = probe(agent, which);
337
+ const gate = gateOf(agent, pr, config, force);
338
+ if (!gate.pass) {
339
+ return { agent, state: 'not-installed', lines: [` ${warn(`${agent.key}: 本机未检出安装足迹 → 依检测门不写入(只给可粘贴片段)`)}`, ...snippet(agent, 'mcp')] };
340
+ }
217
341
  const file = config || pr.mcpFile;
218
342
  const fmt = format || agent.mcp.format;
219
343
  const cpath = agent.mcp.path || ['mcpServers'];
220
344
 
221
- if (!config && pr.mcpState === 'unknown') {
222
- return { agent, state: 'snippet', lines: [` ${warn(`${agent.key}: 未找到配置目录 ${path.dirname(pr.mcpFile)}(未取证,不写入)`)}`, ...snippet(agent, 'mcp')] };
223
- }
224
- const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : (fmt === 'json' ? '{}\n' : fmt === 'toml' ? '' : '');
345
+ const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : (fmt === 'json' ? '{}\n' : '');
225
346
  const up = fmt === 'toml' ? upsertToml(text, SERVER_NAME, serverDef())
226
- : fmt === 'yaml' ? upsertYaml(text, cpath, SERVER_NAME, serverDef())
227
- : fmt === 'json' ? upsertJson(text, cpath, SERVER_NAME, serverDef())
228
- : (() => { throw new Error(`不支持的 --format:${fmt}(可选 json|toml|yaml)`); })();
347
+ : fmt === 'toml-aot' ? upsertTomlAoT(text, agent.mcp.section || 'plugins', agent.mcp.key || 'name', SERVER_NAME,
348
+ { name: SERVER_NAME, type: 'stdio', ...serverDef() })
349
+ : fmt === 'yaml' ? upsertYaml(text, cpath, SERVER_NAME, serverDef())
350
+ : fmt === 'json' ? upsertJson(text, cpath, SERVER_NAME, serverDef())
351
+ : (() => { throw new Error(`不支持的 --format:${fmt}(可选 json|toml|toml-aot|yaml)`); })();
229
352
 
230
353
  if (dryRun) {
231
- return { agent, state: up.had ? 'replace(dry)' : 'add(dry)', lines: [` ${dim(`${file} · ${fmt} · ${up.had ? '替换既有条目' : '新增条目'}`)}`] };
354
+ return { agent, state: up.had ? 'replace(dry)' : 'add(dry)', lines: [` ${dim(`${file} · ${fmt} · ${up.had ? '替换既有条目' : '新增条目'}`)} ${gate.reason}`] };
232
355
  }
233
356
  const bak = backup(file);
234
357
  fs.writeFileSync(file, up.text, 'utf8');
@@ -236,37 +359,38 @@ function installMcpOne(agent, { config, format, dryRun }) {
236
359
  agent,
237
360
  state: up.had ? 'replaced' : 'added',
238
361
  lines: [` ${ok(`${file}`)} ${dim(`· ${fmt} · ${up.had ? '已替换既有 freedom 条目' : '已写入'}${bak ? ` · 备份 ${path.basename(bak)}` : ''}`)}`,
239
- ...(config || pr.mcpState === 'ready' ? [] : [` ${warn('该路径按同族约定新建(本机未取证);若该 agent 不读此文件,请用 --config 指定真实路径。')}`]),
362
+ ...(gate.reason ? [` ${gate.reason}`] : []),
240
363
  ` ${dim(`重启 ${agent.name} 后生效;未自动信任前请在该 agent 内确认。`)}`],
241
364
  };
242
365
  }
243
366
 
244
- function installSkillOne(agent, { skillsDir, dryRun }) {
245
- const pr = probe(agent);
367
+ function installSkillOne(agent, { skillsDir, dryRun, force, which }) {
368
+ const pr = probe(agent, which);
246
369
  const src = skillSource();
247
370
  if (!fs.existsSync(src)) throw new Error(`技能源缺失:${src}`);
248
- const destBase = skillsDir || pr.skillsDir;
249
- if (!skillsDir && pr.skillState === 'unknown') {
250
- return { agent, state: 'snippet', lines: [` ${warn(`${agent.key}: 未找到 skill 根目录 ${destBase}(未取证,不写入)`)}`, ...snippet(agent, 'skill')] };
371
+ const gate = gateOf(agent, pr, skillsDir, force);
372
+ if (!gate.pass) {
373
+ return { agent, state: 'not-installed', lines: [` ${warn(`${agent.key}: 本机未检出安装足迹 → 依检测门不写入(只给可粘贴片段)`)}`, ...snippet(agent, 'skill')] };
251
374
  }
375
+ const destBase = skillsDir || pr.skillsDir;
252
376
  const dest = path.join(destBase, 'freedom');
253
- if (dryRun) return { agent, state: 'dry', lines: [` ${dim(`${src} → ${dest}`)}`] };
377
+ if (dryRun) return { agent, state: 'dry', lines: [` ${dim(`${src} → ${dest}`)} ${gate.reason}`] };
254
378
  if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true, force: true });
255
379
  copyDir(src, dest);
256
380
  return {
257
381
  agent,
258
382
  state: 'installed',
259
383
  lines: [` ${ok(dest)}`,
260
- ...(skillsDir || pr.skillState === 'ready' ? [] : [` ${warn('该 skill 根目录按同族约定新建(本机未取证);若不被读取,请用 --skills-dir 指定真实目录。')}`])],
384
+ ...(gate.reason ? [` ${gate.reason}`] : [])],
261
385
  };
262
386
  }
263
387
 
264
- async function install({ what = 'mcp', agent = 'all', dryRun = false, config = null, format = null, skillsDir = null, home = null } = {}) {
388
+ async function install({ what = 'mcp', agent = 'all', dryRun = false, config = null, format = null, skillsDir = null, force = false, home = null } = {}) {
265
389
  const prevHome = process.env.FREEDOM_AGENT_HOME;
266
390
  if (home) process.env.FREEDOM_AGENT_HOME = path.resolve(home);
267
391
  let out;
268
392
  try {
269
- out = await installRun({ what, agent, dryRun, config, format, skillsDir });
393
+ out = await installRun({ what, agent, dryRun, config, format, skillsDir, force });
270
394
  } finally {
271
395
  if (prevHome === undefined) delete process.env.FREEDOM_AGENT_HOME;
272
396
  else process.env.FREEDOM_AGENT_HOME = prevHome;
@@ -274,7 +398,7 @@ async function install({ what = 'mcp', agent = 'all', dryRun = false, config = n
274
398
  return out;
275
399
  }
276
400
 
277
- async function installRun({ what, agent, dryRun, config, format, skillsDir }) {
401
+ async function installRun({ what, agent, dryRun, config, format, skillsDir, force }) {
278
402
  if (config) config = path.resolve(config);
279
403
  if (skillsDir) skillsDir = path.resolve(skillsDir);
280
404
  const keys = resolveAgentKeys(agent);
@@ -282,37 +406,43 @@ async function installRun({ what, agent, dryRun, config, format, skillsDir }) {
282
406
  const results = [];
283
407
  console.log(section(what === 'skill' ? '安装 Freedom 使用技能' : '注册 Freedom MCP 服务'));
284
408
  if (dryRun) console.log(` ${warn('--dry-run 预览模式:不写任何文件')}`);
409
+ const which = makeWhich();
285
410
  for (const key of keys) {
286
411
  const a = agentByKey(key);
287
412
  const fn = what === 'skill' ? installSkillOne : installMcpOne;
288
413
  let r;
289
414
  try {
290
- r = fn(a, { config, format, dryRun, skillsDir });
415
+ r = fn(a, { config, format, dryRun, skillsDir, force, which });
291
416
  } catch (e) {
292
417
  r = { agent: a, state: 'failed', lines: [` ${err(`${key}: ${e.message}`)}`] };
293
418
  }
294
- results.push({ key, state: r.state });
295
- console.log(`${paint(`${r.state.padEnd(9)}`, C.fg.cyan, C.bold)} ${bold(a.name)} ${dim(`(${key})`)}`);
419
+ results.push({ key, state: r.state, detected: probe(a, which).detected.installed });
420
+ console.log(`${paint(`${r.state.padEnd(13)}`, C.fg.cyan, C.bold)} ${bold(a.name)} ${dim(`(${key})`)}`);
296
421
  for (const l of r.lines) console.log(l);
297
422
  }
298
423
  const bad = results.filter((x) => x.state === 'failed').length;
424
+ const skipped = results.filter((x) => x.state === 'not-installed').length;
299
425
  console.log('');
300
- console.log(` ${dim('汇总:')}${results.length} 个 agent${bad ? `,${err(`${bad} 个失败`)}` : ''}${dryRun ? ` ${dim('(预览,未落盘)')}` : ''}`);
426
+ console.log(` ${dim('汇总:')}${results.length} 个 agent${skipped ? `,${paint(`${skipped} 个未检出安装(未写入)`, C.fg.yellow)}` : ''}${bad ? `,${err(`${bad} 个失败`)}` : ''}${dryRun ? ` ${dim('(预览,未落盘)')}` : ''}`);
301
427
  return { results, code: bad ? 1 : 0 };
302
428
  }
303
429
 
304
430
  // 打印支持矩阵(freedom agents)
305
431
  function printMatrix() {
306
432
  console.log(section('Agent 集成支持矩阵'));
307
- console.log(` ${dim('ready=配置文件/技能目录已存在(合并写入) convention=仅主目录在(按同族约定新建并提示) unknown=本机未取证(仅打印片段)')}`);
433
+ console.log(` ${dim('安装门=本机是否检出该 agent 的足迹(配置文件/声明目录/PATH 可执行);未检出只打印片段、不写入(可 --force 覆写)')}`);
434
+ console.log(` ${dim('MCP/SKILL 列:ready=目标已存在(合并写入) create=安装时新建目录后写入')}`);
308
435
  console.log('');
309
436
  for (const r of matrix()) {
310
- const cell = (s) => (s === 'ready' ? `${ok('ready ')}` : s === 'convention' ? `${paint('conventn ', C.fg.yellow)}` : dim('unknown'));
311
- console.log(` ${paint(r.key.padEnd(17), C.fg.cyan, C.bold)} MCP ${cell(r.mcpState)} ${dim(r.mcpFile)} · SKILL ${cell(r.skillState)} ${dim(r.skillsDir)}`);
437
+ const cell = (s) => (s === 'ready' ? `${ok('ready ')}` : paint('create ', C.fg.yellow));
438
+ const gate = r.detected.installed ? ok('已安装') : dim('未检出');
439
+ const basis = r.detected.installed ? dim(`(${r.detected.evidence.join(';')})`) : dim('(需 --force 或 --config/--skills-dir)');
440
+ console.log(` ${paint(r.key.padEnd(17), C.fg.cyan, C.bold)} ${gate} ${basis}`);
441
+ console.log(` ${' '.repeat(18)}MCP ${cell(r.mcpState)} ${dim(r.mcpFile)} · SKILL ${cell(r.skillState)} ${dim(r.skillsDir)}`);
312
442
  }
313
443
  console.log('');
314
- console.log(` ${dim('用法:')}${paint('freedom skill install --agent all', C.fg.white)} / ${paint('freedom mcp install --agent <key> [--dry-run] [--config <path> --format <json|toml|yaml>]', C.fg.white)}`);
444
+ console.log(` ${dim('用法:')}${paint('freedom skill install --agent all', C.fg.white)} / ${paint('freedom mcp install --agent <key> [--dry-run] [--force] [--config <path> --format <json|toml|toml-aot|yaml>]', C.fg.white)}`);
315
445
  return 0;
316
446
  }
317
447
 
318
- module.exports = { install, printMatrix, upsertJson, upsertToml, upsertYaml };
448
+ module.exports = { install, printMatrix, upsertJson, upsertToml, upsertTomlAoT, upsertYaml, detect, makeWhich };