diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 5077b1780409..329b968c0c3f 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -52,8 +52,8 @@ | B2 | refresh token 续期 + 401 拦截 + 失败降级 BYOK/登出(T3.1 剩余) | feature | A | verified | **PR #42 + alpha-web `a1d4d8a`**;寿命拍板 7*24h(env 可调短测试)+ 提前量续期(整点 tick)+ 401 拦截重试 + invalid_grant 降级登出(明确 UI)+ 冻结 token 快死备胎 respawn;REQ-002④ 过期路径就此成型;134 tests 绿。**verified(2026-07-07 在场批,C 仓 ECS 临调 DESKTOP_ACCESS_TTL_SECONDS=180 真机,测毕已还原 7d)**:①token 过期(-15s)调 account.summary→401 触发自动续期→237ms 无感重试成功+TTL 刷新+180(main.log `tokens refreshed`);②ECS 撤销活跃 session→续期命中 invalid_grant→main.log `refresh rejected...degrading to logged-out`→降级 byok+账户面板「登录」明确 UI(shots/21);③登出态 BYOK deepseek 直连发消息 200 独立可用不串台;proactive 提前量续期半(hourly tick × 短 TTL 测不到)由 alpha-auth-clock 单测覆盖已如实标注;证据 [audits/2026-07-07-inperson](audits/2026-07-07-inperson-batch/verify.md);详见 [requirements/B2](requirements/B2-refresh-token.md) | | B4 | 巨型目录当项目(`/`、`~`、`~/Documents` 建 Instance)治理 | perf | A | shipped | **S17 T5 shipped(2026-07-05)**:数据层过滤(`worktree-filter.ts` 谓词+11 单测)——"/"+macOS home 根默认不纳入、hidden(归档)零请求(不 fetch→引擎不建 Instance);归档即时生效;会话事件循环守卫;~/Documents 级留手动归档;已知限制 unhide 无 UI(记档);**verified 待**冷启动日志复核+watcher 数实测(→真机批;**S20 B3 已验数据层半边**——打包冷启动侧栏仅 3 具体项目、无 "/" 根、home 未纳入,但「零 session.list→引擎零 Instance」深层断言与 watcher 数未取证(info 级 main.log 不记 session.list,留 netlog 专项),**故不翻 verified**);**2026-07-07 复核**:数据层验收①②④达成;验收③有单个已知残留 home Instance(`project.list` 无-directory 回退 cwd=home,单次有界、结构上上游于数据层过滤),移出 B4 独立跟踪 [[REQ-058]];B4 保持 shipped;详见 [requirements/B4](requirements/B4-giant-dir-projects.md) | | REQ-058 | sidecar 惰性 cwd:消除启动期 home Instance 单次 watcher(B4 验收③残留) | perf | A | registered | **B4 verified 复核排查发现(2026-07-07)**:启动期 renderer `project.list`(无 directory)回退引擎 `process.cwd()`=home(alpha `chdir(home)`+sidecar 继承 cwd)→ 建单个 home Instance + 递归 watcher;单次、有界(InstanceStore 缓存去重),非 B4 多-watcher 场景但触碰其验收句③。修法=sidecar fork cwd 指向空 scratch 目录(低风险,需实测 ripgrep/工具执行不回归,独立验证周期);详见 [requirements/REQ-058](requirements/REQ-058-sidecar-lazy-cwd-home-instance.md) | -| REQ-059 | 引擎配置品牌收敛(全局):alpha 写入的引擎配置真源迁 `~/.alpha/alpha.jsonc`(命名用户拍板)+ provider/BYOK 写入域接管 + **全面零 `.opencode`**(同日晚拍板撤销 symlink 指针方案)+ 存量 `~/.opencode` 清理 | ux | A | shipped(全局层 T0/T1/T3 真机 verified;agents/commands 桥退役=T3b) | **用户拍板(2026-07-07)**:「一会 alpha 一会 opencode,租户都蒙了;最小量改动」+ 真源命名 `alpha.jsonc`。调研已源码钉死:上游 paths.ts 把 home `.opencode` 无条件并入每个 instance 的 config 源(与 REQ-058 cwd 改动零耦合——排雷);引擎从不写该文件;`.mcp.json` 引擎零处读取(不做,归 REQ-034)。 **同日晚追加拍板(用户四连收口)**:撤销 symlink 指针 → **任何层级不再创建任何 `.opencode`**——全局通道 G1=`OPENCODE_CONFIG` 原生 additional-config 合并(源码证实 per-instance 合并+dispose 重读+零 junk,`config.ts:401`;上游文档 `customize-opencode.md:431` 语义背书)+ G2=ext `config` hook 备援;项目级=hook(项目唯一目录 `.alpha`+信任门,REQ-060 载体);provider/BYOK 写入域迁真源(XDG 只属引擎/生态);存量 `~/.opencode` reconcile 清理(junk 白名单空则删);原生 CLI 可见性正式放弃;per-route 由 T0 spike 裁定(hook "Notify" 语义 = GO 前唯一闸门)。需求档含同日修订节 [requirements/REQ-059](requirements/REQ-059-alpha-jsonc-config-truth.md);权威方案 [designs/2026-07-07-project-alpha-only-extensions.md](designs/2026-07-07-project-alpha-only-extensions.md)(v3);ADR-019(修订+补充)/ADR-014/GLOSSARY 已同步;需求档已重写为零-`.opencode` 终稿(v2)。 **T0/T1/T3 shipped+真机 verified(2026-07-07,PR feat/req059-060,44 单测)**:T0 通道判定(G1=OPENCODE_CONFIG 文件通道 GREEN 铁证);T1 写入面全切 alpha.jsonc(mcp/plugin/治理键/provider 域,两级逃生,legacyConfigPaths 兜底)+ sidecar G1 注入 + reconcile 迁移;T3 skills 桥退役(skills.paths 文件通道)+ ~/.opencode 清理(拆链/删已迁 jsonc/junk-only 删目录)。真机(本机存量:mcp 无 receipt)实测——~/.opencode **干净消失**、迁移落 alpha.jsonc、引擎 /config 200 读到全部 mcp、定制中心已安装态+BYOK 零回归;放宽所有权判定(用户拍板:.opencode 是 alpha 领地)使存量收敛;修 2 真机 bug(skills.paths bail-out 阻断 / skills 数组 vs object schema)+ 回归锁。证据 [audits/2026-07-07-req059-t1t3](audits/2026-07-07-req059-t1t3-realmachine/verify.md)。**残:agents/commands 桥退役+条目化=T3b(本机无验证物,writeAgent 暂留桥);全局 config 进程级缓存,改 alpha.jsonc 须 respawn(非 dispose),记入已知行为** | -| REQ-060 | 项目级扩展物 `.alpha`-only:五类(skill/command/agent/mcp/plugin)生成与安装全落 `<项目>/.alpha`,项目不产生 `.opencode`(唯一目录 + 懒创建);通道 = ext 插件 `config` hook 注入 + plugin host fan-out + 项目信任门;创建流落点改造(REQ-036 修订)+ `alpha_register` ext 工具 | feature | A | ready | **用户拍板(2026-07-07)**:「项目中生成的 skill command agent mcp plugin 都应该落到 .alpha」「不希望每个项目出现 .alpha .opencode 两个目录」。现状核查 = 主通道方向相反:agent-creator 明文默认写 `/.opencode/agent/`(REQ-036 目标3);command/mcp/plugin 无项目级通道;hub 项目 scope 底座休眠(UI 从不传 InstallTarget)且为 `.opencode` 桥形态;顺手关闭 `.opencode/plugin/*.ts` 生 TS 桌面必崩雷(ADR-006)。T0 spike 与 REQ-059 共享(hook "Notify" 变异可见性 × 四路 + fan-out + 相邻项目隔离断言 + dispose 重注入)。 **T0 GO gate PASS(2026-07-07 真机 spike,PR feat/req059-060)**:config hook 载体(`@alpha-code/ext` config hook + `mergeProjectConfig` 纯逻辑 +8 单测)真机验证——项目级 agent/command/mcp 三路变异**被引擎 /config 完整看到**(design 最担心的 "Notify" 语义消解)+ 相邻项目隔离成立;skills.paths 同通道(object schema 已锁)。证据 [audits/2026-07-07-req060-confighook-spike](audits/2026-07-07-req060-confighook-spike/verify.md)。**通道成立 → 可 GO**。 **T1 核心机制 shipped(2026-07-07,PR feat/req059-060,ext 20 单测)**:①config hook 项目级注入(gate PASS)②信任门(项目自带 mcp 可执行→未 consent 不加载/记 gated loud;agent/command/skills 文本类恒加载;consent 落 `.alpha/prefs.json` extensionsConsent 版本化,ADR-021 模式)③plugin host fan-out(`mergeHooks` tool-map own优先浅并+其余 hook 串行转发 / `loadProjectPlugins` 信任门+动态 import .js/ADR-006+逐个失败 loud;解 config.plugin[] 加载太晚的鸡生蛋)。 **fan-out 真机 verified(2026-07-08,ship:mac 装机 CDP,6/6 PASS)**:①项目 `.alpha/plugins/*.js` tool 出现在引擎 tool ids ②信任门:无 consent 项目同插件**不**加载 ③alpha 自有工具零回归(mergeHooks own 优先)④非 tool hook(event)转发实证(session.created 落项目日志)⑤loud 加载日志 ⑥**consent 翻转免重启**(写 prefs.json→dispose→tool 即刻出现 = 将来 UI 弹窗驱动链已证通)。**场中发现并修复 home 边界 bug**:home 实例(`/.alpha`==全局 `~/.alpha`)误把全局 alpha.jsonc 当项目配置→全局 mcp 被信任门误 gated(噪声 loud;若误授 consent 则 ~/.alpha/plugins 有 fan-out×config.plugin[] 双载风险);修 `isGlobalAlphaDir` guard(config hook+fan-out 对 home 整体跳过,+4 单测=ext 24),rebuild 复验:UNTRUSTED 噪声消失(home 实例主动触发,非空断言)+ 全局 mcp 8 条完好 + 三断言零回归。证据 [audits/2026-07-07-req060-fanout-realmachine](audits/2026-07-07-req060-fanout-realmachine/verify.md)。 **剩(下批)**:信任门 UI 弹窗(renderer 检测 gated→consent 写盘→dispose,链路已真机证通,§5 分期)· T2 `alpha_register` ext 工具 · T3 创建流改造(agent-creator 落 `.alpha`+REQ-036 修订)· T5 真机批。详见 [requirements/REQ-060](requirements/REQ-060-project-alpha-only-extensions.md) + 设计 v3 §三/§四 | +| REQ-059 | 引擎配置品牌收敛(全局):alpha 写入的引擎配置真源迁 `~/.alpha/alpha.jsonc`(命名用户拍板)+ provider/BYOK 写入域接管 + **全面零 `.opencode`**(同日晚拍板撤销 symlink 指针方案)+ 存量 `~/.opencode` 清理 | ux | A | shipped(全局层 T0/T1/T3 + T3b 真机 verified) | **用户拍板(2026-07-07)**:「一会 alpha 一会 opencode,租户都蒙了;最小量改动」+ 真源命名 `alpha.jsonc`。调研已源码钉死:上游 paths.ts 把 home `.opencode` 无条件并入每个 instance 的 config 源(与 REQ-058 cwd 改动零耦合——排雷);引擎从不写该文件;`.mcp.json` 引擎零处读取(不做,归 REQ-034)。 **同日晚追加拍板(用户四连收口)**:撤销 symlink 指针 → **任何层级不再创建任何 `.opencode`**——全局通道 G1=`OPENCODE_CONFIG` 原生 additional-config 合并(源码证实 per-instance 合并+dispose 重读+零 junk,`config.ts:401`;上游文档 `customize-opencode.md:431` 语义背书)+ G2=ext `config` hook 备援;项目级=hook(项目唯一目录 `.alpha`+信任门,REQ-060 载体);provider/BYOK 写入域迁真源(XDG 只属引擎/生态);存量 `~/.opencode` reconcile 清理(junk 白名单空则删);原生 CLI 可见性正式放弃;per-route 由 T0 spike 裁定(hook "Notify" 语义 = GO 前唯一闸门)。需求档含同日修订节 [requirements/REQ-059](requirements/REQ-059-alpha-jsonc-config-truth.md);权威方案 [designs/2026-07-07-project-alpha-only-extensions.md](designs/2026-07-07-project-alpha-only-extensions.md)(v3);ADR-019(修订+补充)/ADR-014/GLOSSARY 已同步;需求档已重写为零-`.opencode` 终稿(v2)。 **T0/T1/T3 shipped+真机 verified(2026-07-07,PR feat/req059-060,44 单测)**:T0 通道判定(G1=OPENCODE_CONFIG 文件通道 GREEN 铁证);T1 写入面全切 alpha.jsonc(mcp/plugin/治理键/provider 域,两级逃生,legacyConfigPaths 兜底)+ sidecar G1 注入 + reconcile 迁移;T3 skills 桥退役(skills.paths 文件通道)+ ~/.opencode 清理(拆链/删已迁 jsonc/junk-only 删目录)。真机(本机存量:mcp 无 receipt)实测——~/.opencode **干净消失**、迁移落 alpha.jsonc、引擎 /config 200 读到全部 mcp、定制中心已安装态+BYOK 零回归;放宽所有权判定(用户拍板:.opencode 是 alpha 领地)使存量收敛;修 2 真机 bug(skills.paths bail-out 阻断 / skills 数组 vs object schema)+ 回归锁。证据 [audits/2026-07-07-req059-t1t3](audits/2026-07-07-req059-t1t3-realmachine/verify.md)。**T3b shipped+真机 verified(2026-07-08,S30)**:writeAgent 去桥=md 真源+agentMdToEntry 受限转换(fail-closed)+alpha.jsonc agent 条目(G1);removeFsInstall 净除条目;reconcile 存量 agents 桥迁条目(全部成功才拆链)。真机:installBuiltinAgent(code-reviewer)→ md+条目、**零 ~/.opencode**、dispose 后引擎可见、卸载净除,证据 [audits/2026-07-08-s30](audits/2026-07-08-s30-req060-realmachine/verify.md)。**残:全局 config 进程级缓存,改 alpha.jsonc 须 respawn(非 dispose),记入已知行为** | +| REQ-060 | 项目级扩展物 `.alpha`-only:五类(skill/command/agent/mcp/plugin)生成与安装全落 `<项目>/.alpha`,项目不产生 `.opencode`(唯一目录 + 懒创建);通道 = ext 插件 `config` hook 注入 + plugin host fan-out + 项目信任门;创建流落点改造(REQ-036 修订)+ `alpha_register` ext 工具 | feature | A | shipped(S30 全量,核心链路真机 verified) | **用户拍板(2026-07-07)**:「项目中生成的 skill command agent mcp plugin 都应该落到 .alpha」「不希望每个项目出现 .alpha .opencode 两个目录」。现状核查 = 主通道方向相反:agent-creator 明文默认写 `/.opencode/agent/`(REQ-036 目标3);command/mcp/plugin 无项目级通道;hub 项目 scope 底座休眠(UI 从不传 InstallTarget)且为 `.opencode` 桥形态;顺手关闭 `.opencode/plugin/*.ts` 生 TS 桌面必崩雷(ADR-006)。T0 spike 与 REQ-059 共享(hook "Notify" 变异可见性 × 四路 + fan-out + 相邻项目隔离断言 + dispose 重注入)。 **T0 GO gate PASS(2026-07-07 真机 spike,PR feat/req059-060)**:config hook 载体(`@alpha-code/ext` config hook + `mergeProjectConfig` 纯逻辑 +8 单测)真机验证——项目级 agent/command/mcp 三路变异**被引擎 /config 完整看到**(design 最担心的 "Notify" 语义消解)+ 相邻项目隔离成立;skills.paths 同通道(object schema 已锁)。证据 [audits/2026-07-07-req060-confighook-spike](audits/2026-07-07-req060-confighook-spike/verify.md)。**通道成立 → 可 GO**。 **T1 核心机制 shipped(2026-07-07,PR feat/req059-060,ext 20 单测)**:①config hook 项目级注入(gate PASS)②信任门(项目自带 mcp 可执行→未 consent 不加载/记 gated loud;agent/command/skills 文本类恒加载;consent 落 `.alpha/prefs.json` extensionsConsent 版本化,ADR-021 模式)③plugin host fan-out(`mergeHooks` tool-map own优先浅并+其余 hook 串行转发 / `loadProjectPlugins` 信任门+动态 import .js/ADR-006+逐个失败 loud;解 config.plugin[] 加载太晚的鸡生蛋)。 **fan-out 真机 verified(2026-07-08,ship:mac 装机 CDP,6/6 PASS)**:①项目 `.alpha/plugins/*.js` tool 出现在引擎 tool ids ②信任门:无 consent 项目同插件**不**加载 ③alpha 自有工具零回归(mergeHooks own 优先)④非 tool hook(event)转发实证(session.created 落项目日志)⑤loud 加载日志 ⑥**consent 翻转免重启**(写 prefs.json→dispose→tool 即刻出现 = 将来 UI 弹窗驱动链已证通)。**场中发现并修复 home 边界 bug**:home 实例(`/.alpha`==全局 `~/.alpha`)误把全局 alpha.jsonc 当项目配置→全局 mcp 被信任门误 gated(噪声 loud;若误授 consent 则 ~/.alpha/plugins 有 fan-out×config.plugin[] 双载风险);修 `isGlobalAlphaDir` guard(config hook+fan-out 对 home 整体跳过,+4 单测=ext 24),rebuild 复验:UNTRUSTED 噪声消失(home 实例主动触发,非空断言)+ 全局 mcp 8 条完好 + 三断言零回归。证据 [audits/2026-07-07-req060-fanout-realmachine](audits/2026-07-07-req060-fanout-realmachine/verify.md)。 **S30 收尾全量 shipped + 真机批 PASS(2026-07-08,S30 sprint,PR feat/s30-req060-finish)**:①信任门 UI(ext-trust-check IPC 原生 sheet,B16 模式;granted/denied 都落 prefs 版本化,「允许但未勾知情」=未决不落盘;ExtTrustWatcher 进项目会话时检测→granted 自动 dispose)②T2 `alpha_register`(SAFE_NAME+逐 type 字段白名单→原子写项目 alpha.jsonc→自动 reload;home 拒;skills.paths 存相对 './.alpha/skills' 由 hook 按项目根解析=项目可移动)③T3 创建流改稿(agent-creator 重写:项目 agent/command 经 alpha_register 零文件零 .opencode,skill 落 .alpha/skills+注册,plugin 指导含信任门告知;全局 agent 指引经定制中心导入;REQ-036 档修订节)④生 TS 拒收 loud(fan-out 对 .ts 明示 ADR-006 + 不误伤同目录 .js)。**真机批 12 断言全 PASS**(CDP+AppleScript 原生 sheet 实点):通道四路可见/相对路径解析/信任门同意+拒绝双路径不重复弹/granted dispose 后 mcp 可见+denied 保持 gated+隔离/零目录新增/生 TS loud。证据 [audits/2026-07-08-s30-req060-realmachine](audits/2026-07-08-s30-req060-realmachine/verify.md)。**残(会话级)**:模型经真 LLM 会话实调 alpha_register 未演(工具注册+纯逻辑 33 测已证);REQ-036 验收 2/3 会话级复测归日常使用场。详见 [requirements/REQ-060](requirements/REQ-060-project-alpha-only-extensions.md) + 设计 v3 §三/§四 | | B7 | 发布流水线制度化:CI 断言版本/种子资产/断网首启 smoke(T2.6 剩余) | debt | A | ready | **验收② shipped(PR #85)**:`scripts/assert-seed-assets.sh` + advisory `seed-assets` job 断言 extraResources 源资产(vendored agent/plugin·skills·NOTICE.txt/B15·签名)存在,静默删除即红;①版本断言(release-time)③断网首启 smoke⑤注入 0.0.0 验证 = 需 build+launch → 真机批;DISTRIBUTION.md 已写 | | B8 | 扩展物运行时生命周期:版本/健康/更新三要素(T5.4/T5.6) | feature | A | registered | 系统性条目,症状=A2;终态=定制中心从商店→运行时管理器;**具体实现路径已立 = REQ-018(账本/生效)+ REQ-019(详情/更新),B8 保留为终态验收视角** | | B9 | 更新链完整性:关 `allowDowngrade` + feed 完整性校验 | security | A | verified | **PR #47**(→ [S10](sprints/2026-07-03-s10-hardening/sprint.md));降级闸关闭(理由入注释:单 prod 渠道无跨渠道降级需求;旧版逃生=手动 dmg);完整性链文档化(yml sha512 → zip → 签名同 identity → 降级闸);**verified 待下个真实发版**(自动更新实测 + 篡改 yml 拒装用例);详见 [requirements/B9](requirements/B9-update-chain-integrity.md) **verified(2026-07-07,S29 真实发版实测)**:0.1.0(7-6 build)→ v0.1.1 全链走通——updater 10 分钟周期检测 `Found version 0.1.1` → 下载 19s → ready → 确认框 Restart → ShipIt 换包 → 0.1.1 自启;`allowDowngrade:false` 全程留痕;feed 200;证据 [audits/s29](audits/2026-07-07-s29-verify/verify.md) | diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e7de49264c4d..2bda048ea90e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,10 @@ ## [Unreleased] +### Added +- **项目里的一切定制只住 `.alpha` 一个目录**(REQ-060,需下个签名版本生效)——在会话里创建的技能 / Agent / 命令 / 连接器 / 插件全部落到项目的 `.alpha/` 下,项目里**不再出现 `.opencode` 目录**;没用过扩展的项目连 `.alpha` 都不会出现。模型经新的注册工具写入(字段校验、原子写、自动生效),不再手改配置文件;写完当前回复结束后自动重载,下一条消息即可用,无需重启。配套:全局安装 Agent 也不再在 `~/.opencode` 留任何痕迹(「`.alpha` 是你的,`.opencode` 是引擎的」自此全兑现)。 +- **打开自带可执行扩展的项目会先征得你同意**(REQ-060 信任门,需下个签名版本生效)——项目若自带连接器(MCP)或插件(均为可在本机运行的程序代码),首次打开该项目会话时弹窗列明具体条目,你可以选择「允许加载」(需勾选知情确认)或「仅文本扩展」;技能 / Agent / 命令等文本类扩展不受影响。决定按项目记录一次不再重复询问,同意后无需重启即刻生效;拒绝后该项目的可执行扩展保持不加载。项目里放置未打包的 TypeScript 插件会被如实拒载并在日志说明原因(桌面运行时无法执行,需先打包成自包含 JS)。 + ### Fixed - **会话页输入框按钮"点了没反应"根治**(REQ-056)——三个叠加原因一并修复:①接管容器层级低于对话滚动区,按钮**看得见点不着**(点击被透明滚动层吃掉);②窄窗口/审查面板打开时底部按钮行溢出输入框外,推理强度/发送被挤到不可点(现模型名截断省略、按钮行自动换行);③会话页丢失 alpha 焦点样式作用域,点击后残留上游橙色粗焦点圈(现样式随组件走,两页一致)。 - **推理强度不再有"选择模型后可用"的死状态**(REQ-056)——登录且代理可用时自动选中默认模型 **Claude Sonnet 4.6**(高级档;不覆盖你的手动选择、不写死到本地),推理强度点开即「默认/低/中/高」;模型不支持档位时点开如实说明原因,任何状态点击都有反馈。 diff --git a/docs/audits/2026-07-08-s30-req060-realmachine/verify.md b/docs/audits/2026-07-08-s30-req060-realmachine/verify.md new file mode 100644 index 000000000000..242ca49981ba --- /dev/null +++ b/docs/audits/2026-07-08-s30-req060-realmachine/verify.md @@ -0,0 +1,79 @@ +# S30 真机批 — REQ-060 验收 1-6 + REQ-059 T3b + 信任门 UI(2026-07-08) + +> ship:mac 装机(alpha @ feat/s30-req060-finish,commit 3dea94b6),CDP 9222 + AppleScript +> System Events(原生对话框点按)。fixture 四项目 `~/req060-t5-{a,b,c,d}`,验证后已删。 + +## 结论:全部 PASS(12 断言 + 2 条 loud 日志) + +## 一、项目级通道 + alpha_register + 生 TS 拒收(项目 A,已 consent) + +fixture:`.alpha/alpha.jsonc`(agent `t5-agent` / command `t5-cmd` / `skills.paths: ["./.alpha/skills"]` +相对路径)+ `.alpha/plugins/{raw-trap.ts, t5-ok.js}` + `.alpha/skills/t5-skill/`。 + +| 断言 | 结果 | +|---|---| +| /config 见 agent `t5-agent` + command `t5-cmd`(条目通道) | ✅ | +| `skills.paths` 相对条目按项目根解析为绝对(`/Users/tide/req060-t5-a/.alpha/skills`)—— 项目可移动 | ✅ | +| tool ids 含 `alpha_register`(T2 工具注册) | ✅ | +| **生 TS 拒收 loud**(验收4):`raw-trap.ts` NOT loaded + 日志指路「Bundle to self-contained ESM .js」 | ✅ | +| 同目录 `t5-ok.js` 正常 fan-out(`t5_plugin_tool` 在 tool ids)—— .ts 拒收不误伤 | ✅ | + +``` +[@alpha-code/ext] project plugin raw-trap.ts is raw TypeScript — NOT loaded (desktop runtime can't run raw TS, ADR-006). Bundle it to a self-contained ESM .js first. +[@alpha-code/ext] project plugin loaded (fan-out): /Users/tide/req060-t5-a/.alpha/plugins/t5-ok.js +``` + +```json +{"cfgStatus":200,"agentVisible":true,"commandVisible":true,"skillsPathResolvedAbs":true, + "registerToolPresent":true,"tsTrapNotLoaded":true,"jsPluginLoaded":true} +``` + +## 二、信任门 UI 弹窗(验收2,项目 B=拒绝 / 项目 D=同意;原生 sheet 实点) + +CDP 调 `window.api.ext.trustCheck(dir)` → 主窗弹原生 sheet(buttons=[允许加载, 仅文本扩展(不加载)], +checkbox=「我了解这会在本机运行该项目提供的代码」)→ AppleScript 实点。 + +| 断言 | 结果 | +|---|---| +| **拒绝路径**(B):点「仅文本扩展」→ prefs.json 落 `{granted:false, version:1}` | ✅ | +| 拒绝后重查:`{prompted:false, granted:false}` ×2(不重复弹) | ✅ | +| **同意路径**(D):勾知情 checkbox + 点「允许加载」→ `{prompted:true, granted:true}`;prefs 落 granted | ✅ | +| 同意后重查:`{prompted:false, granted:true}`(不重复弹) | ✅ | +| granted 项目 dispose 后 mcp 条目引擎可见(`t5grantmcp`) | ✅ | +| denied 项目 mcp 保持 gated(`t5gatedmcp` 不可见)+ 相邻隔离(B 看不到 D 的 mcp) | ✅ | + +```json +{"grantedProjectMcpVisible":true,"deniedProjectMcpGated":true,"isolation":true} +``` + +## 三、REQ-059 T3b:全局 agent 零桥 + 条目化 + 净除 + +`window.api.ext.installBuiltinAgent("agents/code-reviewer.md", "code-reviewer")`(writeAgent 新管线): + +| 断言 | 结果 | +|---|---| +| md 落 `~/.alpha/agents/code-reviewer.md`,files 仅此一项(**无桥文件**) | ✅ | +| **`~/.opencode` 不存在**(零 `.opencode` 不变量,装全局 agent 不再复活它) | ✅ | +| `~/.alpha/alpha.jsonc` 落 `agent.code-reviewer` 条目(agentMdToEntry:permission 嵌套/中文 prompt 正确转换) | ✅ | +| dispose 后引擎 /config 见到该 agent(prompt 完整,G1 通道) | ✅ | +| 卸载(`ext.uninstall`)净除:md 删 + alpha.jsonc 条目删 | ✅ | + +## 四、零目录新增(验收3,项目 C) + +空项目 C 经引擎建实例 + /config 查询后,目录仍为空(无 `.alpha`、无 `.opencode`)。✅ + +## 覆盖对照(REQ-060 验收标准) + +| 验收 | 覆盖 | +|---|---| +| 1 创建产物全在 `.alpha`、免重启可用 | 通道全链已证(条目→/config→dispose);**模型经会话实调 alpha_register 未演**(需真 LLM 会话,机制 = 工具注册✅+纯逻辑 33 测) | +| 2 信任门同意/拒绝双路径 | ✅ 全真机(原生 sheet 实点) | +| 3 零目录新增 | ✅ | +| 4 生 TS 拒收 loud | ✅ | +| 5 存量共存 | 用户自建 `.opencode` 零触碰 = reconcile 既有纪律(REQ-059 批已证);agents 桥迁移逻辑单测覆盖,本机无存量桥验证物 | +| 6 REQ-036 创建→发现→免重启复测 | 通道级已证(同 1);会话级实测归下一次日常使用 | +| 7 零改上游 + alpha-check 绿 | ✅(north-star guard + 549 测) | + +## 残留物 + +fixture 四项目已删;`~/.alpha/alpha.jsonc` 的 code-reviewer 条目已卸载净除;app 已退出。 diff --git a/docs/requirements/REQ-036-creation-via-skills.md b/docs/requirements/REQ-036-creation-via-skills.md index 110a36159bcc..2d05ba8d4052 100644 --- a/docs/requirements/REQ-036-creation-via-skills.md +++ b/docs/requirements/REQ-036-creation-via-skills.md @@ -48,3 +48,13 @@ created: 2026-07-05 - 决策依据与机制证据:本轮分析(2026-07-05 会话,三份勘探 + Claude Code 对标); - [[REQ-033]](开放安装面:导入通道并存)、[[REQ-037]](上游治理层,同为「上游能力面」孪生档)、[[B6]](ext 接缝,alpha_reload 落点)、ADR-014 v3(dispose 免重启)、ADR-019(`.alpha` 落点与桥)、ADR-015 Tier-2(能力扩展走 harness 接缝,不写提示词)。 + +## 修订(2026-07-08,S30/REQ-060 —— 创建流落点改造:零 `.opencode` + alpha_register) + +REQ-060(项目级扩展物 `.alpha`-only)+ REQ-059 T3b(agents 全局桥退役)使本档**目标3 与验收5 的落点表述作废**,agent-creator 已改稿(同批 PR): + +1. **项目 agent(默认)**:不再写 `/.opencode/agent/*.md` 文件 —— 改调 `alpha_register` ext 工具(type=agent,entry 经字段白名单校验后原子写 `/.alpha/alpha.jsonc` 条目,自动排程 reload)。项目内零 `.opencode`、零文件手写。 +2. **全局 agent**:`~/.opencode` 桥已退役(T3b);会话内不再直写全局 —— 稿子指引经**定制中心导入**(writeAgent 管线:`~/.alpha/agents/*.md` + alpha.jsonc 条目,受账本管理)。「全局落点走 `~/.alpha` 桥并在 `~/.opencode` 可见」(验收5 后半)不再成立,替代验收 = 条目进 `~/.alpha/alpha.jsonc` 且引擎 /config 可见。 +3. **skill**:项目级落 `/.alpha/skills/` + `alpha_register`(type=skill,注册 skills.paths 相对路径);全局 `~/.alpha/skills/` 原生可见(REQ-059 T3)。 +4. **command / plugin**:新增会话内指导(command 经 alpha_register;plugin 直落 `.alpha/plugins/*.js` + 信任门告知)。 +5. 「创建 → 发现 → 免重启」核心验收(2/3/4)语义不变,在新落点由 S30 真机批复测。 diff --git a/docs/requirements/REQ-060-project-alpha-only-extensions.md b/docs/requirements/REQ-060-project-alpha-only-extensions.md index 4f3a57285e01..89bf1cfb2b17 100644 --- a/docs/requirements/REQ-060-project-alpha-only-extensions.md +++ b/docs/requirements/REQ-060-project-alpha-only-extensions.md @@ -3,7 +3,7 @@ id: REQ-060 title: 项目级扩展物 `.alpha`-only —— 五类(skill/command/agent/mcp/plugin)生成与安装全落 `<项目>/.alpha`,项目不产生 `.opencode` type: feature priority: P1 -status: ready +status: shipped repo: A created: 2026-07-07 source: 用户拍板(2026-07-07):「项目中生成的 skill command agent mcp 和 plugin 都应该落到 .alpha」「不希望每个项目出现 .alpha .opencode 两个目录」 diff --git a/docs/sprints/2026-07-08-s30-req060-finish/sprint.md b/docs/sprints/2026-07-08-s30-req060-finish/sprint.md new file mode 100644 index 000000000000..238c9db489fe --- /dev/null +++ b/docs/sprints/2026-07-08-s30-req060-finish/sprint.md @@ -0,0 +1,53 @@ +# S30 — REQ-060 项目级 `.alpha`-only 收尾 + REQ-059 T3b(2026-07-08) + +> 契约:ADR-018 §5。WIP=1(S29 已收口)。分支 `feat/s30-req060-finish` → PR → alpha。 + +## 目标 + +REQ-060 剩余全清(信任门 UI / T2 / T3 / T5)+ REQ-059 T3b(agents 桥退役),使「项目唯一目录 = `.alpha`」链路端到端可用并真机 verified;全局层 agents 落点与条目化同步收敛。 + +## 抽取 IDs + +| ID | 条目 | 状态(入 sprint 时) | +|---|---|---| +| REQ-060 | 项目级扩展物 `.alpha`-only(剩:信任门 UI / T2 / T3 / T5) | ready | +| REQ-059 | T3b:agents/commands 全局桥退役 + 条目化 | shipped(残 T3b) | + +## Task 表 + +| # | 任务 | 状态 | +|---|---|---| +| S30-1 | 信任门 consent UI:检测 gated → 原生弹窗(B16 模式)→ prefs.json → dispose;拒绝路径记 denied 不重复弹 | ✅ | +| S30-2 | `alpha_register` ext 工具:SAFE_NAME/字段白名单 → 原子写项目 alpha.jsonc → alpha_reload 一条龙;生 TS plugin 拒收 loud | ✅ | +| S30-3 | 创建流改造:agent-creator 落 `/.alpha/agents` + skill-creator 引导段 + command 指导;REQ-036 修订 | ✅ | +| S30-4 | REQ-059 T3b:writeAgent 去桥(全局 `~/.alpha/agents` + alpha.jsonc 条目;项目 target 同构);removeFsInstall 净除;reconcile 清存量链 | ✅ | +| S30-5 | 真机批(REQ-060 验收 1-6):创建→发现→免重启;信任门双路径;隔离断言;生 TS 拒收;零目录新增 | ✅ | +| S30-6 | 收口:BACKLOG/CHANGELOG/需求档/sprint 回写 + PR merge | ✅ | + +## Gates + +- 零改上游(north-star guard);`alpha-check` 三关绿。 +- 信任门文案与 B16/ADR-021 口径对齐(告知「加载可执行物」的含义)。 +- 真机批证据落 `docs/audits/2026-07-08-s30-req060-realmachine/verify.md`。 + +## 结果 + +六 task 全完成,一日收口。真机批 12 断言全 PASS(CDP + AppleScript 原生 sheet 实点),证据 +[audits/2026-07-08-s30-req060-realmachine](../../audits/2026-07-08-s30-req060-realmachine/verify.md)。 + +- REQ-060 → **shipped**(核心链路真机 verified):信任门 UI(同意/拒绝双路径 + 不重复弹 + granted + 自动 dispose)、alpha_register(项目级注册一条龙)、创建流改稿(零 `.opencode`)、生 TS 拒收 loud、 + 零目录新增全过。**残(会话级)**:模型经真 LLM 会话实调 alpha_register 未演(机制已证)。 +- REQ-059 T3b → **shipped + 真机 verified**:writeAgent 去桥条目化(md 真源 + agentMdToEntry + fail-closed 转换 + alpha.jsonc 条目),装全局 agent 零 `~/.opencode`,卸载净除;reconcile 存量桥 + 迁移(单测覆盖,本机无存量验证物)。 +- 场中发现:S30-5 启动时发现无 CDP 的旧实例抢占单实例锁导致 9222 拒连(带 flag 的新实例让位退出) + —— 真机验证前须 pgrep 确认无残留实例,已记入验证方法。 +- 单测:ext 24→33,ui-mac 549(全绿);alpha-check 三关绿。 + +## 回写清单 + +- [x] BACKLOG:REQ-060 → shipped(核心真机 verified);REQ-059 T3b 残项关闭 +- [x] CHANGELOG [Unreleased]:用户可见变化(项目级扩展 `.alpha`-only + 信任门弹窗) +- [x] 需求档 frontmatter:REQ-060 status=shipped;REQ-036 修订节 +- [x] 本 sprint.md task 勾选 + 结果 diff --git a/packages/ext/src/plugin-fanout.ts b/packages/ext/src/plugin-fanout.ts index 141c3f68792a..f3d2b8fd4c28 100644 --- a/packages/ext/src/plugin-fanout.ts +++ b/packages/ext/src/plugin-fanout.ts @@ -63,7 +63,14 @@ export async function loadProjectPlugins( const hooks: Record[] = [] let files: string[] try { - files = deps.readdirSync(dir).filter((f) => f.endsWith(".js")) + const all = deps.readdirSync(dir) + // ADR-006 生 TS 雷拒收 loud:桌面运行时(Electron-Node)加载生 TS 必崩,只认自包含 ESM .js。 + for (const f of all) + if (f.endsWith(".ts")) + deps.error?.( + `[@alpha-code/ext] project plugin ${f} is raw TypeScript — NOT loaded (desktop runtime can't run raw TS, ADR-006). Bundle it to a self-contained ESM .js first.`, + ) + files = all.filter((f) => f.endsWith(".js")) } catch { return [] } diff --git a/packages/ext/src/plugin.ts b/packages/ext/src/plugin.ts index 8427d4eea8e6..7743aae795ce 100644 --- a/packages/ext/src/plugin.ts +++ b/packages/ext/src/plugin.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, readdirSync } from "node:fs" +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs" import { homedir } from "node:os" import { join } from "node:path" import { pathToFileURL } from "node:url" @@ -6,6 +6,7 @@ import type { Plugin } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin" import { isGlobalAlphaDir, mergeProjectConfig } from "./project-config" import { loadProjectPlugins, mergeHooks } from "./plugin-fanout" +import { applyRegister, type RegisterType } from "./register" /** * alpha-code backend isolation extension. @@ -53,6 +54,7 @@ export const AlphaExt: Plugin = async (input) => { const trustExecutable = readProjectExtensionsConsent(input.directory) const { added, gatedExecutable } = mergeProjectConfig(cfg as Record, readFileSync(f, "utf8"), { trustExecutable, + directory: input.directory, }) if (gatedExecutable.length) console.log( @@ -83,6 +85,68 @@ export const AlphaExt: Plugin = async (input) => { } }, }), + alpha_register: tool({ + description: + "Register a project-scoped extension entry into /.alpha/alpha.jsonc (the ONLY alpha directory in a project — never create .opencode). " + + "Use type=agent|command with an entry object (agent: {description,prompt,mode,...}; command: {template,description,...}); " + + "type=mcp with the connector config (loading executable connectors additionally requires the user's per-project consent dialog); " + + "type=skill takes no entry — it registers the ./.alpha/skills path; write the skill itself to .alpha/skills//SKILL.md. " + + "Plugins are NOT registered here: drop a self-contained ESM .js into .alpha/plugins/ (raw TypeScript is rejected). " + + "The change is validated, written atomically, and auto-reloaded after this reply finishes (available from the NEXT message).", + args: { + type: tool.schema.enum(["mcp", "agent", "command", "skill"]).describe("Extension kind to register"), + name: tool.schema.string().describe("Entry name (letters/digits/._- , max 64 chars); ignored for type=skill").default(""), + entry: tool.schema + .string() + .describe("The entry as a JSON object string, e.g. {\"description\":\"...\",\"prompt\":\"...\"}; empty for type=skill") + .default(""), + }, + async execute(args, ctx) { + if (isGlobalAlphaDir(ctx.directory, globalAlphaRoot)) + return { + title: "alpha_register", + output: "refused: this session runs in the home directory — project-scoped registration needs a project. Global installs go through the Extension Hub.", + metadata: { ok: false }, + } + let entry: Record | undefined + if (args.entry.trim()) { + try { + const parsed: unknown = JSON.parse(args.entry) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object") + entry = parsed as Record + } catch { + return { title: "alpha_register", output: "refused: entry is not a valid JSON object string", metadata: { ok: false } } + } + } + const alphaDir = join(ctx.directory, ".alpha") + const file = join(alphaDir, "alpha.jsonc") + const current = existsSync(file) ? readFileSync(file, "utf8") : null + const r = applyRegister(current, args.type as RegisterType, args.name, entry) + if (!r.ok) return { title: "alpha_register", output: `refused: ${r.reason}`, metadata: { ok: false } } + try { + mkdirSync(alphaDir, { recursive: true }) + const tmp = file + ".tmp" + writeFileSync(tmp, r.next) + renameSync(tmp, file) + } catch (error) { + return { + title: "alpha_register", + output: `write failed: ${error instanceof Error ? error.message : String(error)}`, + metadata: { ok: false }, + } + } + pendingReloads.set(ctx.sessionID ?? "", { reason: `alpha_register ${args.type} ${args.name}`.trim(), at: Date.now() }) + const consentNote = + args.type === "mcp" && !readProjectExtensionsConsent(ctx.directory) + ? "\nnote: this project has not yet been granted permission to load executable extensions — the connector stays gated until the user confirms the trust dialog (it appears when the project session is opened)." + : "" + return { + title: "alpha_register", + output: `${r.summary}\nreload scheduled — available from the NEXT message.${consentNote}`, + metadata: { ok: true, type: args.type, name: args.name, directory: ctx.directory }, + } + }, + }), alpha_echo: tool({ description: "Echo back the provided text. Proof that an alpha-code plugin-registered tool is available with zero opencode source edits.", diff --git a/packages/ext/src/project-config.ts b/packages/ext/src/project-config.ts index 8883664c03b5..9740ad6de746 100644 --- a/packages/ext/src/project-config.ts +++ b/packages/ext/src/project-config.ts @@ -23,11 +23,12 @@ export type MergeResult = { gatedExecutable: string[] } -/** 信任门(REQ-060 §3):mcp = 可执行连接器,只在 trustExecutable 时加载;agent/command/skills = 文本注入,恒加载。 */ +/** 信任门(REQ-060 §3):mcp = 可执行连接器,只在 trustExecutable 时加载;agent/command/skills = 文本注入,恒加载。 + * opts.directory:项目根 —— skills.paths 的相对条目("./…")以此解析为绝对(项目可移动,alpha.jsonc 存相对)。 */ export function mergeProjectConfig( cfg: Record, projectJsoncText: string, - opts: { trustExecutable?: boolean } = {}, + opts: { trustExecutable?: boolean; directory?: string } = {}, ): MergeResult { let proj: unknown try { @@ -54,10 +55,13 @@ export function mergeProjectConfig( if (isObj(proj[key]) && mergeNamed(cfg, proj[key] as Record, key)) added.push(`${key}.*`) } - // skills.paths 并集(object schema;去重,existing 在前) - const projPaths = isObj(proj.skills) && Array.isArray((proj.skills as Record).paths) + // skills.paths 并集(object schema;去重,existing 在前);相对条目按项目根解析为绝对 + const rawPaths = isObj(proj.skills) && Array.isArray((proj.skills as Record).paths) ? ((proj.skills as Record).paths as unknown[]) : [] + const projPaths = rawPaths.map((p) => + typeof p === "string" && p.startsWith("./") && opts.directory ? resolve(opts.directory, p) : p, + ) if (projPaths.length > 0) { const cur = isObj(cfg.skills) && Array.isArray((cfg.skills as Record).paths) ? ((cfg.skills as Record).paths as unknown[]) @@ -91,7 +95,7 @@ function mergeNamed(cfg: Record, src: Record, } /** 极简 jsonc → json:去 // 行注释与 /* *​/ 块注释 + 尾逗号。够用于 alpha 自写的项目 alpha.jsonc。 */ -function stripJsonc(text: string): string { +export function stripJsonc(text: string): string { let out = "" let inStr = false let strCh = "" diff --git a/packages/ext/src/register.test.ts b/packages/ext/src/register.test.ts new file mode 100644 index 000000000000..09a6aee8384a --- /dev/null +++ b/packages/ext/src/register.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test" +import { applyRegister } from "./register" + +describe("applyRegister — alpha_register 纯逻辑(REQ-060 T2)", () => { + test("agent 注册:白名单字段 → 条目落 agent 域", () => { + const r = applyRegister(null, "agent", "proj-helper", { description: "d", prompt: "p", mode: "subagent" }) + expect(r.ok).toBe(true) + if (r.ok) { + const cfg = JSON.parse(r.next) + expect(cfg.agent["proj-helper"]).toEqual({ description: "d", prompt: "p", mode: "subagent" }) + expect(r.summary).toContain("registered") + } + }) + + test("同名 = 更新(创建流迭代),summary 说 updated", () => { + const first = applyRegister(null, "command", "gen", { template: "t1" }) + expect(first.ok).toBe(true) + const second = applyRegister(first.ok ? first.next : "", "command", "gen", { template: "t2" }) + expect(second.ok).toBe(true) + if (second.ok) { + expect(JSON.parse(second.next).command.gen.template).toBe("t2") + expect(second.summary).toContain("updated") + } + }) + + test("字段白名单:未知字段拒绝 loud", () => { + const r = applyRegister(null, "agent", "x", { prompt: "p", evil: true }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toContain("evil") + }) + + test("mcp 字段白名单对齐 ext-config(environment/headers 可,任意键不可)", () => { + const ok = applyRegister(null, "mcp", "projdb", { type: "local", command: ["npx", "x"], environment: { A: "1" } }) + expect(ok.ok).toBe(true) + const bad = applyRegister(null, "mcp", "projdb", { type: "local", shell: "rm -rf" }) + expect(bad.ok).toBe(false) + }) + + test("SAFE_NAME:路径分隔/空名拒绝", () => { + expect(applyRegister(null, "agent", "../evil", { prompt: "p" }).ok).toBe(false) + expect(applyRegister(null, "agent", "", { prompt: "p" }).ok).toBe(false) + }) + + test("skill:无 entry,skills.paths 注册相对路径且幂等", () => { + const r1 = applyRegister(null, "skill", "", undefined) + expect(r1.ok).toBe(true) + const r2 = applyRegister(r1.ok ? r1.next : "", "skill", "", undefined) + expect(r2.ok).toBe(true) + if (r2.ok) expect(JSON.parse(r2.next).skills.paths).toEqual(["./.alpha/skills"]) + }) + + test("既有 jsonc(含注释/尾逗号)被保留合并;坏 jsonc 拒写(不覆盖用户文件)", () => { + const existing = `{ + // 项目连接器 + "mcp": { "keep": { "type": "remote", "url": "https://x" } }, + }` + const r = applyRegister(existing, "agent", "a", { prompt: "p" }) + expect(r.ok).toBe(true) + if (r.ok) { + const cfg = JSON.parse(r.next) + expect(cfg.mcp.keep.url).toBe("https://x") + expect(cfg.agent.a.prompt).toBe("p") + } + expect(applyRegister("{ broken", "agent", "a", { prompt: "p" }).ok).toBe(false) + }) + + test("entry 非对象拒绝", () => { + expect(applyRegister(null, "agent", "a", undefined).ok).toBe(false) + }) +}) + +describe("mergeProjectConfig 相对 skills.paths 解析(register 写入的 ./.alpha/skills)", () => { + test("./ 前缀按 directory 解析为绝对", async () => { + const { mergeProjectConfig } = await import("./project-config") + const cfg: Record = {} + mergeProjectConfig(cfg, JSON.stringify({ skills: { paths: ["./.alpha/skills"] } }), { directory: "/proj/x" }) + expect(cfg.skills.paths).toEqual(["/proj/x/.alpha/skills"]) + }) +}) diff --git a/packages/ext/src/register.ts b/packages/ext/src/register.ts new file mode 100644 index 000000000000..43ec49ffd4c2 --- /dev/null +++ b/packages/ext/src/register.ts @@ -0,0 +1,74 @@ +// REQ-060 T2 `alpha_register` 纯逻辑:把一条项目级扩展条目登记进 `/.alpha/alpha.jsonc`。 +// 模型不手改 config —— 校验(SAFE_NAME + 逐 type 字段白名单)与序列化都在这里,写坏面收敛; +// 消费方 = plugin.ts 的 alpha_register tool(读现文本 → applyRegister → 原子写 → 登记 reload)。 +// +// type 语义: +// mcp / agent / command → `alpha.jsonc` 对应命名域条目(同名 = 更新,创建流会迭代); +// skill → 无 entry:确保 skills.paths 含 "./.alpha/skills"(相对路径,config hook 按项目根解析, +// 项目可移动)—— skill 本体是 `/.alpha/skills//SKILL.md` 文件,模型直接写盘; +// plugin 不在此注册:`.alpha/plugins/*.js` 由 host fan-out 自动发现(必须自包含 ESM .js,ADR-006)。 + +import { stripJsonc } from "./project-config" + +export const SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/ + +/** 逐 type 字段白名单(与 ui-mac ext-config 的 SAFE_MCP_FIELDS 语义对齐;agent/command 对齐上游 v1 config 常用字段)。 */ +const ENTRY_FIELDS: Record<"mcp" | "agent" | "command", Set> = { + mcp: new Set(["type", "command", "args", "url", "environment", "headers", "enabled", "disabled", "cwd"]), + agent: new Set(["description", "prompt", "mode", "model", "temperature", "top_p", "permission", "hidden", "disable", "tools", "color"]), + command: new Set(["template", "description", "agent", "model", "subtask"]), +} + +export type RegisterType = "mcp" | "agent" | "command" | "skill" + +export type RegisterResult = + | { ok: true; next: string; summary: string } + | { ok: false; reason: string } + +export function applyRegister( + jsoncText: string | null | undefined, + type: RegisterType, + name: string, + entry: Record | undefined, +): RegisterResult { + let cfg: Record = {} + if (jsoncText && jsoncText.trim()) { + try { + const parsed: unknown = JSON.parse(stripJsonc(jsoncText)) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { ok: false, reason: "existing alpha.jsonc is not an object" } + cfg = parsed as Record + } catch { + // 现文件坏 → 拒绝写(覆盖坏文件可能吞用户内容;loud 让人修) + return { ok: false, reason: "existing .alpha/alpha.jsonc is invalid JSONC — fix it manually first" } + } + } + + if (type === "skill") { + const skills = isObj(cfg.skills) ? { ...(cfg.skills as Record) } : {} + const paths = Array.isArray(skills.paths) ? [...(skills.paths as unknown[])] : [] + if (!paths.includes("./.alpha/skills")) paths.push("./.alpha/skills") + skills.paths = paths + cfg.skills = skills + return { ok: true, next: serialize(cfg), summary: `skills path registered (./.alpha/skills); put the skill at .alpha/skills//SKILL.md` } + } + + if (!SAFE_NAME.test(name)) return { ok: false, reason: `invalid name: ${JSON.stringify(name)} (allowed: ${SAFE_NAME})` } + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return { ok: false, reason: `entry must be a JSON object for type=${type}` } + const allowed = ENTRY_FIELDS[type] + for (const key of Object.keys(entry)) { + if (!allowed.has(key)) return { ok: false, reason: `field not allowed for ${type}: ${key} (allowed: ${[...allowed].join(", ")})` } + } + + const domain = isObj(cfg[type]) ? { ...(cfg[type] as Record) } : {} + const existed = name in domain + domain[name] = entry + cfg[type] = domain + return { ok: true, next: serialize(cfg), summary: `${type} "${name}" ${existed ? "updated" : "registered"} in .alpha/alpha.jsonc` } +} + +const isObj = (v: unknown): v is Record => !!v && typeof v === "object" && !Array.isArray(v) + +/** 序列化(注:JSONC 注释不保留 —— 项目 alpha.jsonc 是 alpha 生成物,诚实边界记档)。 */ +function serialize(cfg: Record): string { + return JSON.stringify(cfg, null, 2) + "\n" +} diff --git a/packages/ui-mac/resources/factory-skills/agent-creator/SKILL.md b/packages/ui-mac/resources/factory-skills/agent-creator/SKILL.md index c39054fb7dfb..a7876dabfc92 100644 --- a/packages/ui-mac/resources/factory-skills/agent-creator/SKILL.md +++ b/packages/ui-mac/resources/factory-skills/agent-creator/SKILL.md @@ -1,13 +1,18 @@ --- name: agent-creator -description: Create a new opencode agent (subagent or primary) from a natural-language description, and know the opencode-specific file locations + hot-reload steps for ANY created extension. Use when the user asks to create/add/build an agent, a reviewer/researcher persona, or a recurring role — and ALSO after creating a skill (e.g. via skill-creator) to place it where opencode discovers it and make it take effect without restarting. +description: Create a new opencode agent (subagent or primary) from a natural-language description, and know the alpha-specific registration + hot-reload steps for ANY created extension. Use when the user asks to create/add/build an agent, a reviewer/researcher persona, or a recurring role — and ALSO after creating a skill (e.g. via skill-creator) to place it where the engine discovers it and make it take effect without restarting. license: MIT (alpha-code original) --- # Agent Creator -You create **opencode agent definition files** (`.md` with YAML frontmatter). You are an elite agent -architect: translate the user's requirement into a precisely-tuned agent specification. +You create **opencode agent definitions**. You are an elite agent architect: translate the user's +requirement into a precisely-tuned agent specification. + +**Golden rule (REQ-060)**: everything you create in a project lives under `/.alpha/` — +**never create `.opencode` anywhere** (no directories, no files, no links). Registration goes +through the `alpha_register` tool, which validates and writes the project's `.alpha/alpha.jsonc` +for you and schedules the reload — do not hand-edit config files. ## Workflow @@ -25,63 +30,62 @@ architect: translate the user's requirement into a precisely-tuned agent specifi - **permission**: default to least privilege. Read-only reviewer/researcher agents get `edit: deny`, `bash: deny`. Only grant write/exec when the user's purpose requires it. Always keep `"*.env*": deny` under `read` for anything that scans files broadly. - - **prompt** (the markdown body): expert persona + clear behavioral boundaries + methodology + - edge-case handling + output format. Be specific, not aspirational. - - Optional: `model` (leave unset to inherit), `temperature`, `color`, `steps`, `hidden`. -3. **Write the file**: - - **Default (project agent)**: `/.opencode/agent/.md` — no extra permissions - needed, discovered automatically for this project. - - **Global (only when the user says "所有项目/全局")**: `~/.alpha/agents/.md` (alpha's - global root; the `~/.opencode/agent` bridge makes the engine see it). If `~/.opencode/agent` - does not exist or is not a symlink/dir you can write through, fall back to - `~/.opencode/agent/.md` directly and say so. -4. **Hot-reload**: call the `alpha_reload` tool. It SCHEDULES an engine registry reload that runs - right after your current reply completes (an immediate reload would cut your reply off) — the - new agent is usable from the **next message in this same session**, no app restart. If - `alpha_reload` is unavailable, tell the user honestly that the agent takes effect after the app - restarts. -5. **Confirm**: show the file path, the frontmatter, and one example of how to invoke it - (`@ …` for subagents / agent switcher for primary). - -## File format (engine schema: fields outside this set are rejected or ignored) - -```markdown ---- -description: When to use this agent — concrete triggers, written for the model. -mode: subagent # subagent | primary | all -# model: provider/model-id (optional — omit to inherit the session model) -# temperature: 0.2 (optional) -# color: "#6366F1" (optional, hex or theme name) -# steps: 30 (optional, max agentic iterations) -# hidden: true (optional, subagent only: hide from @ menu) -permission: - read: - "*": allow - "*.env*": deny - edit: deny - bash: deny ---- - -(System prompt body — the agent's persona, methodology, boundaries, output format.) -``` + - **prompt**: expert persona + clear behavioral boundaries + methodology + edge-case handling + + output format. Be specific, not aspirational. + - Optional: `model` (leave unset to inherit), `temperature`, `color`, `hidden`. +3. **Register it (project agent — the default)**: call the `alpha_register` tool: + - `type`: `"agent"` + - `name`: the agent name + - `entry`: a JSON object string with the fields you designed, e.g. + `{"description":"…","mode":"subagent","prompt":"…","permission":{"edit":"deny","bash":"deny","read":{"*":"allow","*.env*":"deny"}}}` + - Allowed entry fields: description, prompt, mode, model, temperature, top_p, permission, + hidden, disable, tools, color. The tool validates, writes `/.alpha/alpha.jsonc` + atomically, and schedules a reload — the agent is usable from the **next message in this same + session**, no app restart. Do NOT write any agent file yourself and do NOT create `.opencode`. +4. **Global agent (only when the user says "所有项目/全局")**: session-created agents are + project-scoped. For a global agent, write the definition to a regular `.md` file (frontmatter + + prompt body) somewhere visible (e.g. the project root or Desktop), then tell the user honestly: + import it via **定制中心 → 创建/导入 → 导入 Agent**, which installs it globally (validated, + receipted, uninstallable). There is no session-side global registration. +5. **Confirm**: state what was registered (name + one-line description) and one example of how to + invoke it (`@ …` for subagents / agent switcher for primary). If `alpha_register` is + unavailable, say honestly that in-session creation isn't wired in this build and point the user + to 定制中心. ## Rules -- The filename (minus `.md`) IS the agent name — keep them consistent. -- Never overwrite an existing agent file silently; if the name exists, show the conflict and ask. +- Never overwrite an existing agent silently; `alpha_register` reports "updated" vs "registered" — + if the user didn't ask for an update and you see "updated", flag the name collision. - Never invent permission keys; use only: read/edit/bash/glob/grep/list/webfetch/websearch/skill/ external_directory/doom_loop/question/task (value: allow/deny/ask, or a pattern→action map). -- Keep the whole file self-contained — no references to documents the agent won't have at runtime. +- Keep the prompt self-contained — no references to documents the agent won't have at runtime. -## Creating SKILLS (opencode specifics — use together with skill-creator) +## Creating SKILLS (alpha specifics — use together with skill-creator) -skill-creator teaches the generic methodology; these are the opencode-specific rules it doesn't know: +skill-creator teaches the generic methodology; these are the alpha-specific rules it doesn't know: -- **Where to write**: default `/.opencode/skill//SKILL.md` (project-local, auto-discovered). - Global (only when asked): `~/.alpha/skills//` if the `~/.opencode/skill` bridge exists, else - `~/.opencode/skill//` directly — say which one you used. +- **Project skill (default)**: write `/.alpha/skills//SKILL.md`, then call + `alpha_register` with `type: "skill"` (no name/entry needed — it registers the skills path once). + Never write into `.opencode`. +- **Global (only when asked)**: write `~/.alpha/skills//SKILL.md` — the engine discovers + this directory natively (no registration needed). - **Frontmatter**: `name` (lowercase-hyphen, ≤64, must equal the folder name) + `description` (write it for the MODEL: concrete "Use when …" triggers decide whether the skill fires). - **Take effect**: the engine does NOT watch files — after writing, call the `alpha_reload` tool (it schedules a registry reload right after your reply completes; the skill works from the next - message). If `alpha_reload` is unavailable, say honestly that a restart is needed. + message). `alpha_register` already schedules this for you. + +## Creating COMMANDS (project-scoped) + +A command is a reusable prompt template (`/name` in the composer). Register with `alpha_register`: +`type: "command"`, `name`, and `entry` like `{"template":"…the prompt, may use $ARGUMENTS…", +"description":"…"}`. Allowed fields: template, description, agent, model, subtask. Project-scoped +only; it takes effect from the next message (auto-reload). + +## Creating PLUGINS (advanced, project-scoped) + +A plugin is executable JS loaded into the engine. Drop a **self-contained ESM `.js`** file into +`/.alpha/plugins/` (raw TypeScript is rejected — the desktop runtime cannot load it; +bundle first). No registration needed — but loading executable extensions requires the user's +one-time per-project consent dialog (it appears when the project session is opened). Be honest +about this gate when you create one. diff --git a/packages/ui-mac/src/main/agent-md-entry.test.ts b/packages/ui-mac/src/main/agent-md-entry.test.ts new file mode 100644 index 000000000000..9b2985e4e2fd --- /dev/null +++ b/packages/ui-mac/src/main/agent-md-entry.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test" +import { agentMdToEntry } from "./agent-md-entry" + +const CODE_REVIEWER = `--- +description: 只读代码审查 Agent(REQ-023 官方示例) +mode: subagent +permission: + edit: deny + bash: ask + webfetch: allow +--- + +你是一名严格的代码审查者。只读审查,输出问题清单。 +` + +describe("agentMdToEntry — md → config agent 条目(REQ-059 T3b)", () => { + test("打包资产形状(code-reviewer):平铺键 + permission 一层", () => { + const r = agentMdToEntry(CODE_REVIEWER) + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.entry.mode).toBe("subagent") + expect(r.entry.permission).toEqual({ edit: "deny", bash: "ask", webfetch: "allow" }) + expect(String(r.entry.prompt)).toContain("代码审查者") + expect("name" in r.entry).toBe(false) + } + }) + + test("permission 两层(pattern map,agent-creator 模板形状)", () => { + const md = `--- +description: d +mode: subagent +permission: + read: + "*": allow + "*.env*": deny + edit: deny +--- + +body +` + const r = agentMdToEntry(md) + expect(r.ok).toBe(true) + if (r.ok) expect(r.entry.permission).toEqual({ read: { "*": "allow", "*.env*": "deny" }, edit: "deny" }) + }) + + test("数值/布尔转型(temperature/steps/hidden)", () => { + const md = `--- +description: d +temperature: 0.2 +steps: 30 +hidden: true +--- + +body +` + const r = agentMdToEntry(md) + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.entry.temperature).toBe(0.2) + expect(r.entry.steps).toBe(30) + expect(r.entry.hidden).toBe(true) + } + }) + + test("fail-closed:未知顶层键 / 三层嵌套 / 非法 action / 空 body 全拒", () => { + expect(agentMdToEntry(`---\ndescription: d\nevil_key: x\n---\n\nbody\n`).ok).toBe(false) + expect(agentMdToEntry(`---\ndescription: d\npermission:\n read:\n deep:\n more: allow\n---\n\nbody\n`).ok).toBe(false) + expect(agentMdToEntry(`---\ndescription: d\npermission:\n edit: maybe\n---\n\nbody\n`).ok).toBe(false) + expect(agentMdToEntry(`---\ndescription: d\n---\n\n`).ok).toBe(false) + expect(agentMdToEntry(`no frontmatter`).ok).toBe(false) + }) + + test("description 必填;注释与空行被忽略;引号剥除", () => { + expect(agentMdToEntry(`---\nmode: subagent\n---\n\nbody\n`).ok).toBe(false) + const r = agentMdToEntry(`---\n# comment\ndescription: "quoted"\n\nmode: subagent\n---\n\nbody\n`) + expect(r.ok).toBe(true) + if (r.ok) expect(r.entry.description).toBe("quoted") + }) +}) diff --git a/packages/ui-mac/src/main/agent-md-entry.ts b/packages/ui-mac/src/main/agent-md-entry.ts new file mode 100644 index 000000000000..4339fcd27d66 --- /dev/null +++ b/packages/ui-mac/src/main/agent-md-entry.ts @@ -0,0 +1,112 @@ +// REQ-059 T3b:agent md(frontmatter + body)→ 引擎 config agent 条目 —— 桥退役后全局 agent 经 +// `~/.alpha/alpha.jsonc` 的 `agent.` 条目进引擎(G1/OPENCODE_CONFIG 通道),不再造 `.opencode` 桥。 +// +// 解析器纪律(沿 ext-import-validate:不引 YAML 解析器):只认 alpha 自有 agent 资产/创建流实际用到的 +// 受限形状 —— 顶层平铺 `key: value` + `permission:` 一~两层嵌套(tool: action 或 tool 下 pattern→action)。 +// 解析不动(未知顶层键 / 更深嵌套 / 列表语法)→ fail-closed loud:拒装比装出一个字段静默丢失的 agent 好 +// (C28 反 placebo)。prompt = body 内联进条目(md 文件仍写盘作内容真源/人读;编辑文件不生效是诚实边界, +// 改 agent 请重装或经 hub)。 + +export type AgentEntryResult = + | { ok: true; entry: Record } + | { ok: false; reason: string } + +const SCALAR_KEYS = new Set(["description", "mode", "model", "temperature", "top_p", "color", "steps", "hidden", "disable", "variant"]) +const NUMERIC_KEYS = new Set(["temperature", "top_p", "steps"]) +const BOOLEAN_KEYS = new Set(["hidden", "disable"]) +const ACTIONS = new Set(["allow", "deny", "ask"]) + +export function agentMdToEntry(text: string): AgentEntryResult { + if (!text.startsWith("---")) return { ok: false, reason: "missing frontmatter" } + const end = text.indexOf("\n---", 3) + if (end === -1 || end > 8192) return { ok: false, reason: "unterminated frontmatter" } + const block = text.slice(3, end).replace(/^\r?\n/, "") + const body = text.slice(end + 4).replace(/^\r?\n/, "").trim() + + const entry: Record = {} + const lines = block.split("\n") + let i = 0 + while (i < lines.length) { + const raw = lines[i] + if (!raw.trim() || raw.trim().startsWith("#")) { + i++ + continue + } + if (/^\s/.test(raw)) return { ok: false, reason: `unexpected indentation at frontmatter line: ${raw.trim()}` } + const m = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(raw) + if (!m) return { ok: false, reason: `unparsable frontmatter line: ${raw.trim()}` } + const key = m[1] + const val = m[2].trim() + + if (key === "permission") { + if (val) return { ok: false, reason: "permission must be a nested block" } + const { value, next } = parseNested(lines, i + 1, 1) + if ("error" in value) return { ok: false, reason: value.error } + entry.permission = value.obj + i = next + continue + } + if (key === "name") { + i++ // 文件名即名字;frontmatter name 只跳过,不进条目(config agent 条目键名 = agent 名) + continue + } + if (!SCALAR_KEYS.has(key)) return { ok: false, reason: `unsupported frontmatter key: ${key}` } + if (!val) return { ok: false, reason: `empty value for: ${key}` } + const unquoted = val.replace(/^["']|["']$/g, "") + if (NUMERIC_KEYS.has(key)) { + const n = Number(unquoted) + if (!Number.isFinite(n)) return { ok: false, reason: `non-numeric value for ${key}: ${val}` } + entry[key] = n + } else if (BOOLEAN_KEYS.has(key)) { + if (unquoted !== "true" && unquoted !== "false") return { ok: false, reason: `non-boolean value for ${key}: ${val}` } + entry[key] = unquoted === "true" + } else { + entry[key] = unquoted + } + i++ + } + + if (typeof entry.description !== "string" || !entry.description) return { ok: false, reason: "description is required" } + if (!body) return { ok: false, reason: "empty prompt body" } + entry.prompt = body + return { ok: true, entry } +} + +/** 解析缩进嵌套块(permission 及其下一层 pattern map)。depth 1 = permission 直下,2 = pattern map。 */ +function parseNested( + lines: string[], + start: number, + depth: number, +): { value: { obj: Record } | { error: string }; next: number } { + const obj: Record = {} + let i = start + let indent: number | null = null + while (i < lines.length) { + const raw = lines[i] + if (!raw.trim() || raw.trim().startsWith("#")) { + i++ + continue + } + const lead = raw.length - raw.trimStart().length + if (lead === 0) break // 回到顶层 + if (indent === null) indent = lead + if (lead < indent) break // 回到上一层 + if (lead > indent) return { value: { error: `unexpected deeper indentation: ${raw.trim()}` }, next: i } + const m = /^([A-Za-z0-9_.*"'-]+|"[^"]+")\s*:\s*(.*)$/.exec(raw.trim()) + if (!m) return { value: { error: `unparsable permission line: ${raw.trim()}` }, next: i } + const key = m[1].replace(/^["']|["']$/g, "") + const val = m[2].trim().replace(/^["']|["']$/g, "") + if (val) { + if (!ACTIONS.has(val)) return { value: { error: `invalid permission action for ${key}: ${val}` }, next: i } + obj[key] = val + i++ + } else { + if (depth >= 2) return { value: { error: `nesting too deep at: ${key}` }, next: i } + const inner = parseNested(lines, i + 1, depth + 1) + if ("error" in inner.value) return inner + obj[key] = inner.value.obj + i = inner.next + } + } + return { value: { obj }, next: i } +} diff --git a/packages/ui-mac/src/main/alpha-ext-trust.test.ts b/packages/ui-mac/src/main/alpha-ext-trust.test.ts new file mode 100644 index 000000000000..f32e4fa56249 --- /dev/null +++ b/packages/ui-mac/src/main/alpha-ext-trust.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test" +import { + EXTENSIONS_CONSENT_VERSION, + extensionsGranted, + hasExtensionsDecision, + listProjectExecutables, + withExtensionsConsent, +} from "./alpha-ext-trust" + +describe("extensionsConsent 决策语义(REQ-060 信任门)", () => { + test("无记录 = 未决策", () => { + expect(hasExtensionsDecision({})).toBe(false) + expect(extensionsGranted({})).toBe(false) + }) + + test("granted 与 denied 都是决策(不再弹);granted 语义分离", () => { + const g = withExtensionsConsent({}, true, "2026-07-08T00:00:00Z") + const d = withExtensionsConsent({}, false, "2026-07-08T00:00:00Z") + expect(hasExtensionsDecision(g)).toBe(true) + expect(hasExtensionsDecision(d)).toBe(true) + expect(extensionsGranted(g)).toBe(true) + expect(extensionsGranted(d)).toBe(false) + }) + + test("旧版本决策失效(版本化重弹)", () => { + const old = { extensionsConsent: { version: EXTENSIONS_CONSENT_VERSION - 1, granted: true, decidedAt: "x" } } + expect(hasExtensionsDecision(old)).toBe(false) + expect(extensionsGranted(old)).toBe(false) + }) + + test("写入保留 prefs 其它字段(与 cloudConsent 共存)", () => { + const prefs = { cloudConsent: { version: 1, acceptedAt: "y" } } + const out = withExtensionsConsent(prefs, true, "2026-07-08T00:00:00Z") + expect(out.cloudConsent).toEqual({ version: 1, acceptedAt: "y" }) + expect((out as Record).extensionsConsent.granted).toBe(true) + }) + + test("坏形状(granted 非布尔)不算决策", () => { + expect(hasExtensionsDecision({ extensionsConsent: { version: EXTENSIONS_CONSENT_VERSION, granted: "yes" } } as any)).toBe(false) + }) +}) + +describe("listProjectExecutables — 可执行物清单派生", () => { + test("jsonc mcp 键名 + plugins 只认 .js(ADR-006)", () => { + const jsonc = `{ + // 项目连接器 + "mcp": { "projdb": { "type": "local", "command": ["npx", "x"] }, "projfetch": {} }, + "agent": { "a": {} }, + }` + const r = listProjectExecutables(jsonc, ["p.js", "raw.ts", "note.md"]) + expect(r.mcp.sort()).toEqual(["projdb", "projfetch"]) + expect(r.plugins).toEqual(["p.js"]) + }) + + test("jsonc 坏/缺 → mcp 空(诚实降级,与 ext 侧一致)", () => { + expect(listProjectExecutables("{ bad", ["p.js"]).mcp).toEqual([]) + expect(listProjectExecutables(null, []).mcp).toEqual([]) + }) + + test("文本类域(agent/command/skills)不进可执行清单", () => { + const r = listProjectExecutables(`{ "agent": { "a": {} }, "command": { "c": {} }, "skills": { "paths": ["x"] } }`, []) + expect(r.mcp).toEqual([]) + expect(r.plugins).toEqual([]) + }) +}) diff --git a/packages/ui-mac/src/main/alpha-ext-trust.ts b/packages/ui-mac/src/main/alpha-ext-trust.ts new file mode 100644 index 000000000000..19cf4b8110a0 --- /dev/null +++ b/packages/ui-mac/src/main/alpha-ext-trust.ts @@ -0,0 +1,91 @@ +// REQ-060 信任门(项目自带可执行扩展的 per-project consent)—— 纯核。 +// +// 语义:项目 `.alpha/alpha.jsonc` 的 mcp 与 `.alpha/plugins/*.js` 是**可执行物**(打开陌生仓库即 +// 在本机跑其代码),@alpha-code/ext 的信任门只在 `.alpha/prefs.json` 的 `extensionsConsent.granted +// === true` 时加载它们(plugin.ts:readProjectExtensionsConsent)。本模块负责决策记录的读写语义与 +// 可执行物清单派生;弹窗在 ext-ipc(B16/ADR-021 同款分层:纯核可单测,对话框在 IPC 层)。 +// +// 决策(granted/denied)都落盘 —— 拒绝 = 该项目仅文本类(skill/agent/command)生效,且不再重复弹。 +// 版本化:告知内容实质变更时 bump EXTENSIONS_CONSENT_VERSION → 旧决策失效、重新弹。 + +import type { ProjectPrefs } from "./alpha-cloud-consent" + +export const EXTENSIONS_CONSENT_VERSION = 1 + +export type ExtensionsConsent = { version: number; granted: boolean; decidedAt: string } + +/** 已有当前版本的决策(granted 与 denied 都算)⟺ 不再弹。 */ +export function hasExtensionsDecision(prefs: ProjectPrefs): boolean { + const c = (prefs as { extensionsConsent?: { version?: unknown; granted?: unknown } }).extensionsConsent + return c?.version === EXTENSIONS_CONSENT_VERSION && typeof c.granted === "boolean" +} + +export function extensionsGranted(prefs: ProjectPrefs): boolean { + const c = (prefs as { extensionsConsent?: { version?: unknown; granted?: unknown } }).extensionsConsent + return c?.version === EXTENSIONS_CONSENT_VERSION && c.granted === true +} + +/** 合并写入决策(保留 prefs 其它字段)。iso 由调用方注入(单测确定性)。 */ +export function withExtensionsConsent(prefs: ProjectPrefs, granted: boolean, iso: string): ProjectPrefs { + return { ...prefs, extensionsConsent: { version: EXTENSIONS_CONSENT_VERSION, granted, decidedAt: iso } } +} + +export type ProjectExecutables = { mcp: string[]; plugins: string[] } + +/** + * 从项目 alpha.jsonc 文本 + plugins 目录文件列表派生可执行物清单(供弹窗如实列明「要加载什么」)。 + * jsonc 坏/缺 → mcp 空(与 ext 侧 mergeProjectConfig 的诚实降级一致);plugins 只认 .js(ADR-006)。 + */ +export function listProjectExecutables(jsoncText: string | null | undefined, pluginFiles: string[]): ProjectExecutables { + let mcp: string[] = [] + if (jsoncText) { + try { + const parsed: unknown = JSON.parse(stripJsonc(jsoncText)) + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const m = (parsed as Record).mcp + if (m && typeof m === "object" && !Array.isArray(m)) mcp = Object.keys(m as Record) + } + } catch { + /* 项目文件坏 → 引擎侧也不会注入,无可执行物 */ + } + } + return { mcp, plugins: pluginFiles.filter((f) => f.endsWith(".js")) } +} + +/** 极简 jsonc → json(与 @alpha-code/ext project-config 同款):去注释 + 尾逗号。 */ +function stripJsonc(text: string): string { + let out = "" + let inStr = false + let strCh = "" + for (let i = 0; i < text.length; i++) { + const c = text[i] + const n = text[i + 1] + if (inStr) { + out += c + if (c === "\\") { + out += n ?? "" + i++ + } else if (c === strCh) inStr = false + continue + } + if (c === '"' || c === "'") { + inStr = true + strCh = c + out += c + continue + } + if (c === "/" && n === "/") { + while (i < text.length && text[i] !== "\n") i++ + out += "\n" + continue + } + if (c === "/" && n === "*") { + i += 2 + while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++ + i++ + continue + } + out += c + } + return out.replace(/,(\s*[}\]])/g, "$1") +} diff --git a/packages/ui-mac/src/main/engine-config-truth-boot.ts b/packages/ui-mac/src/main/engine-config-truth-boot.ts index 89e7a21bb7ab..e0a1baddb14b 100644 --- a/packages/ui-mac/src/main/engine-config-truth-boot.ts +++ b/packages/ui-mac/src/main/engine-config-truth-boot.ts @@ -15,6 +15,8 @@ import { parse } from "jsonc-parser" import { alphaGlobalRoot } from "./alpha-installs" import { opencodeHomeDir } from "./alpha-bridge" import { readLedger } from "./alpha-installs" +import { agentMdToEntry } from "./agent-md-entry" +import { persistAgentEntry } from "./ext-config" import { alphaJsoncPath, alphaSkillsDir, @@ -152,10 +154,14 @@ function cleanupOpencodeHome(log?: Logger): void { } catch { return } - // 1. 拆 alpha 自有的 skills 类目 symlink(dir-link 指向 ~/.alpha/skills)。用户真实目录/异源链不碰。 - // T3 本批只退役 skills 桥(skills.paths 文件通道接管);agents/commands 桥退役 + 条目化 = T3b - // (引擎无 agent/command paths,需读 md 写 config 条目;本机无全局 agent/command 验证物,不阻塞)。 - for (const kind of ["skills"]) { + // 0.(T3b)存量 agents 桥 → 条目化迁移 + 拆链:~/.alpha/agents/*.md → agentMdToEntry → alpha.jsonc + // agent 条目;**全部成功才拆 agents 桥**(任一转换失败 → loud 保留桥 = 该 agent 继续经桥可见, + // 诚实降级)。幂等:persistAgentEntry 覆盖写同值;无 agents 目录/无桥 → no-op。 + migrateAgentBridges(dir, alphaRoot, log) + // 1. 拆 alpha 自有的类目 symlink(dir-link 指向 ~/.alpha/)。用户真实目录/异源链不碰。 + // skills = REQ-059 T3(skills.paths 文件通道接管);agents 由上一步全权处理(迁移成功才拆); + // commands = 防御性(无写入方,预留目录,若存在 alpha 链一并拆)。 + for (const kind of ["skills", "commands"]) { const p = path.join(dir, kind) try { const st = fs.lstatSync(p) @@ -198,3 +204,80 @@ function cleanupOpencodeHome(log?: Logger): void { /* best effort */ } } + +/** + * T3b:存量 agents 桥的条目化迁移。桥形态两种(alpha-bridge):目录级 dir-link + * `~/.opencode/agents → ~/.alpha/agents`,或真实目录内的逐条目链。逐 md 转换写条目; + * **全部成功才拆链**(部分失败 → 保留桥,loud;下次启动重试 —— persistAgentEntry 幂等)。 + */ +function migrateAgentBridges(opencodeDir: string, alphaRoot: string, log?: Logger): void { + const bridged = path.join(opencodeDir, "agents") + const alphaAgents = path.join(alphaRoot, "agents") + let form: "dir-link" | "item-links" | null = null + const itemLinks: string[] = [] + try { + const st = fs.lstatSync(bridged) + if (st.isSymbolicLink()) { + const target = fs.readlinkSync(bridged) + const resolved = path.isAbsolute(target) ? target : path.resolve(opencodeDir, target) + if (resolved === alphaAgents || resolved.startsWith(alphaRoot + path.sep)) form = "dir-link" + } else if (st.isDirectory()) { + for (const f of fs.readdirSync(bridged)) { + const p = path.join(bridged, f) + try { + const ls = fs.lstatSync(p) + if (!ls.isSymbolicLink()) continue + const target = fs.readlinkSync(p) + const resolved = path.isAbsolute(target) ? target : path.resolve(bridged, target) + if (resolved.startsWith(alphaRoot + path.sep)) itemLinks.push(p) + } catch { + /* skip */ + } + } + if (itemLinks.length > 0) form = "item-links" + } + } catch { + return // no agents bridge at all + } + if (!form) return + + // 迁移:真源 md 全部转换成条目(alpha.jsonc);任一失败 → 保留桥 loud。 + let mds: string[] = [] + try { + mds = fs.existsSync(alphaAgents) ? fs.readdirSync(alphaAgents).filter((f) => f.endsWith(".md")) : [] + } catch { + mds = [] + } + for (const f of mds) { + const name = f.slice(0, -3) + let content: string + try { + content = fs.readFileSync(path.join(alphaAgents, f), "utf8") + } catch (error) { + log?.warn(`[req059-t3b] agent md unreadable — bridge kept`, { file: f, error: String(error) }) + return + } + const parsed = agentMdToEntry(content) + if (!parsed.ok) { + log?.warn(`[req059-t3b] agent "${name}" not convertible (${parsed.reason}) — bridge kept for all agents`) + return + } + const persisted = persistAgentEntry(name, parsed.entry) + if (!persisted.ok) { + log?.warn(`[req059-t3b] agent "${name}" entry write failed (${persisted.reason}) — bridge kept`) + return + } + } + // 全部条目就位 → 拆桥 + try { + if (form === "dir-link") { + fs.unlinkSync(bridged) + } else { + for (const p of itemLinks) fs.unlinkSync(p) + if (fs.readdirSync(bridged).length === 0) fs.rmSync(bridged, { recursive: true, force: true }) + } + log?.log(`[req059-t3b] agents bridge retired (${mds.length} agent(s) migrated to alpha.jsonc entries)`) + } catch (error) { + log?.warn(`[req059-t3b] agents bridge unlink failed`, { error: String(error) }) + } +} diff --git a/packages/ui-mac/src/main/ext-config.ts b/packages/ui-mac/src/main/ext-config.ts index 81cb96a69592..8ccb54bbdbd7 100644 --- a/packages/ui-mac/src/main/ext-config.ts +++ b/packages/ui-mac/src/main/ext-config.ts @@ -282,7 +282,7 @@ function validateServer(server: Record): ConfigResult { // Top-level keys we've verified against opencode's V1 schema (packages/core/src/v1/config/config.ts). // opencode hard-fails its ENTIRE config on any unrecognized top-level key, so a single wrong key // breaks every session — this allowlist makes such a regression fail loudly here instead. -const ALLOWED_TOP_KEYS = new Set(["mcp", "plugin", "provider"]) +const ALLOWED_TOP_KEYS = new Set(["mcp", "plugin", "provider", "agent"]) // https for any host; plain http only for loopback, never with embedded credentials. WHATWG-parsed // (not substring) so http://localhost.evil.com / http://127.0.0.1@evil.com can't slip past. @@ -359,6 +359,19 @@ export function persistMcp(name: string, server: Record, meta?: return written } +/** REQ-059 T3b:agent 条目写 alpha.jsonc 的 agent.(桥退役后引擎经 G1 见到全局 agent)。 + * targetPath 可覆盖(项目分支写 /.alpha/alpha.jsonc);条目由 agent-md-entry 转换器产出。 */ +export function persistAgentEntry(name: string, entry: Record, targetPath?: string): ConfigResult { + if (!SAFE_NAME.test(name)) return { ok: false, reason: "invalid agent name" } + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return { ok: false, reason: "invalid agent entry" } + return writeKey(targetPath ?? mcpPluginTargetPath(), ["agent", name], entry) +} + +export function removeAgentEntry(name: string, targetPath?: string): ConfigResult { + if (!SAFE_NAME.test(name)) return { ok: false, reason: "invalid agent name" } + return writeKey(targetPath ?? mcpPluginTargetPath(), ["agent", name], undefined) +} + /** * Remove mcp[] — from the alpha-owned file, and (pre-migration installs, T3) from the legacy * shared XDG config when it still carries the entry. Receipt goes too. diff --git a/packages/ui-mac/src/main/ext-fs-installer.test.ts b/packages/ui-mac/src/main/ext-fs-installer.test.ts index 4eba166fd673..8ce27a49b8d6 100644 --- a/packages/ui-mac/src/main/ext-fs-installer.test.ts +++ b/packages/ui-mac/src/main/ext-fs-installer.test.ts @@ -72,14 +72,33 @@ describe("writeSkill — global scope writes truth + bridge + receipt", () => { }) describe("writeAgent — global scope", () => { - test("agent md lands in ~/.alpha/agents and bridges as agents/.md", () => { - const r = writeAgent("helper", "---\ndescription: h\n---\nsystem") + test("T3b:agent md 落 ~/.alpha/agents + alpha.jsonc 条目;零 .opencode 桥", () => { + const r = writeAgent("helper", "---\ndescription: h\nmode: subagent\n---\nsystem") expect(r.ok).toBe(true) expect(fs.readFileSync(path.join(alphaDir, "agents", "helper.md"), "utf8")).toContain("system") - expect(fs.readFileSync(path.join(opencodeDir, "agents", "helper.md"), "utf8")).toContain("system") + // 桥退役:引擎经 alpha.jsonc 的 agent. 条目见到(G1 通道),.opencode 内零 alpha 痕迹 + expect(fs.existsSync(path.join(opencodeDir, "agents"))).toBe(false) + const cfg = JSON.parse(fs.readFileSync(path.join(alphaDir, "alpha.jsonc"), "utf8")) + expect(cfg.agent.helper).toMatchObject({ description: "h", mode: "subagent", prompt: "system" }) const { receipts } = readLedger(alphaDir) expect(receipts[0]).toMatchObject({ type: "agent", name: "helper" }) }) + + test("T3b fail-closed:frontmatter 转换不了(未知键)→ 拒装,零落盘", () => { + const r = writeAgent("bad", "---\ndescription: d\nmystery_key: x\n---\nbody") + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toContain("mystery_key") + expect(fs.existsSync(path.join(alphaDir, "agents", "bad.md"))).toBe(false) + }) + + test("T3b:removeFsInstall 净除 alpha.jsonc 条目 + md", () => { + expect(writeAgent("gone", "---\ndescription: d\n---\nbody").ok).toBe(true) + const r = removeFsInstall("agent", "gone") + expect(r.ok).toBe(true) + expect(fs.existsSync(path.join(alphaDir, "agents", "gone.md"))).toBe(false) + const cfg = JSON.parse(fs.readFileSync(path.join(alphaDir, "alpha.jsonc"), "utf8")) + expect(cfg.agent?.gone).toBeUndefined() + }) }) describe("project scope", () => { @@ -164,14 +183,16 @@ describe("removeFsInstall — deletes truth, unbridges, drops receipt (T6)", () describe("installRemoteAgent — REQ-046 远程 agent 通道(单 .md 约定 + writeAgent 同管线)", () => { const md = (s = "---\ndescription: remote helper\n---\nsystem prompt") => Buffer.from(s, "utf8") - test("happy path:单 .md → 真源 + 桥 + 账本(origin=catalog,meta 记版本)", () => { + test("happy path:单 .md → 真源 + alpha.jsonc 条目(T3b 零桥)+ 账本(origin=catalog,meta 记版本)", () => { const r = installRemoteAgent("remote-helper", [{ path: "remote-helper.md", data: md() }], undefined, { catalogId: "agent:remote-helper", version: "1.0.0", }) expect(r.ok).toBe(true) expect(fs.readFileSync(path.join(alphaDir, "agents", "remote-helper.md"), "utf8")).toContain("system prompt") - expect(fs.readFileSync(path.join(opencodeDir, "agents", "remote-helper.md"), "utf8")).toContain("system prompt") + expect(fs.existsSync(path.join(opencodeDir, "agents"))).toBe(false) + const cfg = JSON.parse(fs.readFileSync(path.join(alphaDir, "alpha.jsonc"), "utf8")) + expect(cfg.agent["remote-helper"].prompt).toContain("system prompt") const { receipts } = readLedger(alphaDir) expect(receipts[0]).toMatchObject({ type: "agent", name: "remote-helper", origin: "catalog" }) }) diff --git a/packages/ui-mac/src/main/ext-fs-installer.ts b/packages/ui-mac/src/main/ext-fs-installer.ts index 9d3dbc12f5c2..29435c8340d5 100644 --- a/packages/ui-mac/src/main/ext-fs-installer.ts +++ b/packages/ui-mac/src/main/ext-fs-installer.ts @@ -15,7 +15,9 @@ import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" import { fileURLToPath } from "node:url" -import { bridgeItem, opencodeHomeDir, unbridgeItem } from "./alpha-bridge" +import { opencodeHomeDir, unbridgeItem } from "./alpha-bridge" +import { agentMdToEntry } from "./agent-md-entry" +import { persistAgentEntry, removeAgentEntry } from "./ext-config" import { addReceipt, alphaGlobalRoot, removeReceipt } from "./alpha-installs" import { alphaRoot, ensureAlphaScaffold } from "./alpha-workdir" import type { InstallMeta, InstallReceipt, InstallTarget } from "../preload/types" @@ -149,7 +151,10 @@ export function writeSkill( return { ok: true, files } } -/** Write an agent definition (caller composes the markdown) into the alpha truth root + bridge + receipt. */ +/** Write an agent definition (caller composes the markdown) into the alpha truth root + config entry + receipt. + * REQ-059 T3b 桥退役:引擎经 alpha.jsonc 的 `agent.` 条目(md 先过 agentMdToEntry 转换,fail-closed) + * 见到 agent,不再造 `.opencode` 桥(不变量:任何层级零 `.opencode`)。md 文件仍写盘 = 内容真源/人读; + * 编辑文件不生效(诚实边界:改 agent 走重装/hub)。项目 target 同构(条目写 /.alpha/alpha.jsonc)。 */ export function writeAgent(name: string, content: string, target?: InstallTarget, meta?: InstallMeta, origin?: InstallReceipt["origin"]): FsResult { if (!SAFE_NAME.test(name)) return { ok: false, reason: "invalid agent name" } const normalized = content.endsWith("\n") ? content : `${content}\n` @@ -160,6 +165,8 @@ export function writeAgent(name: string, content: string, target?: InstallTarget return file }) } + const parsed = agentMdToEntry(normalized) + if (!parsed.ok) return { ok: false, reason: `agent frontmatter not convertible: ${parsed.reason}` } const roots = resolveRoots(target) if ("error" in roots) return { ok: false, reason: roots.error } const dir = safeResolveUnder(roots.alphaDir, "agents") @@ -171,9 +178,17 @@ export function writeAgent(name: string, content: string, target?: InstallTarget } catch (error) { return { ok: false, reason: error instanceof Error ? error.message : "failed to write agent" } } - const bridge = bridgeItem(roots.alphaDir, roots.opencodeDir, "agents", name) - if (!bridge.ok) return { ok: false, reason: `已写入 ${file},但引擎桥接失败:${bridge.reason}` } - const files = [file, ...bridge.created] + const entryTarget = roots.scope === "project" ? path.join(roots.alphaDir, "alpha.jsonc") : undefined + const persisted = persistAgentEntry(name, parsed.entry, entryTarget) + if (!persisted.ok) { + try { + fs.unlinkSync(file) // 条目失败则撤 md,不留「文件在、引擎看不见」的半装态 + } catch { + /* best-effort */ + } + return { ok: false, reason: `agent config entry failed: ${persisted.reason}` } + } + const files = [file] recordReceipt(roots, { name, type: "agent", files, meta, origin }) return { ok: true, files } } @@ -336,6 +351,12 @@ export function removeFsInstall(type: "skill" | "agent", name: string, target?: } catch (error) { return { ok: false, reason: error instanceof Error ? error.message : "failed to remove" } } + // REQ-059 T3b:agent 条目净除(alpha.jsonc 的 agent.;存量桥装的 agent 无条目 → no-op 幂等) + if (type === "agent") { + const entryTarget = roots.scope === "project" ? path.join(roots.alphaDir, "alpha.jsonc") : undefined + const r = removeAgentEntry(name, entryTarget) + if (!r.ok) return { ok: false, reason: `agent config entry removal failed: ${r.reason}` } + } removeReceipt(roots.alphaDir, type, name) return { ok: true, files: removed } } diff --git a/packages/ui-mac/src/main/ext-ipc.ts b/packages/ui-mac/src/main/ext-ipc.ts index 8fbba2da7668..075e97d51c67 100644 --- a/packages/ui-mac/src/main/ext-ipc.ts +++ b/packages/ui-mac/src/main/ext-ipc.ts @@ -3,11 +3,13 @@ // config (ext-config.ts), and a runtime which-check so the UI can warn before adding a local MCP // whose binary (uv/node/…) is missing. All validation lives in ext-config / here — see ADR-014 §8. -import { ipcMain, type IpcMainInvokeEvent } from "electron" +import { BrowserWindow, dialog, ipcMain, type IpcMainInvokeEvent } from "electron" import { execFile } from "node:child_process" import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" +import { extensionsGranted, hasExtensionsDecision, listProjectExecutables, withExtensionsConsent } from "./alpha-ext-trust" +import { readProjectPrefs, writeProjectPrefs } from "./alpha-workdir" import type { InstallMeta, InstallReceipt, InstallTarget } from "../preload/types" import { addReceipt, alphaGlobalRoot, listInstalls, removeReceipt } from "./alpha-installs" import { fileifyMcpSecrets, removeMcpServerSecrets } from "./alpha-mcp-secrets" @@ -237,6 +239,67 @@ export function registerExtIpcHandlers(userDataPath: string) { // REQ-018 T3:存量迁移(旧 XDG 根 → .alpha)。scan 报告 + removeLegacy 删旧位;新位由 renderer // 复用既有 installer 重装(顺带 A2 钉版 + secret file 化)。用户面触发受 ALPHA_MIGRATE_ENABLE 门控 // (A6 真机验证后开,S12 T8)。 + // REQ-060 信任门 UI:renderer 打开项目时调用。项目 `.alpha` 含可执行扩展(mcp/plugins)且无当前 + // 版本决策 → 弹 per-project 原生确认(B16/ADR-021 同款);granted/denied 都写 `.alpha/prefs.json` + // 的 extensionsConsent(@alpha-code/ext 信任门读同一字段)。granted 后由 renderer 调 dispose 免重启 + // 生效(链路已真机证通,audits/2026-07-07-req060-fanout-realmachine)。写盘失败不静默放行(反 placebo): + // 不落决策 + 返回 denied,下次仍会弹。 + ipcMain.handle("ext-trust-check", async (event: IpcMainInvokeEvent, directory: string) => { + if (typeof directory !== "string" || !directory) return { prompted: false, granted: false } + const alphaDir = path.join(directory, ".alpha") + let jsoncText: string | null = null + try { + jsoncText = fs.readFileSync(path.join(alphaDir, "alpha.jsonc"), "utf8") + } catch { + /* 无项目配置 */ + } + let pluginFiles: string[] = [] + try { + pluginFiles = fs.readdirSync(path.join(alphaDir, "plugins")) + } catch { + /* 无 plugins 目录 */ + } + const exec = listProjectExecutables(jsoncText, pluginFiles) + if (exec.mcp.length === 0 && exec.plugins.length === 0) return { prompted: false, granted: false } + const prefs = readProjectPrefs(directory) + if (hasExtensionsDecision(prefs)) return { prompted: false, granted: extensionsGranted(prefs) } + + const items = [ + ...exec.mcp.map((n) => `· 连接器(MCP):${n}`), + ...exec.plugins.map((f) => `· 插件:.alpha/plugins/${f}`), + ].join("\n") + const parent = BrowserWindow.fromWebContents(event.sender) ?? undefined + const opts = { + type: "warning" as const, + title: "项目自带扩展加载确认(首次)", + message: "此项目自带可执行扩展,是否允许加载?", + detail: + `发现以下可执行扩展:\n${items}\n\n` + + "允许加载 = 在本机运行该项目提供的程序代码。若这不是你信任的项目,请选择「仅文本扩展」——\n" + + "技能 / Agent / 命令等文本类扩展不受影响,仍正常生效。\n\n" + + "本决定按项目记录一次(存于本项目 .alpha/prefs.json),之后不再重复询问。", + buttons: ["允许加载", "仅文本扩展(不加载)"], + defaultId: 1, + cancelId: 1, + checkboxLabel: "我了解这会在本机运行该项目提供的代码", + checkboxChecked: false, + } + const res = parent ? await dialog.showMessageBox(parent, opts) : await dialog.showMessageBox(opts) + const granted = res.response === 0 && res.checkboxChecked + // 「允许」但未勾知情确认 = 未决(不落盘,下次再弹)——落 denied 会把手滑变成永久拒绝。 + if (res.response === 0 && !res.checkboxChecked) { + getLogger().log(`[req060-trust] allow clicked without acknowledgement — treated as undecided: ${directory}`) + return { prompted: true, granted: false } + } + const written = writeProjectPrefs(directory, withExtensionsConsent(readProjectPrefs(directory), granted, new Date().toISOString())) + if (!written.ok) { + getLogger().error(`[req060-trust] failed to persist decision: ${written.reason}`) + return { prompted: true, granted: false, persistError: written.reason } + } + getLogger().log(`[req060-trust] ${granted ? "granted" : "denied"} for project: ${directory}`) + return { prompted: true, granted } + }) + ipcMain.handle("ext-migrate-scan", () => ({ enabled: isMigrationEnabled(), inventory: scanLegacy() })) // REQ-044:名字匹配只定位候选;这里做 provenance 终审(打包资产逐字节 / catalog 形状)—— // 只放行 alpha 自装,同名用户自建被排除并留痕(fail-closed,ADR-019 §4)。 diff --git a/packages/ui-mac/src/preload/index.ts b/packages/ui-mac/src/preload/index.ts index f9b94511b593..bb12e555a3e7 100644 --- a/packages/ui-mac/src/preload/index.ts +++ b/packages/ui-mac/src/preload/index.ts @@ -176,6 +176,7 @@ const api: ElectronAPI = { migrateVerify: (requests) => ipcRenderer.invoke("ext-migrate-verify", requests), removeLegacy: (type, name) => ipcRenderer.invoke("ext-migrate-remove-legacy", type, name), enableCloud: (id, name, meta) => ipcRenderer.invoke("ext-enable-cloud", id, name, meta), + trustCheck: (directory) => ipcRenderer.invoke("ext-trust-check", directory), }, account: { summary: () => ipcRenderer.invoke("account-summary"), diff --git a/packages/ui-mac/src/preload/types.ts b/packages/ui-mac/src/preload/types.ts index e0d9d0126369..e45ce975a92b 100644 --- a/packages/ui-mac/src/preload/types.ts +++ b/packages/ui-mac/src/preload/types.ts @@ -370,6 +370,8 @@ export type ElectronAPI = { // REQ-020 T4:启用云 pipeline = receipts-only(进本机可用列表,不落文件、不写引擎 config); // 停用走 uninstall(type:"cloud" → 去账)。 enableCloud: (id: string, name: string, meta?: InstallMeta) => Promise<{ ok: true; warning?: string } | { ok: false; reason: string }> + /** REQ-060 信任门:项目含可执行扩展且未决策 → main 弹 per-project 确认;granted 后调用方 dispose 生效。 */ + trustCheck: (directory: string) => Promise<{ prompted: boolean; granted: boolean; persistError?: string }> } // alpha account (balance / membership / usage) read from the alpha-platform (B) account-server // using the main-held JWT. The renderer gets only the resolved summary, never the token. diff --git a/packages/ui-mac/src/renderer/alpha-ui/ext-trust-watcher.tsx b/packages/ui-mac/src/renderer/alpha-ui/ext-trust-watcher.tsx new file mode 100644 index 000000000000..2a9146568ac8 --- /dev/null +++ b/packages/ui-mac/src/renderer/alpha-ui/ext-trust-watcher.tsx @@ -0,0 +1,65 @@ +// ExtTrustWatcher — REQ-060 信任门 UI 驱动端(headless AppInterface child,仿 CloudRunWatcher)。 +// +// 时机:进入某项目的会话路由(/:b64dir/session/:id)首次见到该 directory → main `ext-trust-check`: +// 项目 `.alpha` 含可执行扩展(mcp / plugins)且未决策 → main 弹 per-project 原生确认(B16 模式)→ +// 决策写 `.alpha/prefs.json`;granted → 本组件调 `POST /global/dispose` 使引擎实例重建, +// @alpha-code/ext 信任门重读 consent,项目扩展当前会话下一条消息即生效(免重启, +// 链路真机证据 audits/2026-07-07-req060-fanout-realmachine)。 +// +// 去重:per-renderer 生命周期一个 directory 只查一次(main 侧有决策落盘,重复查也幂等;去重只省 IPC)。 + +import { createEffect, type Accessor } from "solid-js" +import { useLocation } from "@solidjs/router" +// CLIENT subpath only — the v2 barrel pulls Node-only deps that break the renderer (see ADR-008). +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { base64UrlDecode } from "../sidebar/route" +import type { ServerInfo } from "../sidebar/use-projects" +import { pushToast } from "./Toast" +import { t } from "../i18n" + +function authHeaders(info: ServerInfo): Record | undefined { + if (!info.username && !info.password) return undefined + return { Authorization: `Basic ${btoa(`${info.username ?? ""}:${info.password ?? ""}`)}` } +} + +function routeDirectory(pathname: string): string | null { + const m = pathname.match(/^\/([^/]+)\/session\//) + if (!m) return null + try { + return base64UrlDecode(m[1]) + } catch { + return null + } +} + +export function ExtTrustWatcher(props: { server: Accessor }) { + const loc = useLocation() + const checked = new Set() + + createEffect(() => { + const dir = routeDirectory(loc.pathname) + const info = props.server() + if (!dir || !info || checked.has(dir)) return + checked.add(dir) + void (async () => { + try { + const r = await window.api.ext.trustCheck(dir) + if (r.persistError) { + pushToast({ kind: "error", title: t("alpha.ext.trustPersistFailed"), detail: r.persistError }) + checked.delete(dir) // 未留痕 = 未决,允许本会话稍后重试 + return + } + if (r.prompted && r.granted) { + // dispose → 实例惰性重建 → ext 信任门重读 consent → 项目扩展下一条消息可用 + const c = createOpencodeClient({ baseUrl: info.baseUrl, headers: authHeaders(info) }) + await (c as unknown as { global: { dispose(): Promise } }).global.dispose().catch(() => {}) + pushToast({ kind: "success", title: t("alpha.ext.trustGranted") }) + } + } catch { + checked.delete(dir) // IPC 瞬时失败允许重查 + } + })() + }) + + return null +} diff --git a/packages/ui-mac/src/renderer/i18n/en.ts b/packages/ui-mac/src/renderer/i18n/en.ts index 03a1ccc4dbe2..ed2661bf0038 100644 --- a/packages/ui-mac/src/renderer/i18n/en.ts +++ b/packages/ui-mac/src/renderer/i18n/en.ts @@ -311,6 +311,8 @@ export const dict = { "alpha.cloud.runSaved": "Cloud run saved to .alpha/runs", "alpha.cloud.runEnded": "Cloud run ended (not successful); record saved", "alpha.cloud.runSaveFailed": "Failed to save cloud run", + "alpha.ext.trustGranted": "Project extensions enabled — available from the next message", + "alpha.ext.trustPersistFailed": "Failed to save the project-extension decision", "alpha.ext.loadFailed": "Installed list failed to load", "alpha.ext.loadFailedDetail": "Engine connection issue; browsing the catalog still works", "alpha.ext.retry": "Retry", diff --git a/packages/ui-mac/src/renderer/i18n/zh.ts b/packages/ui-mac/src/renderer/i18n/zh.ts index 54b95ff3489e..86f48b169d5d 100644 --- a/packages/ui-mac/src/renderer/i18n/zh.ts +++ b/packages/ui-mac/src/renderer/i18n/zh.ts @@ -310,6 +310,8 @@ export const dict = { "alpha.cloud.runSaved": "云任务结果已回流到 .alpha/runs", "alpha.cloud.runEnded": "云任务已结束(未成功),记录已保存", "alpha.cloud.runSaveFailed": "云任务结果回流失败", + "alpha.ext.trustGranted": "项目扩展已启用——下一条消息起可用", + "alpha.ext.trustPersistFailed": "项目扩展决策保存失败", "alpha.ext.loadFailed": "已安装列表加载失败", "alpha.ext.loadFailedDetail": "引擎连接异常,浏览目录不受影响", "alpha.ext.retry": "重试", diff --git a/packages/ui-mac/src/renderer/index.tsx b/packages/ui-mac/src/renderer/index.tsx index b75aa474bc09..ff4443f78044 100644 --- a/packages/ui-mac/src/renderer/index.tsx +++ b/packages/ui-mac/src/renderer/index.tsx @@ -39,6 +39,7 @@ import { ComposerTakeover } from "./alpha-ui/composer-takeover" import { ModelPickerInject } from "./alpha-ui/model-picker-inject" import { TimelineInject } from "./alpha-ui/timeline-inject" import { CloudRunWatcher } from "./alpha-ui/cloud-run-watcher" +import { ExtTrustWatcher } from "./alpha-ui/ext-trust-watcher" import { AlphaSidebar } from "./sidebar/alpha-sidebar" import { useAlphaProjects } from "./sidebar/use-projects" import { AlphaHome } from "./alpha-ui/AlphaHome" @@ -454,6 +455,10 @@ render(() => { + {/* REQ-060 信任门:进入项目会话时检测项目自带可执行扩展 → per-project consent → dispose 生效 */} + + +