diff --git a/AGENTS.md b/AGENTS.md index 6184e3d..8def26d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,15 +22,15 @@ - 该目录从明确 tag 导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留上游 `.pi` 状态;源码、测试、wrapper 与权限集成实现继续保留,但当前不属于根组合包运行时依赖,`extensions/subagents.ts` 也不在默认扩展加载列表中。 - `extensions/fff-override.ts`:强制使用 FFF 官方 `override` 模式,统一接管 `find`、`grep`、`multi_grep` 和 FFF 的 `@` 补全;显式 CLI flag 仍遵循 FFF 官方优先级。 - `pi-web-search/`:完全自维护的统一在线搜索扩展。对模型只暴露 `web_search` / `web_fetch`,schema 与提示词不含厂商名;`web_search` 由当前主模型(`ctx.model` + `ctx.modelRegistry.complete`)按内置路由规则在 Tavily / Exa / Keenable 间选择并经统一参数翻译调用,`details` 记录实际 provider 与路由方式,决策按会话内 LRU 缓存,路由失败或被选厂商报错按可用性顺序降级;`web_fetch` 固定走 Exa `web_fetch_exa`,失败时静默降级 Keenable → Tavily。`clients/` 为纯 `fetch` 直连:Tavily 走 `api.tavily.com`(必须有 key),Keenable 复刻上游 client 保留 keyless `/public` 端点与 SSRF 防护,Exa 直接对 `mcp.exa.ai/mcp` 发 JSON-RPC(缓存 `mcp-session-id`,404 自动重建会话,key 只走 `x-api-key` 头),不使用 `pi-mcp-adapter` 或任何上游扩展包。 -- `search_config.sh` 与 `scripts/search-auth.mjs`:以交互或参数方式把 `tavily` / `exa` / `keenable` 三条 `api_key` 凭据写入 Pi 的 `auth.json`(`$PI_CODING_AGENT_DIR/auth.json`,默认 `~/.pi/agent/auth.json`);加锁后原子写、权限 `600`、保留全部其他条目,支持 `--migrate` 从旧 `search.env` 导入。`pi-web-search/auth.ts` 每次调用读取并复刻上游解析语义(`!command` 执行、`$VAR`/`${VAR}` 插值、credential `env` 优先于进程 env、`$$`/`$!` 转义);显式进程环境变量不再直接读取,env 注入通过 `"key": "$VAR"` 表达。 +- `configure.sh search` 与 `scripts/search-auth.mjs`:以交互或参数方式把 `tavily` / `exa` / `keenable` 三条 `api_key` 凭据写入 Pi 的 `auth.json`(`$PI_CODING_AGENT_DIR/auth.json`,默认 `~/.pi/agent/auth.json`);加锁后原子写、权限 `600`、保留全部其他条目,支持 `--migrate` 从旧 `search.env` 导入。`pi-web-search/auth.ts` 每次调用读取并复刻上游解析语义(`!command` 执行、`$VAR`/`${VAR}` 插值、credential `env` 优先于进程 env、`$$`/`$!` 转义);显式进程环境变量不再直接读取,env 注入通过 `"key": "$VAR"` 表达。 - `extensions/codegraph.ts`:注册原生 `codegraph_explore` 工具,以 `execFile` argv 形式调用机器级 `codegraph` CLI(每次调用为短命进程,无常驻 server/daemon);未初始化项目先经 `ctx.ui.confirm` 确认再 `codegraph init` 并自动重试,拒绝或失败则把结果打回让模型改用 `find`/`grep`/`read`/LSP;结果按 `codegraph.db` mtime 做有界缓存,索引一变即失效。 - `extensions/plugin-init.ts`:已移除;原 `/plugin_init` 的 CodeGraph 初始化职责由 `extensions/codegraph.ts` 在首次 `codegraph_explore` 调用内按需确认完成。 - `context-mode`:加载上游 Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性。 - `extensions/hashline.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`,以行哈希锚点覆盖内置 `read` / `edit`;Hashline 自带 `grep` 强制关闭。 -- `extensions/permission-system.ts`:直接加载仓库内 `pi-permission-system/src/index.ts`,自身不部署、不播种、不迁移任何配置。`config/pi-permission-policy.jsonc` / `config/pi-permission-hard-guards.jsonc` 由 `install.sh` / `update.sh` 经 `scripts/rebuild-bundle-config.mjs` 部署到 `/my-pi/permission-system/`(policy.jsonc 与 hard-guards.jsonc 且权限 owner-only);旧 `extensions/pi-permission-system/` 位置的残留文件被忽略,不迁移也不清理。项目级 policy 不读取。 +- `extensions/permission-system.ts`:直接加载仓库内 `pi-permission-system/src/index.ts`,自身不部署、不播种、不迁移任何配置。扩展运行时直接读取已安装组合包内的 `config/pi-permission-policy.jsonc` / `config/pi-permission-hard-guards.jsonc`;日志保留在 `/my-pi/permission-system/logs/`。旧 `extensions/pi-permission-system/` 位置的残留文件被忽略,不迁移也不清理。项目级 policy 不读取。 - `extensions/lsp.ts`:把组合包内 TypeScript Language Server CLI 物化为绝对命令路径,部署 TypeScript/Kotlin/JDT LS 全局配置并加载 `pi-lsp`。 - `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,在每轮开始前按激活工具追加简短路由规则,并提供 `/dump-system-prompt` 将扩展所见的有效提示词写入 `.pi-debug/effective-system-prompt.md`。 -- `extensions/system-prompt-cover.ts`:把 Pi 默认系统提示词的开头替换为组合包权威 `config/system-prompt-cover.md`(部署到 `~/.pi/agent/my-pi/system-prompt-cover.md`),并用文本拼接保留其余全部分区及其原始顺序。必须注册在 `tool-routing.ts` **之前**:`tool-routing` 会以 `forceSystemPrompt` 压平提示词,在其后改 `systemPromptOptions` 将不再影响本轮请求。封面文件缺失或空白时不干预。`install.sh` / `update.sh` 经 `scripts/rebuild-bundle-config.mjs` 与全部组合包配置一起整体重建该文件,不保留备份。 +- `extensions/system-prompt-cover.ts`:把 Pi 默认系统提示词的开头替换为组合包权威 `config/system-prompt-cover.md`(扩展运行时直接读取已安装组合包内文件,不部署到 agent 目录),并用文本拼接保留其余全部分区及其原始顺序。必须注册在 `tool-routing.ts` **之前**:`tool-routing` 会以 `forceSystemPrompt` 压平提示词,在其后改 `systemPromptOptions` 将不再影响本轮请求。封面文件缺失或空白时不干预。 - `pi-chrome/`:从上游源码导入并由本仓库直接维护,通过用户安装的 unpacked MV3 扩展与 `127.0.0.1:17318` loopback bridge 接管当前已登录 Chrome;控制默认锁定,每个 Pi session 必须显式 `/chrome authorize`。 - 上游来源: - 初始导入快照:`017ff4b9a639f0b8b213e58a3f30613fc38edcc8`(`0.15.46`,该 commit 无对应 tag)。 @@ -45,7 +45,7 @@ - 该目录由本仓库直接维护,保留上游 MIT 许可证与来源说明;根组合包直接加载源码并通过本地 `file:` 依赖打包。 - `pi-worktree/`:完全自维护(无上游)的斜杠命令 git worktree 管理扩展,提供 `/wt`(别名 `/worktree`)的 `new` / `merge` / `switch` / `list` / `remove` / `prune` 子命令,实现「一键拆分」与「一键合并」;不注册模型可调用工具。 - 状态登记位于 `/pi-worktree/state.json`(目录 `700`、文件 `600`、原子写入),不放 `/my-pi/` 下;Pi 不能修改进程 cwd,会话切换通过 `SessionManager.forkFrom`(或为空会话写入最小 header)生成 cwd 指向目标目录的新会话文件再 `ctx.switchSession` 重建运行时,切换后的收尾只能在新 `ctx` 的 `withSession` 回调中执行。 -- 合并流程固定为先确认、再 rebase 到来源分支、最后默认 `--ff-only` 合并(`--squash` 可选压缩),冲突时可选择中止或保留后用 `/wt merge --continue` / `--abort` 处理,来源改动用 stash 兜底,永远不会 push;登记文件写入走 `state.json.lock` 锁文件串行化;worktree 根目录与分支前缀由 `settings.json.piWorktree` 的 `root` / `branchPrefix` 配置。 +- 合并流程固定为先确认、再 rebase 到来源分支、最后默认 `--ff-only` 合并(`--squash` 可选压缩),冲突时可选择中止或保留后用 `/wt merge --continue` / `--abort` 处理,来源改动用 stash 兜底,永远不会 push;登记文件写入走 `state.json.lock` 锁文件串行化;worktree 根目录与分支前缀由组合包 `config/pi-worktree.json` 的 `root` / `branchPrefix` 配置。 - 来源 checkout 的 `.pi/worktree.json`(`version: 1`)声明环境复刻:`copy` 只复制未跟踪文件、`link` 只软链 gitignore 目标、`env` 渲染为 `600` 权限环境文件、`postCreate` 仅在 `ctx.isProjectTrusted()` 时以 argv 形式 `execFile`(绝不走 shell),任一失败即停并可用 `/wt reopen` 增量重跑;未被 `.gitignore` 覆盖的软链/环境文件写入 `.git/info/exclude`(仓库各 worktree 共享),保证 `/wt merge` 不会提交它们。 - `pi-lsp@0.1.7`:提供声明式 LSP 接入;组合包内置 `typescript-language-server@5.3.0` + `typescript@6.0.3`,并配置机器级 `kotlin-lsp --stdio` 与 `jdtls`。 - `pi-hermes-memory/`:从官方 `pi-hermes-memory` 仓库导入并由本仓库直接维护的 Hermes 风格持久记忆扩展;提供全局/项目/用户/失败四类记忆、SQLite FTS5 trigram 会话与记忆检索、`SKILL.md` 过程记忆、correction 检测、自动 consolidation、`STANDING.md` 常驻指令和 6 个记忆工具。 @@ -63,10 +63,10 @@ - `pi-compaction/`:统一管理增量 Session segment、确定性工具结果压缩、后台语义摘要、effective token 口径、`context_tree_query`、portable checkpoint 与 Codex native compaction;普通 Provider、native compaction、footer 和本地 context observer 必须消费同一个不可变 projection snapshot。 - 上游参考一:,快照 `59c95110aacd8131db71e21296765dd80b72fae8`(`v2.9.1`)。 - 上游来源二:,导入快照 `c4d6ca8fb484ba51684296ba8b4715f8c7b3ac91`(`0.1.4`)。 -- `pi-compaction` 采用全新的 `pi-compaction` version 1 Session 记录和 `settings.json.piCompaction` 配置,不读取或迁移旧 `context-prune-*`、`contextPrune` 或 `openai-codex-native-compaction` 状态;切换后应使用新 Session。两份上游 MIT 许可证及来源差异保留在包内。 +- `pi-compaction` 采用全新的 `pi-compaction` version 1 Session 记录和组合包 `config/pi-compaction.json` 配置,不读取或迁移旧 `context-prune-*`、`contextPrune` 或 `openai-codex-native-compaction` 状态;切换后应使用新 Session。两份上游 MIT 许可证及来源差异保留在包内。 - 根包还固定安装 `pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0` 和 `typescript@6.0.3`;`@gotgenes/pi-permission-system`、`@smoose/pi-notify`、`pi-ask-user`、`pi-chrome`、`pi-compaction`、`pi-hermes-memory`、`pi-minimal-footer`、`pi-ssh`、`pi-tool-search`、`pi-web-search` 与 `pi-worktree` 分别指向仓库内对应目录的本地 `file:` 依赖;`pi-subagents/` 仅保留源码,不作为根运行时依赖。 -- `install.sh`:先安装根组合包,再通过 `scripts/rebuild-bundle-config.mjs` 整体重建 `/my-pi/` 受管配置目录,然后交互检查 Kitty/Solarized Dark、Oh My Zsh/Powerlevel10k/Zsh 插件、CodeGraph、Kotlin LSP、Java 21+ 和 JDT LS;缺失项目只在用户明确选择 `Y` 后安装或配置。Powerlevel10k 默认配置来自仓库内置的 `config/p10k.zsh`(当前 Rainbow/ASCII 单行紧凑主题)。 -- `update.sh`:先通过 `pi update` 升级根组合包,再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。不得在 `pi update` 前预清理安装缓存;`pi update` 失败时 fail-fast 停止——Pi 1.3.0 会在下一次 `pi update` 时依据其 `.pi-update-incomplete` 标记自行重跑 `git clean -fdx` 与依赖安装完成自愈,本脚本不做人工恢复。升级成功后调用可定位的更新后组合包 Node helper `scripts/rebuild-bundle-config.mjs` 整体重建 `/my-pi/`:先按 `config/bundle-manifest.json` 校验全部来源与目标,在 agent 目录内构建临时树,再删除旧 `my-pi/`(符号链接只删链接不跟随)并原子替换,不备份、不迁移、不合并不属于组合包的内容。无法从 `PI_PACKAGE_SOURCE` 定位更新后 helper 时必须报告失败,不能回退到可能陈旧的脚本目录 helper。其他组件升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。 +- `install.sh`:先安装根组合包(组合包配置即随包内的 `config/` 就位,不做额外部署),然后交互检查 Kitty/Solarized Dark、Oh My Zsh/Powerlevel10k/Zsh 插件、CodeGraph、Kotlin LSP、Java 21+ 和 JDT LS;缺失项目只在用户明确选择 `Y` 后安装或配置。Powerlevel10k 默认配置来自仓库内置的 `config/p10k.zsh`(当前 Rainbow/ASCII 单行紧凑主题)。 +- `update.sh`:先通过 `pi update` 升级根组合包,成功升级后把默认 git 来源的已安装组合包 checkout `git reset --hard` 还原到已发布版本(本地路径来源不 reset,不做 `git clean` 以保留 `node_modules`),再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。不得在 `pi update` 前预清理安装缓存;`pi update` 失败时 fail-fast 停止——Pi 1.3.0 会在下一次 `pi update` 时依据其 `.pi-update-incomplete` 标记自行重跑 `git clean -fdx` 与依赖安装完成自愈,本脚本不做人工恢复。其他组件升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。 - `uninstall.sh`:先移除根组合包,其他共享终端环境和机器工具不卸载。不得删除 `.pi/agent/pi-hermes-memory/`、`projects-memory/`、`.hippo/` 或任何用户记忆数据。 ## 当前职责与默认行为 @@ -81,7 +81,7 @@ - Hashline 默认以 2 字符行哈希覆盖内置 `read` / `edit`,`grep` 和 `replaceText` 均关闭;它保证锚定编辑而不负责压缩,大文件上下文节省必须来自先定位/隔离分析、再做最小范围 `offset/limit` 读取。 - 工具路由规则不得替换 Pi 默认系统提示词:代码结构、调用关系和待修改 symbol 优先 CodeGraph,定义/引用/类型/诊断优先 LSP;工作区内大文件探索、分析和总结优先 `ctx_execute_file`,日志、构建与不可预测命令输出优先 Context Mode;字面搜索先用 FFF `find` 收敛文件位置,再在收敛路径内用 `grep`/`multi_grep` 获取行号;真正修改前才用小范围 Hashline `read` 获取新鲜锚点并用锚定 `edit`。在线搜索由 `pi-web-search` 内部路由,模型不感知厂商:路由规则为广泛发现、新闻和候选来源偏 Tavily,官方/技术/论文和高确定性来源偏 Exa,中文、站点限定和日期筛选偏 Keenable;路由决策由当前主模型做出,确定性逻辑只处理可用性与失败兜底,不得改写成启发式断路。遇到大量结果、截断或上限时继续缩小范围,不得靠提高 limit 或倾倒全部结果解决。 - `ask_user_question` 只用于模型继续任务所必需的用户决定、偏好、确认或澄清;不得重复询问直接用户消息中已有的信息。工具支持交互式 TUI 与具备 Pi extension UI bridge 的 RPC 客户端、顺序执行和最多 8 题;RPC 必须使用标准 `select` / `input` 对话框逐题收集,JSON/print 等无 UI 模式保持拒绝。取消或 abort 必须返回 `cancelled: true`,不得伪造答案,且 `/qna` 不属于该扩展。其成功 `details.answers` 必须保留 `question` / `answer` 字段,以匹配 AutoReview 已有的可信结构化用户交互边界。 -- Tool Search 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、`ctx_execute` / `ctx_execute_file` / `ctx_batch_execute`、六个记忆工具与 `context_tree_query`,以及 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。配置从 `/my-pi/tool-search/config.json` 读取(由 rebuild 脚本部署 `config/tool-search.json`),文件缺失或无效时回退 `BUNDLE_TOOL_SEARCH_DEFAULTS`;扩展自身不播种、不迁移,也不再读写 `settings.json` 的 `toolSearch` 段。其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。 +- Tool Search 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、`ctx_execute` / `ctx_execute_file` / `ctx_batch_execute`、六个记忆工具与 `context_tree_query`,以及 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。配置从已安装组合包内的 `config/tool-search.json` 直接读取,文件缺失或无效时回退 `BUNDLE_TOOL_SEARCH_DEFAULTS`;扩展自身不播种、不迁移,也不再读写 `settings.json` 的 `toolSearch` 段。其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。 - `pi-chrome` 在未授权时不注册 Chrome 工具;显式授权后出现的 21 个 `chrome_*` 工具必须由 Tool Search 分入 `chrome-navigation`、`chrome-interaction`、`chrome-debugging`,不得落入 unknown-tools 模型增强路径。授权撤销或过期时必须停用全部 21 个工具,包括 `chrome_find` / `chrome_inspect`;Tool Search 重新激活 schema 不能替代每次执行时的授权校验。 - 标准组合包工具全部命中预置目录时 Tool Search 不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。 - Tool Search 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。Tool Search 是唯一可调用 `getActiveTools()` / `setActiveTools()` 管理工具显隐的组合包扩展;`pi-permission-system` 只能在调用时裁决,不能读写 active tool 列表。 @@ -91,20 +91,21 @@ - `codegraph_explore` 为原生 Pi 工具而非 MCP 工具:每次调用通过 `execFile`/`pi.exec` 以 argv 形式运行 `codegraph explore`(带超时、不经 shell),结果按 `.codegraph/codegraph.db` mtime 缓存;CLI 缺失、init 被拒绝或执行失败都不得抛出或阻止其他工具,必须以文本结果打回并引导模型改用 `find`/`grep`/`read`/LSP。 - 组合包不加载任何 MCP adapter;Exa 由 `pi-web-search/clients/exa.ts` 以纯 `fetch` JSON-RPC 直连托管端点 `https://mcp.exa.ai/mcp`:`initialize` 一次并缓存 `mcp-session-id`,随后 `tools/call`,HTTP 404 视为会话失效自动重建并重试一次。`exa` 凭据通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。 - 在线搜索对模型只暴露 `web_search` / `web_fetch` 两个统一工具名,不得重新引入按厂商命名的工具;`web_search` 必须经 `router.ts` 的主模型分类选择 provider,`web_fetch` 固定优先 Exa。Exa 搜索统一走 `web_search_advanced_exa` 并显式约束 `numResults` 与 `textMaxCharacters`;Tavily 默认 `search_depth: basic` 且必须在 auth.json 存在 `tavily` api_key 凭据才参与路由;Keenable keyless 可用,`keenable` 凭据仅提高速率限制。 -- 搜索 key 统一保存在 Pi 的 `auth.json`(`tavily`/`exa`/`keenable` 各一条 `api_key` 凭据,文件权限 `600` 且不得提交);key 支持 `!command` 与 `$VAR` 间接寻址,`search_config.sh` / `search-auth.mjs` 只输出配置状态,禁止回显 key。旧的 `search.env` 不再被读取,仅由 `--migrate` 一次性导入。 +- 搜索 key 统一保存在 Pi 的 `auth.json`(`tavily`/`exa`/`keenable` 各一条 `api_key` 凭据,文件权限 `600` 且不得提交);key 支持 `!command` 与 `$VAR` 间接寻址,`configure.sh search` / `search-auth.mjs` 只输出配置状态,禁止回显 key。旧的 `search.env` 不再被读取,仅由 `--migrate` 一次性导入。 - `extensions/context-view.ts` 是只读的本地 effective-context observer,只读取 `pi-compaction/src/service.ts` 发布的 projection metrics,不注册生产 `context` writer;`pi-minimal-footer` 优先显示同一 effective usage,snapshot 尚未建立时才回退宿主原始 usage。 - `pi-compaction` 负责已经进入 Session 的历史投影,与 Context Mode 的输入隔离职责互补。首次 Session hydration 允许一次完整扫描;后续普通 `context` hot path 必须复用不可变 snapshot 或只处理 delta,不得调用 summarizer、写 Session 或重新构建完整 branch。 - 已闭合工具结果先同步生成保留工具名、参数、状态、首尾证据和 source key 的有界确定性 stub;大于 `semantic.minInputChars` 的结果再进入单 Session 有界后台语义 worker。后台结果只在安全事件边界提交,陈旧 hash 必须丢弃,Provider 请求不得等待后台摘要。 - effective token 默认使用 Provider usage-backed baseline 加 projected delta;无可靠 baseline 时使用带上下界的估算。Pi Core raw threshold 只负责唤醒,只有 effective lower bound 达到 `hardRatio` 才执行 threshold compaction;manual 与 overflow 不得被阻断。 -- Codex native compaction 必须直接消费当前 projection 生成的 ResponseItems,不得从 raw branch 重建输入;native checkpoint 同时保存有界 portable summary 供模型或 Provider 切换。配置只从 `settings.json.piCompaction` 读取且不写回,默认 soft/hard/target 比例为 72%/90%/55%。 +- Codex native compaction 必须直接消费当前 projection 生成的 ResponseItems,不得从 raw branch 重建输入;native checkpoint 同时保存有界 portable summary 供模型或 Provider 切换。配置只从组合包 `config/pi-compaction.json` 读取且不写回 `settings.json`,默认 soft/hard/target 比例为 72%/90%/55%。 - Kitty 完成通知只在 TUI 模式的 `agent_settled` 后发送;自动重试、自动 compaction 和 follow-up 期间不得提前通知或重置总耗时。权限通知只订阅 `permissions:ui_prompt`,在人类确认界面即将出现时立即发送,不得为策略或 AutoReview 已直接决定的请求通知,也不受完成通知时长门槛影响。默认 `o=always`、`a=focus`,每个 Pi Session 使用独立稳定 ID,标题和正文必须 Base64 编码。 - `/notify on|off` 只覆盖当前 Session;持久默认来自 `PI_NOTIFY_*` 环境变量。`PI_NOTIFY_MESSAGE_SOURCE=none` 必须继续提供不泄露回复正文、权限值或请求者细节的隐私模式,非 TUI 模式不得写入 OSC 序列。 -- 权限策略默认允许常规工具,允许 FFF 工具;拒绝 Bash 直搜,硬防御默认只保留确定性的破坏性 Shell 命令;Git 非只读操作、包管理、文件/系统/网络高风险操作与内置 MCP 的 `mcp____` 调用按唯一全局 policy 判定。项目外路径不再产生独立 `external_directory` 判断,只走配置中的工具、`path` 与 Bash pattern。 +- 权限策略默认允许常规工具,允许 FFF 工具,并放行 `ls`、LSP 查询(`lsp_definition`/`lsp_references`/`lsp_hover`/`lsp_symbols`)、`ctx_search`/`ctx_index`/`ctx_stats`/`ctx_doctor`、`web_search` 与 Chrome 只读检查工具;`web_fetch`、`ctx_fetch_and_index` 与 `skill_manage` 保持 `ask`(模型自选的 URL 可能外传数据,skill_manage 写入的 skill 会进入所有后续会话);拒绝 Bash 直搜,硬防御默认只保留确定性的破坏性 Shell 命令;Git 只读管道命令(`blame`/`describe`/`rev-list`/`merge-base`/`ls-tree`/`cat-file`/`name-rev`/`shortlog`/`stash list`/`worktree list`/`tag -l|--list`/`config --get|--list`)放行,其余非只读操作、包管理、文件/系统/网络高风险操作与内置 MCP 的 `mcp____` 调用按唯一全局 policy 判定。项目外路径不再产生独立 `external_directory` 判断,只走配置中的工具、`path` 与 Bash pattern。 - SSH 权限沿用同一条 `pi-permission-system` gate 与 `auto-review` authorizer chain:`ssh_connect`、`ssh_cd`、`ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 默认 `ask`;连接前的证据解析已导入 host ID 为非秘密 target、port 与请求/default cwd,AutoReview 只可依据用户直接消息中的明确目标授权连接。`ssh_bash` 通过 `shellTools` 映射到完整 Bash 策略并设置 `decisionFloor: "ask"`,把普通 Bash `allow` 提升为复核请求,同时保留原有 `ask` 与硬 `deny`。全部远端路径不得送入基于本机 cwd 的默认 `input.path` 归一化。 - `auto-review` 是 `pi-permission-system` 内置 authorizer,不是独立扩展或第二个 `tool_call` gate;只复核普通策略产生的 `ask`,不会重复处理普通策略已 `allow` / `deny` 或硬防御已 `deny` 的请求。 -- reviewer 是对话式 Codex Guardian 风格审查:以独立请求(不写入主会话)把有界 active-branch 对话记录与权限请求交给模型,模型只能通过 `approve` / `reject` 两个工具给出带 `reason` 的结论,不强制 `toolChoice`。`approve` 自动批准(包括 `path`);`reject` 以及配置、模型、超时、Provider 错误、截断、未调用工具、多次调用或参数无效等一切异常都必须 `defer` 到人工提示,并在弹窗中显示原因(模型理由标注为模型生成)。正文文本即使是合法 JSON 也不得作为结论;只有用户按 Esc 中止当前操作时才直接拒绝。不得在 authorizer chain 外增加第二层内置授权范围限制,也不得恢复 annotator 机制。 +- reviewer 是对话式 Codex Guardian 风格审查:以独立请求(不写入主会话)把有界 active-branch 对话记录与权限请求交给模型,模型只能通过 `approve` / `reject` 两个工具给出带 `reason` 的结论,不强制 `toolChoice`。`approve` 自动批准(包括 `path`);`reject` 以及配置、模型、超时、Provider 错误、截断、未调用工具、多次调用或参数无效等一切异常都必须 `defer` 到人工提示,并在弹窗中显示原因(模型理由标注为模型生成)。正文文本即使是合法 JSON 也不得作为结论;只有用户按 Esc 中止当前操作时才直接拒绝。不得在 authorizer chain 外增加第二层内置授权范围限制,也不得恢复 annotator 机制。唯一例外是 `protectedWrites`:硬防御文件里配置的确定性 human-only 写路径——它不经 authorizer chain(含 AutoReview)、yolo 或 Session grant,无确认界面时直接拒绝,本身不产生裁决,只是永远把人拉进回路。 - reviewer 默认跟随会话当前主模型(每次审查实时读取 `ctx.model`),经 `modelRegistry.streamSimple(...).result()` 调用;`autoReview.provider` / `autoReview.model` 必须同时设置(固定审查模型)或同时留空,只设其一视为配置错误并 `defer`。默认 `reasoning: "low"`(仅对支持推理的模型生效)、总超时 30 秒、最多 2 次尝试(仅 Provider 错误重试)、`maxOutputTokens` 4000。只把 active branch 中的直接用户消息与已识别、未取消的结构化问答作为授权证据,assistant/tool/compaction 内容不能自行授权;工具结果可替换为更短的 pi-compaction 摘要,但仍属不可信证据。同一条用户消息内完全相同的请求只缓存 `approve` 结论。 -- `config/pi-permission-policy.jsonc` 必须显式配置 `authorizerChain: ["auto-review"]`,普通未匹配请求和需要自动复核的 Git 非只读、包管理等类别声明为 `ask`;普通 pattern 必须按具体度优先(精确匹配、字面字符更多、通配符更少、pattern 更长,最后才以声明顺序破同级);`autoReview` 段只保留 `enabled`,不设 provider/model 种子值,使审查默认跟随主模型;`config/pi-permission-hard-guards.jsonc` 独立保存可由用户调整但不能被普通策略、Session grant、per-agent 规则或 reviewer 覆盖的高危 `deny`。 +- `config/pi-permission-policy.jsonc` 必须显式配置 `authorizerChain: ["auto-review"]`,普通未匹配请求和需要自动复核的 Git 非只读、包管理等类别声明为 `ask`;普通 pattern 必须按具体度优先(精确匹配、字面字符更多、通配符更少、pattern 更长,最后才以声明顺序破同级);`autoReview` 段只保留 `enabled`,不设 provider/model 种子值,使审查默认跟随主模型;`config/pi-permission-hard-guards.jsonc` 独立保存可由用户调整但不能被普通策略、Session grant、per-agent 规则或 reviewer 覆盖的高危 `deny`,以及 `protectedWrites` 人工写保护清单。 +- `protectedWrites`(`config/pi-permission-hard-guards.jsonc`)是确定性的 human-only 写路径,不属于普通策略也不是第二个授权范围:命中目标的 `write`/`edit` 与本地 `bash` 写入(redirect-out、写操作程序参数、`bash -c`/`eval` 等包装载荷中的受保护路径)一律弹出人工确认,跳过 AutoReview、yolo 与既有 Session grant,弹窗不提供「本会话允许」选项;没有可用确认界面(无 UI、子代理转发终端)时直接拒绝。读取不受影响;`ssh_bash` 等远端 shell 工具不参与本机路径保护。 - `pi-lsp` 的 TypeScript/JavaScript 后端由根包固定依赖提供,`extensions/lsp.ts` 使用当前 Node 可执行文件直接启动包内 `typescript-language-server` CLI,不得依赖或调用 VS Code GUI;Kotlin/JDT LS 仍使用组合包部署的 `kotlin-lsp --stdio` 与 `jdtls` 配置,根包不安装这两个系统可执行文件,便捷脚本可在用户逐项明确确认后通过 Homebrew 安装 Kotlin LSP、Java 21 和 JDT LS。 - 终端环境配置只属于便捷脚本:Kitty 可选安装后可通过官方 kitten 启用 Solarized Dark;Oh My Zsh 使用不切换 shell 的 unattended 安装;Powerlevel10k 将仓库内置的 `config/p10k.zsh`(Rainbow/ASCII 单行紧凑主题)部署到 `~/.config/my-pi/p10k.zsh`(遵循 `XDG_CONFIG_HOME`),不得覆盖用户自己的 `~/.p10k.zsh`。`.zshrc` 中脚本拥有的内容必须使用 `# >>> my-pi: >>>` / `# <<< my-pi: <<<` 受管块,更新时只替换块内内容;标记不完整或重复时拒绝修改。实际修改前必须创建带时间戳的备份。`git`、`zsh-autosuggestions`、`zsh-syntax-highlighting` 在安装流程中逐项询问后才安装或启用。 - 原生远程压缩限定为 `openai-codex` + `openai-codex-responses` 与 `sub-api` + `openai-responses`,默认在 turn boundary 达到 effective 下界 90% 时触发。`pi-compaction/src/native-provider.ts` 统一资格与传输选择:Codex 保留原 endpoint/JWT/account ID;SubAPI 从显式 `baseUrl` 解析公共 `/responses`,使用配置的 API key 和请求头,不解析 ChatGPT account ID、不硬编码网关域名,也不调用独立 `/responses/compact`。两者复用 projection、`compaction_trigger`、SSE 结果、native checkpoint 恢复和 portable 模型切换摘要;SubAPI 部署须支持原生 compaction v2。普通聊天支持不代表压缩支持,失败回退 Pi,不自动改写用户模型配置。 @@ -116,14 +117,22 @@ - `skill_manage` 是 `pi-hermes-memory` 提供的能力,不是 Pi 宿主能力:Pi 只发现并加载 skill 目录(`~/.pi/agent/skills/` 加扩展通过 `resources_discover` 注册的目录),没有创建 skill 的工具。该扩展同时承担 skill 写入与目录注册;不加载它,`projects-memory/<项目>/skills/` 下的既有 skill 也不会被 Pi 发现。 - `install.sh` 默认从 `git:git@bitbucket.org:siakitem/my-pi.git` 安装,并允许用 `PI_PACKAGE_SOURCE` 覆盖来源;组合包安装失败时立即停止,机器级依赖安装失败时继续检查其余依赖并最终返回非零状态。 - CodeGraph 的可选安装使用官方远程安装脚本;macOS Kitty 使用 Homebrew cask,其他系统使用 Kitty 官方安装器;Kotlin LSP、Java 21 和 JDT LS 使用 Homebrew;Oh My Zsh 使用官方安装器,Powerlevel10k 与两个第三方 Zsh 插件从各自官方 Git 仓库克隆。升级时 Git checkout、Homebrew 包、Kitty 官方安装和 CodeGraph 都必须先比较本地与远端版本,版本一致时不得重新下载安装;无法可靠比较时保留现状并报告。缺少 Homebrew、curl 或 git 时保留已经完成的组合包安装并报告失败。 -- `install.sh` 不检查默认不需要的 `rtk` CLI;`update.sh` 和 `uninstall.sh` 使用同一个 `PI_PACKAGE_SOURCE` 规则分别调用 `pi update` 和 `pi remove`。运行 `update.sh` 视为明确授权升级已安装组件并整体重建 `/my-pi/` 受管配置目录:重建按 `config/bundle-manifest.json` 全量替换,不逐字段迁移或合并用户改动;需要长期保留的自定义必须改仓库 `config/` 源文件。不得安装缺失组件或改写受管块之外的用户配置;卸载不得删除用户记忆数据,其他共享机器级工具保持不变。 -- `/my-pi/` 只存放组合包拥有、可随时重建的配置/日志/缓存;`install.sh` / `update.sh` 每次运行都整体删除后按 `config/bundle-manifest.json` 重建且不备份。需要长期保留的配置修改必须改仓库 `config/` 中的源文件;用户数据(记忆、会话、`auth.json`、`models.json`、`settings.json`、`search.env` 等)不得放入该目录。外部 npm 扩展固定路径的 `hashline.json`、`lsp.json` 位于 agent 根目录,是唯一例外;旧位置(`extensions/pi-permission-system/`、`settings.json` 的 `toolSearch` 段、agent 根 `SYSTEM_COVER.md`)的残留不迁移也不清理。 +- `install.sh` 不检查默认不需要的 `rtk` CLI;`update.sh` 和 `uninstall.sh` 使用同一个 `PI_PACKAGE_SOURCE` 规则分别调用 `pi update` 和 `pi remove`。运行 `update.sh` 视为明确授权升级已安装组件;单独的 `pi update` 只在新版本存在时整体覆盖已安装副本(含 `config/`),`update.sh` 每次成功升级后都会把受管 checkout 还原到已发布版本,两者都不逐字段迁移或合并本机对已安装副本的改动;需要长期保留的自定义必须改仓库 `config/` 源文件。不得安装缺失组件或改写受管块之外的用户配置;卸载不得删除用户记忆数据,其他共享机器级工具保持不变。 +- `/my-pi/` 只存放可随时丢弃的运行时状态(日志、缓存),不存放配置;组合包配置一律以仓库 `config/` 为唯一来源并由扩展直接读取。用户数据(记忆、会话、`auth.json`、`models.json`、`settings.json`、`search.env` 等)不得放入该目录。外部 npm 扩展固定路径的 `hashline.json`、`lsp.json` 位于 agent 根目录,是唯一例外(扩展加载时从 `config/` 覆盖写入);旧位置(`extensions/pi-permission-system/`、`settings.json` 的 `toolSearch` 段、agent 根 `SYSTEM_COVER.md`)的残留不迁移也不清理。 + +## 组合包配置原则 + +- 本组合包是私有组合包,所有机器上的组合包配置必须一致。组合包自有扩展的全部配置只以仓库 `config/` 为唯一来源,扩展运行时直接读取已安装组合包内的 `config/` 文件,不复制、不播种、不迁移到 agent 目录。 +- 配置变更一律修改仓库 `config/` 并随组合包发布;`pi install` / `pi update` 即完成部署,便捷脚本不得再承担配置部署。某台机器上对已安装副本的临时修改(包括扩展设置界面的保存)不做保护、合并或提示:单独运行 `pi update` 只在有新版本时覆盖,`update.sh` 每次成功升级后都会把受管 checkout 还原到已发布版本。 +- 新增或导入扩展时,其可配置项必须放进 `config/` 下的独立文件;不得读写 Pi `settings.json` 的自定义段、agent 目录下的独立配置文件,也不得以环境变量作为持久默认值。只有外部 npm 扩展固定读取 agent 目录路径时(当前为 `hashline.json`、`lsp.json`),才允许在扩展加载时从 `config/` 覆盖写入该路径。 +- `config/` 只放与机器无关的值;依赖本机路径的默认值(home 下的目录、可执行文件绝对路径等)保留在代码中运行时计算。 +- `/my-pi/` 只存放可随时丢弃的运行时状态(日志、缓存),不得存放配置。用户数据(记忆、会话、`auth.json`、`models.json`、凭据库)不属于组合包配置,不受本节约束,也不得放进 `config/`。 ## 修改边界 - 优先在目标扩展目录内完成改动;不要让一个扩展依赖另一个扩展的未公开内部实现。 - 保留原项目的 `LICENSE`、版权信息和必要的来源说明。 -- agent 目录下扩展运行时产生的数据——`/my-pi/` 受管重建目录、旧 `extensions/pi-permission-system/` 残留、日志、构建产物、覆盖率目录和依赖目录——属于本地状态,不应提交;`config/bundle-manifest.json`、`config/pi-permission-policy.jsonc`、`config/pi-permission-hard-guards.jsonc`、`config/tool-search.json`、`config/system-prompt-cover.md`、`config/pi-hashline-edit.json` 与 `config/lsp.json` 是组合包权威源配置,必须提交并维护。 +- agent 目录下扩展运行时产生的数据——`/my-pi/` 可丢弃运行时目录、旧 `extensions/pi-permission-system/` 残留、日志、构建产物、覆盖率目录和依赖目录——属于本地状态,不应提交;`config/` 下的全部文件(`pi-permission-policy.jsonc`、`pi-permission-hard-guards.jsonc`、`tool-search.json`、`rtk-optimizer.json`、`pi-compaction.json`、`pi-worktree.json`、`pi-hermes-memory.json`、`system-prompt-cover.md`、`pi-hashline-edit.json`、`lsp.json`、`p10k.zsh` 等)是组合包权威源配置,必须提交并维护。 - 外部 Pi 扩展依赖必须在根 `package.json` 中使用精确版本,自维护扩展必须使用本地 `file:` 依赖,并更新根 `package-lock.json`;不要用仓库级 `.pi/settings.json` 代替组合包依赖。 - 需要原生构建的依赖只按锁定版本加入根 `allowScripts`;当前 `better-sqlite3@12.11.1` 仍由 Context Mode 使用并保持精确放行。`pi-hermes-memory` 的 `better-sqlite3@13` 运行时使用包内 N-API 预编译二进制,npm 因 `binding.gyp` 推断出的 `node-gyp rebuild` 无需执行,不得为此新增放行条目。 - Pi 核心包只作为宿主 peer dependencies,不得在组合包内再安装或打包一套 Pi runtime;保留根 `.npmrc` 的 peer 安装策略。 @@ -133,8 +142,8 @@ - 每个自维护扩展目录必须保留 `UPSTREAM.md` 来源记录,并且**必须用中文维护**:记录官方仓库、tag/commit、导入版本、导入日期、许可证归属、本地改动清单,以及同步上游时的约束。`UPSTREAM.md` 是仓库自有的维护文档,不是上游产物;上游自带的源码、测试、文档、README、CHANGELOG 与 `LICENSE` 一律保持上游原文,不翻译、不改写。新增扩展或同步上游导致来源信息变化时,必须同步更新该文件。 - `pi-notify/` 的根依赖必须保持 `file:./pi-notify`,组合包直接加载 `pi-notify/src/index.ts`;不得同时安装或加载 npm 预编译入口,否则会重复通知。 - 上游仅作为参考来源。同步上游改动时先核对本仓库已有修改,再按明确范围移植;不要直接覆盖本地实现。 -- `install.sh`、`update.sh`、`uninstall.sh` 和 `search_config.sh` 必须保持 POSIX `sh` 兼容和可执行权限,并包含在根 `package.json` 的 `files` 中;修改脚本行为时同步更新 README 和本文件。 -- 安装流程中的机器级依赖和用户终端配置必须保持逐项询问且默认拒绝,不得在没有用户明确确认的情况下自动安装或改写。用户主动运行 `update.sh` 只授权升级已安装项和整体重建 `my-pi/` 受管配置目录;重建必须先完成全部校验并在临时目录构建成功后一次性替换,任何失败不得留下半重建状态。缺失项仍必须跳过。`.zshrc` 修改必须局限于受管块并保留备份,卸载不得顺带删除或还原共享工具和用户终端配置。 +- `install.sh`、`update.sh`、`uninstall.sh`、`configure.sh` 和 `scripts/lib.sh` 必须保持 POSIX `sh` 兼容,前者包含在根 `package.json` 的 `files` 中;修改脚本行为时同步更新 README 和本文件。 +- 安装流程中的机器级依赖和用户终端配置必须保持逐项询问且默认拒绝,不得在没有用户明确确认的情况下自动安装或改写。用户主动运行 `update.sh` 只授权升级已安装项;缺失项仍必须跳过。`.zshrc` 修改必须局限于受管块并保留备份,卸载不得顺带删除或还原共享工具和用户终端配置。 - 未经明确要求,不执行发布、提交、推送、运行安装/卸载脚本或安装到用户 Pi 运行目录等外部写操作。 ## 根目录 TypeScript 与 LSP 验证约定 @@ -167,7 +176,7 @@ - 根包必须通过 `file:./pi-permission-system` 提供运行时依赖,包装入口必须直接加载仓库源码;不得同时加载 npm 预编译入口或第二个 permission-system 实例。 - 内置 AutoReview 实现位于 `pi-permission-system/src/auto-review/`;同步原 AutoReview 上游时必须从源码仓库明确 tag/commit 移植,保留 `LICENSES/pi-permission-auto-review-MIT.txt`、作者和 `docs/auto-review/UPSTREAM.md`,不得恢复顶层独立扩展、npm 预编译入口或平行 gate。 - reviewer 只能处理普通策略已判定为 `ask` 且未被 hard guard 拒绝的请求;不得绕过硬 `deny`。 -- `HardGuardPolicyLoader` 必须保持 global-only、无 SessionRules、无项目覆盖;用户文件和 bundled fallback 都不可用时 fail closed 到 deny-all,并报告配置问题。普通 policy 与 hard guard 配置不得合并成一份文件。 +- `HardGuardPolicyLoader` 必须保持 global-only、无 SessionRules、无项目覆盖;唯一的组合包 hard-guards 文件不可用时 fail closed 到 deny-all,并报告配置问题。普通 policy 与 hard guard 配置不得合并成一份文件。 - 修改 reviewer 策略、可信证据边界、配置结构、hard-guard precedence、模型解析或 authorizer/lifecycle 注册时,必须同步更新 README、配置文档、架构文档、schema 和相应测试。 @@ -179,14 +188,14 @@ - 六个记忆工具与 `context_tree_query` 必须在 `pi-tool-search/extensions/config.ts` 的 `BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled` 中常驻,并由 `pi-tool-search/extensions/bundle-groups.ts` 的 `memory-recall` / `memory-management` 分组提供目录元数据(`context_tree_query` 留在 `memory-recall`)。新增、删除或重命名这些工具时必须同步 `bundle-groups.ts`、`BUNDLE_TOOL_SEARCH_DEFAULTS`、`config/tool-search.json`(二者必须完全一致)、Tool Search 测试、权限策略与 `pi-tool-search` 的 README / CHANGELOG。 - 依赖必须保持 `better-sqlite3@^13.0.3`:13.x 运行时使用包内 N-API 预编译二进制 `prebuilds/.node`,不依赖 node-gyp 编译产物,必须能由 npm 嵌套安装并与根 `better-sqlite3@12.11.1` 并存。不得把它降级回 12.x(Node 24 下会在进程退出时 SIGABRT),也不得让根包提升为单一版本。 - `devDependencies` 中的 `@earendil-works/*` 必须与根包当前版本精确一致。若写成 `^0.80.2` 这类与根不兼容的范围,npm 会在本目录嵌套安装第二套 Pi runtime,违反"不得在组合包内再安装一套 Pi runtime"的纪律;`typebox`、`tsx`、`typescript`、`@types/better-sqlite3` 同理,必须钉到根已有版本。 -- 全局记忆、项目记忆与 skill 位于 `~/.pi/agent/pi-hermes-memory/` 与 `~/.pi/agent/projects-memory/`,配置位于 `~/.pi/agent/hermes-memory-config.json`;这些属于用户数据,安装、升级与卸载脚本不得创建、迁移或删除。 +- 全局记忆、项目记忆与 skill 位于 `~/.pi/agent/pi-hermes-memory/` 与 `~/.pi/agent/projects-memory/`,属于用户数据,安装、升级与卸载脚本不得创建、迁移或删除;扩展配置位于已安装组合包的 `config/pi-hermes-memory.json`(其中路径类默认值由代码运行时计算),旧 `~/.pi/agent/hermes-memory-config.json` 被忽略。 - 修改工具注册、`before_agent_start` 注入策略、`resources_discover` 目录注册、SQLite 迁移或 consolidation 后,必须同步包内 README/CHANGELOG/docs,并运行根扩展联合加载与 Tool Search 测试。 ## `pi-compaction` 开发约定 - 扩展入口是 `pi-compaction/index.ts`,增量索引、投影、后台摘要、服务与 Codex native 实现在 `pi-compaction/src/`,测试位于 `pi-compaction/test/`,双上游来源记录是 `pi-compaction/UPSTREAM.md`。 - 根包必须通过 `file:./pi-compaction` 提供依赖并直接加载源码;不得重新声明或加载 `pi-condense`、npm `@ogulcancelik/pi-codex-compaction` 或 npm `pi-context-view`。 -- `pi-compaction` 只支持自己的 version 1 记录和 `piCompaction` 配置,不得恢复旧状态兼容、自动迁移或加载时写回 `settings.json`。 +- `pi-compaction` 只支持自己的 version 1 记录和 `config/pi-compaction.json` 配置,不得恢复旧状态兼容、自动迁移或读写 `settings.json` 自定义段。 - Context Authority 是 compaction 唯一生产投影者。普通 Provider、Codex native、`/context`、`/compaction-status` 和 footer 必须消费同一 snapshot;background worker 不得在 Provider 请求中被 await。 - Segment custom entry 只能保存摘要、hash 和 source reference,不得复制完整原始工具输出。`context_tree_query` 恢复结果必须有界,native checkpoint 的 portable summary 也必须有界。 - 修改 projection、token accounting、threshold、checkpoint、模型切换、后台并发或恢复索引后,至少运行 `npm test`、`npm run check`、`npm pack --dry-run --json`、根扩展联合加载、packed 根 tarball 隔离加载和性能回归。性能门禁为 cache-hit P95 <2ms、delta projection P95 <10ms,普通 hot path 不得扫描完整 branch。 @@ -224,7 +233,7 @@ ## `pi-ssh` 开发约定 -- 扩展入口是 `pi-ssh/index.ts`,`ssh2` transport 与 SFTP 实现在 `pi-ssh/src/ssh2-transport.ts`,有界自适应远程搜索位于 `pi-ssh/src/remote-search.ts`,AES-GCM vault 位于 `pi-ssh/src/vault.ts`,选择性导入入口是根 `ssh_config.sh` 与 `pi-ssh/scripts/ssh-config.mjs`,权限桥接位于 `pi-ssh/permission-integration.ts`,测试保留在 `pi-ssh/test/`,来源记录是 `pi-ssh/UPSTREAM.md`。 +- 扩展入口是 `pi-ssh/index.ts`,`ssh2` transport 与 SFTP 实现在 `pi-ssh/src/ssh2-transport.ts`,有界自适应远程搜索位于 `pi-ssh/src/remote-search.ts`,AES-GCM vault 位于 `pi-ssh/src/vault.ts`,选择性导入入口是根 `configure.sh ssh` 与 `pi-ssh/scripts/ssh-config.mjs`,权限桥接位于 `pi-ssh/permission-integration.ts`,测试保留在 `pi-ssh/test/`,来源记录是 `pi-ssh/UPSTREAM.md`。 - 同步上游必须从明确 tag/commit 移植并保留 `LICENSE`、作者和来源;参考其他实现时记录明确 commit,不得导入嵌套 `.git`、`node_modules` 或构建产物。 - 运行时必须保持纯 `ssh2`,不得回退到系统 OpenSSH、`sshpass`、`SSH_ASKPASS` 或 ControlMaster;认证可使用密码、私钥文件或 `ssh2` 直连的 SSH Agent socket(包括 1Password),Agent 模式只保存 socket 路径且不得导出私钥。OpenSSH 只允许由显式配置脚本通过 `ssh -G` 解析用户选中的 alias,已导入配置变化必须由用户显式更新。第一版遇到 `ProxyJump` / `ProxyCommand` 必须拒绝,不能静默忽略。 - 根包必须直接固定安装 `ssh2@1.17.0` 与配置 CLI 所需的 `jiti@2.7.0`,因为 packed bundle 直接加载其内置 `pi-ssh/` 源码;`ssh2` 与 `cpu-features` 的 install scripts 只构建可选加速绑定,当前不得加入根 `allowScripts`,纯 JavaScript fallback 必须可运行。 @@ -244,14 +253,14 @@ ## `extensions/system-prompt-cover.ts` 开发约定 -- 扩展入口是 `extensions/system-prompt-cover.ts`;纯 helper(`spliceCover` / `readCoverFile` / `resolveCoverPath` / `COVER_FILE_NAME`)位于 `extensions/system-prompt-cover-config.ts` 以便 `node --test` 单测;部署由 `scripts/rebuild-bundle-config.mjs` 按 `config/bundle-manifest.json` 统一完成;组合包权威源是 `config/system-prompt-cover.md`。 +- 扩展入口是 `extensions/system-prompt-cover.ts`;纯 helper(`spliceCover` / `readCoverFile` / `resolveCoverPath` / `COVER_FILE_NAME`)位于 `extensions/system-prompt-cover-config.ts` 以便 `node --test` 单测;组合包权威源是 `config/system-prompt-cover.md`,扩展运行时直接读取已安装组合包内文件,不经任何脚本部署。 - 只能使用 Pi 的 `forceSystemPrompt` 投影路径(`before_agent_start` 返回 `{ systemPrompt }`)配合文本拼接来保持分区顺序;不得改用 `customPrompt` 选项。后者会抑制 `tools` / `rules` / `docs` 三个分区,而回填只能追加到末尾,无法恢复原顺序。 - 不得在 handler 中写 `customPrompt` 或依赖其生效:`forceSystemPrompt` 优先级更高,二者同时存在时 `customPrompt` 会被静默忽略而不报错。 - 分割边界必须是独占一行的裸标签(带 backreference 同名闭合),不能匹配带属性的标签:`` 嵌套在 `` 内,只有裸标签才是分区边界。 -- 封面文件是 `/my-pi/system-prompt-cover.md`,不得改名为 `SYSTEM.md`:后者会被 Pi 先当成 `customPrompt` 读入,封面扩展拿到的提示词会缺少可回填的分区。 +- 封面文件是已安装组合包内的 `config/system-prompt-cover.md`,不得改名为 `SYSTEM.md` 或移到 agent 根目录:后者会被 Pi 先当成 `customPrompt` 读入,封面扩展拿到的提示词会缺少可回填的分区。 - 文件缺失、不可读或为空白时必须静默 no-op,保持 Pi 默认封面;不得写入空 preamble,也不得因读取失败阻止其他扩展加载。 -- `install.sh` / `update.sh` 必须从**当前升级后**组合包内的 helper 重建 `my-pi/`(`update.sh` 只定位 `updated_bundle_dir` 内的 `scripts/rebuild-bundle-config.mjs`,禁止回退到可能陈旧的脚本目录;`install.sh` 使用自身脚本目录副本),helper 缺失时报告失败但继续处理其他组件。重建不保留备份,与 `pi-hashline-edit.json` 的组合包权威配置语义一致。 -- 修改 `spliceCover` 语义、注册顺序、部署路径或脚本钩子后,必须运行 `node --test tests/system-prompt-cover.test.ts`、`node --test tests/bundle-config-rebuild.test.ts`、`node --test tests/extension-load.test.ts`、`sh -n install.sh`、`sh -n update.sh`,并确认 `npm pack --dry-run` 包含 `config/system-prompt-cover.md`、`config/bundle-manifest.json` 与 `scripts/rebuild-bundle-config.mjs`。 +- 封面文件随组合包发布,`pi install` / `pi update` 后即就位,无需任何部署 helper;对已安装副本的临时修改由 `pi update` 覆盖。 +- 修改 `spliceCover` 语义、注册顺序或封面路径后,必须运行 `node --test tests/system-prompt-cover.test.ts`、`node --test tests/bundle-config.test.ts`、`node --test tests/extension-load.test.ts`,并确认 `npm pack --dry-run` 包含 `config/system-prompt-cover.md`。 ## `pi-ask-user` 开发约定 @@ -266,7 +275,7 @@ ## `pi-tool-search` 开发约定 -- 扩展入口是 `pi-tool-search/extensions/index.ts`,组合包权威分组位于 `pi-tool-search/extensions/bundle-groups.ts`,目录生成/校验/缓存位于 `pi-tool-search/extensions/catalog.ts`,配置读取与路径解析位于 `pi-tool-search/extensions/config.ts`(`/my-pi/tool-search/config.json`,由 rebuild 脚本部署),测试保留在 `pi-tool-search/test/`。 +- 扩展入口是 `pi-tool-search/extensions/index.ts`,组合包权威分组位于 `pi-tool-search/extensions/bundle-groups.ts`,目录生成/校验/缓存位于 `pi-tool-search/extensions/catalog.ts`,配置读取与路径解析位于 `pi-tool-search/extensions/config.ts`(直接读取组合包 `config/tool-search.json`;目录缓存仍在 `/my-pi/tool-search/catalog-v1.json`),测试保留在 `pi-tool-search/test/`。 - 同步上游必须记录明确 tag/commit,保留 README、CHANGELOG、文档、作者和许可证说明;不得导入上游 `.pi`、嵌套 `.git`、`node_modules` 或构建产物。 - 修改 Tool Card 提示词、目录校验、分组、缓存、active-tool/LRU 生命周期、配置或工具结果时,必须同步更新包内 README、当前动态加载文档、CHANGELOG 和测试。 - 新增、删除或重命名组合包暴露工具时必须同步维护 `BUNDLE_GROUP_DEFINITIONS`;日常只读工具、provider 工具和高风险管理工具不得因名称相似而混入同一大组,根验证应确认标准组合包没有落入 unknown-tools 路径。 @@ -275,9 +284,9 @@ ## 验证 -组合包依赖或加载入口变化时,至少验证根 `npm install` 幂等、锁文件有效,以及全部扩展可在隔离的临时 Pi agent 目录加载。权限配置变化时使用仓库内固定快照的 `pi-permission-system` schema 校验,并验证包装入口部署后的文件与仓库源配置一致。 +组合包依赖或加载入口变化时,至少验证根 `npm install` 幂等、锁文件有效,以及全部扩展可在隔离的临时 Pi agent 目录加载。权限配置变化时使用仓库内固定快照的 `pi-permission-system` schema 校验,并验证扩展运行时解析到的 `config/` 路径确实位于组合包内。 -安装、升级、卸载、搜索或 SSH 配置脚本变化时,至少运行 `sh -n install.sh`、`sh -n update.sh`、`sh -n uninstall.sh`、`sh -n search_config.sh`、`sh -n ssh_config.sh` 和 ShellCheck,并核对脚本仍具有可执行权限、仍包含在根 `package.json` 的 `files` 中、README 描述与实际流程一致。组合包配置重建必须用隔离 agent dir 覆盖:`my-pi/` 全量替换且内容字节一致、权限 `600`/`700`、目录外文件不受影响、空或相对 agentDir 拒绝且不删除、manifest 目标越界拒绝且不删除、`my-pi/` 为符号链接时只替换链接且不触碰目标目录;`update.sh` 中 helper 无法定位时必须记录失败且不回退脚本目录。搜索配置测试只能使用虚拟 key 和隔离 HOME;SSH 配置测试必须使用隔离 HOME、虚拟凭据与 mock transport,不得连接真实服务器或把真实密码写入测试输出。涉及真实 `pi install`、`pi update`、`pi remove`、Homebrew、远程安装器、Git 克隆、真实 SSH 连接或真实用户终端配置的端到端验证属于外部写操作,未经明确要求不得执行;可以使用隔离的临时 HOME 和 mock 命令验证分支行为。 +安装、升级、卸载、搜索或 SSH 配置脚本变化时,至少运行 `sh -n install.sh`、`sh -n update.sh`、`sh -n uninstall.sh`、`sh -n configure.sh`、`sh -n scripts/lib.sh` 和 ShellCheck,并核对脚本仍具有可执行权限、仍包含在根 `package.json` 的 `files` 中、README 描述与实际流程一致。搜索配置测试只能使用虚拟 key 和隔离 HOME;SSH 配置测试必须使用隔离 HOME、虚拟凭据与 mock transport,不得连接真实服务器或把真实密码写入测试输出。涉及真实 `pi install`、`pi update`、`pi remove`、Homebrew、远程安装器、Git 克隆、真实 SSH 连接或真实用户终端配置的端到端验证属于外部写操作,未经明确要求不得执行;可以使用隔离的临时 HOME 和 mock 命令验证分支行为。 记忆扩展变化必须额外验证:`pi-hermes-memory` 在隔离临时 Pi agent 目录中可加载且注册 6 个工具、`pi.extensions` 中排在 `pi-compaction` 之前、Tool Search 目录把六个工具归入 `memory-recall` / `memory-management` 且全部常驻、权限策略默认 `allow`、`better-sqlite3@13` 嵌套安装与根 `12.11.1` 并存。不得在测试中连接真实数据库或修改真实 `~/.pi/agent/` 记忆数据。 diff --git a/README.md b/README.md index c4502bc..7c5c266 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ Git checkout 在 `fetch` 后比较本地与上游提交,Homebrew 包在刷新 日志中的“Zsh 插件未安装,升级流程跳过”不是升级失败:升级脚本不会新增用户此前未选择的 插件。需要补装时重新运行 `./install.sh`,并在对应的逐项询问中明确选择 `Y`。 -升级成功后,脚本会使用**更新后**组合包中的 `scripts/rebuild-bundle-config.mjs` 整体重建 `/my-pi/` 受管配置目录:按 `config/bundle-manifest.json` 校验来源与目标、在临时目录完成构建后一次性替换,不备份也不保留目录内的本地改动。Powerlevel10k 受管副本只在内容不同时备份并原子同步;`.zshrc` 也只更新已有的 my-pi 受管块,不触碰块外配置。因此安装流程负责补齐缺失项,升级流程只升级已安装项并整体刷新组合包拥有的配置。 +`pi update` 在有新版本时用更新后的组合包整体覆盖已安装副本;`update.sh` 在每次成功升级后还会把默认 git 来源的已安装 checkout `git reset --hard` 还原到已发布版本(本地路径来源不 reset,不做 `git clean` 以保留 `node_modules`),因此对已安装副本(含 `config/`)的本地修改不会保留。扩展运行时直接读取已安装组合包内的 `config/` 文件,脚本不再做任何配置部署。Powerlevel10k 受管副本只在内容不同时备份并原子同步;`.zshrc` 也只更新已有的 my-pi 受管块,不触碰块外配置。因此安装流程负责补齐缺失项,升级流程只升级已安装项。 卸载组合包: @@ -116,8 +116,8 @@ prompt 前缀、对前缀缓存友好。模型需要历史上下文时主动调 Tool Search 常驻,保证 policy 指示与模型可见工具一致。 记忆与 skill 数据位于 `~/.pi/agent/pi-hermes-memory/`(全局)与 `~/.pi/agent/projects-memory/<项目>/` -(项目级),可选配置为 `~/.pi/agent/hermes-memory-config.json`。这些是用户数据,安装、升级与卸载脚本 -不会创建、迁移或删除。 +(项目级)。这些是用户数据,安装、升级与卸载脚本不会创建、迁移或删除;扩展配置来自已安装组合包的 +`config/pi-hermes-memory.json`。 CodeGraph 采用惰性初始化:`codegraph_explore` 在未建立索引的项目上被调用时会先向用户确认, 确认后才运行 `codegraph init` 并自动重试本次探索;不需要手动执行任何初始化命令。 @@ -147,13 +147,13 @@ CodeGraph 的更新或遥测设置。 ### 系统提示词封面 -`extensions/system-prompt-cover.ts` 把 Pi 默认系统提示词的**开头**替换为组合包权威的 `config/system-prompt-cover.md`(部署到 `~/.pi/agent/my-pi/system-prompt-cover.md`),同时**保留其余全部分区及其原始顺序**:``、``、``、``、``、`` 都不会被删除或重新排序。 +`extensions/system-prompt-cover.ts` 把 Pi 默认系统提示词的**开头**替换为组合包权威的 `config/system-prompt-cover.md`(扩展直接读取已安装组合包内的该文件,不复制到 agent 目录),同时**保留其余全部分区及其原始顺序**:``、``、``、``、``、`` 都不会被删除或重新排序。 实现上使用 Pi 的 `forceSystemPrompt` 投影路径(每轮把渲染结果作为强制定本发送)而不是 `customPrompt` 选项:后者会连带抑制 `tools`/`rules`/`docs` 三个分区,且回填只能追加到末尾,无法保持顺序。封面只在**请求**时应用,不修改 transcript 中已持久化的结构化分区。 -封面每轮重新读取,因此修改 `~/.pi/agent/my-pi/system-prompt-cover.md` 后在**下一轮**即生效,不需要重开会话;已经结束的历史对话不受影响。文件缺失或为空白时不干预,Pi 继续使用自身默认封面。 +封面每轮重新读取,因此修改已安装组合包内的 `config/system-prompt-cover.md` 后在**下一轮**即生效,不需要重开会话;已经结束的历史对话不受影响。文件缺失或为空白时不干预,Pi 继续使用自身默认封面。 -该文件由组合包直接维护,`install.sh` 与 `update.sh` 会在组合包安装/升级后连同整个 `my-pi/` 目录一起重建,**不保留备份并且会覆盖**本地修改;要自定义请直接编辑仓库中的 `config/system-prompt-cover.md`。 +该文件由组合包直接维护并随组合包发布;对已安装副本的本地修改不做保护,`pi update` 会直接覆盖;要长期自定义请直接编辑仓库中的 `config/system-prompt-cover.md`。 `system-prompt-cover.md` 必须使用这个名字:不能改名为 `SYSTEM.md`,否则 Pi 会先把它当作 `customPrompt` 读入,封面扩展拿到的提示词就已经缺少可供回填的分区了。 @@ -177,7 +177,7 @@ CodeGraph 的更新或遥测设置。 动态组默认最多同时加载 5 个、每个模型生成组最多 8 个工具、全部动态组最多 28 个工具。`tool_search.groups` 可在一次调用中原子加载最多 5 个精确组;完整请求本身超限时保持原状态。加载或调用组内工具会更新 LRU,超限时先卸载最久未使用且不在本次请求内的组。自然语言 query 对精确工具名和已提交 workflow alias 加权,弱匹配或近似同分只返回候选。首次纯增加载继续使用 Pi 原生增量传播;发生卸载和加载的替换由 Pi 自动走安全 fallback。 -Tool Search 的用户级配置文件是 `~/.pi/agent/my-pi/tool-search/config.json`(准确位置为 Pi agent dir 下的 `my-pi/tool-search/config.json`),由 `install.sh` / `update.sh` 从仓库的 `config/tool-search.json` 部署: +Tool Search 的配置文件是已安装组合包内的 `config/tool-search.json`(即本仓库的 `config/tool-search.json`,随 `pi install` / `pi update` 就位,扩展运行时直接读取): ```json { @@ -203,7 +203,7 @@ Tool Search 的用户级配置文件是 `~/.pi/agent/my-pi/tool-search/config.js } ``` -扩展只读取该文件,从不写入:文件缺失、不可读或结构无效时回退内置的 `BUNDLE_TOOL_SEARCH_DEFAULTS`;每次运行 `install.sh` / `update.sh` 都会随整个 `my-pi/` 目录重建该文件,本地手改会被覆盖,长期自定义应改仓库 `config/tool-search.json`。`alwaysEnabled` 中的精确工具名属于固定工具,不占动态组额度;`groupOverrides` 可把指定工具强制归入命名组。默认关闭状态行,避免与 minimal footer 叠加。 +扩展只读取该文件,从不写入:文件缺失、不可读或结构无效时回退内置的 `BUNDLE_TOOL_SEARCH_DEFAULTS`;对已安装副本的手改不做保护,`pi update` 会覆盖,长期自定义应改仓库 `config/tool-search.json`。`alwaysEnabled` 中的精确工具名属于固定工具,不占动态组额度;`groupOverrides` 可把指定工具强制归入命名组。默认关闭状态行,避免与 minimal footer 叠加。 ### 精确读写归 Hashline @@ -238,21 +238,21 @@ FFF 仍负责精确字面搜索;RTK 继续压缩未走 Context Mode 的 Bash `pi-ssh` 运行时使用纯 `ssh2`,不依赖系统 `ssh` 或 `sshpass`。首次使用前显式导入需要的 OpenSSH alias: ```bash -./ssh_config.sh import packaging-server +./configure.sh ssh import packaging-server ``` 脚本只在导入阶段调用 `ssh -G` 解析用户选中的 alias,随后交互选择 SSH Agent(包括 1Password)、私钥文件或密码认证、显示并确认服务器 Host Key、测试连接,再把完整配置写入 `${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/pi-ssh/hosts.enc`。Agent 模式只保存 socket 路径,私钥仍留在 Agent 中;邻接的随机 `vault.key` 用于 AES-256-GCM 解密,目录与两个文件在 POSIX 上分别是 `700` / `600`。该加密只避免误看或单独泄漏密文,能读取两文件的同一用户仍可解密。 -可用 `./ssh_config.sh list|update|remove|rotate-key` 管理已导入主机。第一版拒绝 `ProxyJump` / `ProxyCommand`,且不会自动读取远端 `AGENTS.md` 或 `CLAUDE.md`。连接不再通过 `/ssh`、`--ssh` 或 session resume 建立:用户在具体远端任务中明确指定已导入 host ID 后,Agent 把受复核、串行且可取消的 `ssh_connect` 作为独立步骤调用并等待成功,再使用其他 `ssh_*` 工具;连接动作及后续操作均进入 `ask → auto-review`,相对远端路径会在预览中显示请求值和解析后的绝对路径,会话结束自动断开。SSH transport 在会话内持久,独立命令最多使用 4 路有界并发 exec channel,但每次 `ssh_bash` 都启动独立的非交互 Shell;需要持续改变后续远端操作的工作目录时,Agent 同样必须把受复核且串行执行的 `ssh_cd` 作为独立步骤调用并等待成功,而不是依赖某次 Shell 中临时执行的 `cd`。 +可用 `./configure.sh ssh list|update|remove|rotate-key` 管理已导入主机。第一版拒绝 `ProxyJump` / `ProxyCommand`,且不会自动读取远端 `AGENTS.md` 或 `CLAUDE.md`。连接不再通过 `/ssh`、`--ssh` 或 session resume 建立:用户在具体远端任务中明确指定已导入 host ID 后,Agent 把受复核、串行且可取消的 `ssh_connect` 作为独立步骤调用并等待成功,再使用其他 `ssh_*` 工具;连接动作及后续操作均进入 `ask → auto-review`,相对远端路径会在预览中显示请求值和解析后的绝对路径,会话结束自动断开。SSH transport 在会话内持久,独立命令最多使用 4 路有界并发 exec channel,但每次 `ssh_bash` 都启动独立的非交互 Shell;需要持续改变后续远端操作的工作目录时,Agent 同样必须把受复核且串行执行的 `ssh_cd` 作为独立步骤调用并等待成功,而不是依赖某次 Shell 中临时执行的 `cd`。 远端文件搜索使用独立的 `ssh_find` / `ssh_grep`:在每次获批调用内按服务器现有能力选择 `fd/fdfind → git ls-files → find` 或 `rg → git grep → find+grep`,不安装远端软件,并以最大 200 条结果、单行截断和 `truncated` 标记约束返回。搜索目标作为绝对参数与当前远端执行 cwd 分离,因此 `ssh_grep` 可直接搜索单个文件而不会尝试把文件当目录进入;30 秒超时会报告解析后的 root 并提示先用 `ssh_find` 缩小范围。远端 HOME/cwd 通过可取消、有界随机标记探测获得,畸形结果不会更新连接状态。RTK 只压缩 `ssh_bash` 的非搜索输出,不改写远端命令,也不处理 `ssh_find`、`ssh_grep` 或 `ssh_read`。详细说明见 [`pi-ssh/README.md`](pi-ssh/README.md)。 ### 在线搜索服务 -运行 `./search_config.sh` 可用隐藏输入依次配置 Tavily、Exa 和 Keenable;也可一次无交互配置: +运行 `./configure.sh search` 可用隐藏输入依次配置 Tavily、Exa 和 Keenable;也可一次无交互配置: ```bash -./search_config.sh --tavily '...' --exa '...' --keenable '...' +./configure.sh search --tavily '...' --exa '...' --keenable '...' ``` 只传部分参数时保留其他服务的已有值。凭据写入 Pi 自己的 @@ -293,16 +293,17 @@ codegraph status `pi-permission-system/` 从上游 `pi-permission-system-v26.2.1` tag 导入,初始源码快照为 `ec4fdb11343dc94f7185b113e559a4cf9f8dc035`。原 `pi-permission-auto-review` 的 reviewer、可信证据边界和 Codex Guardian 风格策略已整合进同一个扩展;其 MIT 许可和来源记录保留在 `pi-permission-system/LICENSES/` 与 `docs/auto-review/`。根组合包不再加载第二个 AutoReview 扩展。 -`extensions/permission-system.ts` 只加载扩展本身,不做任何配置播种或迁移;两份用户可编辑 JSONC 由 `install.sh` / `update.sh` 经 `scripts/rebuild-bundle-config.mjs` 以 `0600` 部署: +`extensions/permission-system.ts` 只加载扩展本身,不做任何配置播种或迁移;扩展运行时直接读取已安装组合包内的两份 JSONC: -- `~/.pi/agent/my-pi/permission-system/policy.jsonc`:唯一的全局普通 allow/ask/deny 策略,同时保存运行参数与 AutoReview 配置。项目级 `.pi/extensions/pi-permission-system/policy.jsonc` 不再读取。 -- `~/.pi/agent/my-pi/permission-system/hard-guards.jsonc`:仅全局生效的硬防御。硬 `deny` 先于 Session grant、per-agent 规则和 AutoReview,不能被后续层放宽。 +- `config/pi-permission-policy.jsonc`:唯一的全局普通 allow/ask/deny 策略,同时保存运行参数与 AutoReview 配置。项目级 `.pi/extensions/pi-permission-system/policy.jsonc` 不再读取。 +- `config/pi-permission-hard-guards.jsonc`:仅全局生效的硬防御。硬 `deny` 先于 Session grant、per-agent 规则和 AutoReview,不能被后续层放宽。 +- 同一文件内的 `protectedWrites`:Pi 凭据/全局设置/模型配置、已安装组合包与扩展目录、pi-ssh 凭据库等受保护文件的写入永远走人工确认——不经 AutoReview、yolo 或 Session grant,弹窗没有「本会话允许」选项,没有可用确认界面时直接拒绝。读取不受影响,`ssh_bash` 等远端 shell 工具不参与本机路径保护。 普通 pattern 不再按“最后一条命中”覆盖,而按具体度选择:精确匹配优先,其次字面字符更多、通配符更少、pattern 更长,只有完全同级时才以后声明者为准。因此具体 deny 会稳定压过宽泛的 `"*": "allow"`。 系统不再产生 `external_directory` 判断:项目外路径与项目内路径一样,只走用户配置的工具、`path` 和 Bash 规则。Tool Search 是唯一的工具显隐/激活控制者;权限系统只在工具被调用时做裁决。 -`my-pi/` 目录由组合包整体拥有:每次 `install.sh` / `update.sh` 都会按 `config/bundle-manifest.json` 删除后重建,不备份也不迁移目录内的改动;要长期保留的自定义必须改仓库 `config/` 下的权威源。旧位置 `~/.pi/agent/extensions/pi-permission-system/` 的残留文件被忽略,不迁移也不清理。 +`/my-pi/` 只保留可随时丢弃的运行时状态(如权限日志、Tool Search 目录缓存);组合包配置一律位于仓库 `config/` 并随组合包发布,对已安装副本的本地改动由 `pi update` 覆盖,要长期保留的自定义必须改仓库 `config/` 下的权威源。旧位置 `~/.pi/agent/extensions/pi-permission-system/` 的残留文件被忽略,不迁移也不清理。 默认普通策略对未匹配请求返回 `ask`,并通过 `authorizerChain: ["auto-review"]` 先交给内置 reviewer。高危 Hard Guard 直接终止;AutoReview 的 `allow` / `deny` 对任一普通策略 `ask`(包括 `path`)直接结束当前审批;模型、配置、认证、超时或输出异常统一 `defer` 到人工界面。 @@ -354,8 +355,9 @@ JDT LS 需要 Java 21 或更高版本;可使用带 `jdtls` wrapper 的官方 skill 也不会出现在 Pi 的 skill 索引中。 记忆与 skill 位于 `~/.pi/agent/pi-hermes-memory/`(全局)和 `~/.pi/agent/projects-memory/<项目>/` -(项目级),配置位于 `~/.pi/agent/hermes-memory-config.json`。这些是用户数据:安装、升级和卸载脚本都 -不会创建、迁移或删除它们,也不会自动转换旧 Hippo `.hippo/` 数据。 +(项目级)。这些是用户数据:安装、升级和卸载脚本都 +不会创建、迁移或删除它们,也不会自动转换旧 Hippo `.hippo/` 数据;扩展配置来自已安装组合包的 +`config/pi-hermes-memory.json`。 ### Kitty 完成与权限确认通知 @@ -379,7 +381,7 @@ compaction 和排队 follow-up 完成前不会提前通知;同时监听 `pi-pe 普通 Provider、阈值判断、Codex native compaction、`/context`、`/compaction-status` 和 minimal footer 都读取同一个 effective projection snapshot。Pi Core 的 raw threshold 只负责唤醒:只有 effective token 下界达到默认 90% 才执行 threshold compaction;manual 与 overflow 始终允许继续并在 native 失败时回退 Pi 默认 compaction。Codex native API 直接消费 effective ResponseItems,同时保存有界 portable summary 供模型或 Provider 切换。 -唯一配置位于 Pi agent `settings.json` 的 `piCompaction` 字段,默认 soft/hard/target 比例为 72%/90%/55%,扩展只在内存中归一化且不写回配置。`/compaction-status` 显示 raw/effective/reclaimed token、置信度、segment 和后台任务。完整配置、Session version 1 格式和双上游来源见 [`pi-compaction/README.md`](pi-compaction/README.md) 与 [`pi-compaction/UPSTREAM.md`](pi-compaction/UPSTREAM.md)。 +唯一配置位于已安装组合包的 `config/pi-compaction.json`,默认 soft/hard/target 比例为 72%/90%/55%,扩展只在内存中归一化且不写回配置。`/compaction-status` 显示 raw/effective/reclaimed token、置信度、segment 和后台任务。完整配置、Session version 1 格式和双上游来源见 [`pi-compaction/README.md`](pi-compaction/README.md) 与 [`pi-compaction/UPSTREAM.md`](pi-compaction/UPSTREAM.md)。 原生远程压缩同时支持 `openai-codex` + `openai-codex-responses` 和 `sub-api` + `openai-responses`,使用触发时当前会话模型。SubAPI 保留现有模型配置,从 `baseUrl` 调用公共 `/responses` 并使用 `compaction_trigger` 流式协议;优先保留非空显式 `Authorization`,否则使用 API key,也支持仅凭非空 `cf-aig-authorization` 的代理认证。缺失或空白凭据在请求前拒绝。SubAPI 不要求客户端解析 ChatGPT account ID,也不调用独立 `/responses/compact`;Codex 的 JWT 要求保持不变。网关部署须支持 Sub2API 原生压缩 v2;请求失败沿用 Pi 回退,切换模型或 Provider 时使用 portable summary。 diff --git a/config/bundle-manifest.json b/config/bundle-manifest.json deleted file mode 100644 index a3136bb..0000000 --- a/config/bundle-manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "version": 1, - "directory": "my-pi", - "files": [ - { "source": "config/pi-permission-policy.jsonc", "target": "permission-system/policy.jsonc" }, - { "source": "config/pi-permission-hard-guards.jsonc", "target": "permission-system/hard-guards.jsonc" }, - { "source": "config/tool-search.json", "target": "tool-search/config.json" }, - { "source": "config/system-prompt-cover.md", "target": "system-prompt-cover.md" } - ] -} diff --git a/config/pi-compaction.json b/config/pi-compaction.json new file mode 100644 index 0000000..ad0c00c --- /dev/null +++ b/config/pi-compaction.json @@ -0,0 +1,22 @@ +{ + "enabled": true, + "softRatio": 0.72, + "hardRatio": 0.9, + "targetRatio": 0.55, + "deterministic": { + "enabled": true, + "maxStubTokens": 1024 + }, + "semantic": { + "enabled": true, + "model": "default", + "thinking": "low", + "concurrency": 1, + "minInputChars": 4000, + "maxInputChars": 24000, + "maxRetries": 2 + }, + "native": { + "enabled": true + } +} diff --git a/config/pi-hermes-memory.json b/config/pi-hermes-memory.json new file mode 100644 index 0000000..2455a66 --- /dev/null +++ b/config/pi-hermes-memory.json @@ -0,0 +1,35 @@ +{ + "lazyInitialization": false, + "memoryMode": "policy-only", + "memoryPolicyStyle": "full", + "memoryCharLimit": 5000, + "userCharLimit": 5000, + "projectCharLimit": 5000, + "nudgeInterval": 10, + "reviewRecentMessages": 0, + "reviewEnabled": true, + "reviewTransport": "direct", + "flushOnCompact": true, + "flushOnShutdown": true, + "flushMinTurns": 6, + "flushRecentMessages": 0, + "flushCompactTimeoutMs": 60000, + "memoryOverflowStrategy": "auto-consolidate", + "overflowGraceMs": 180000, + "autoConsolidate": true, + "correctionDetection": true, + "failureInjectionEnabled": true, + "failureInjectionMaxAgeDays": 7, + "failureInjectionMaxEntries": 5, + "consolidationChunking": false, + "consolidationChunkChars": 4000, + "consolidationTimeoutMs": 180000, + "usageHitTrackingEnabled": true, + "consolidationUsageSignals": true, + "autoConsolidationWarnOnFailure": true, + "nudgeToolCalls": 15, + "standingInstructionsEnabled": true, + "sessionSearch": { "variant": "legacy" }, + "quickCheckOnOpen": true, + "sessionRetentionDays": 0 +} diff --git a/config/pi-permission-hard-guards.jsonc b/config/pi-permission-hard-guards.jsonc index 4b91ce1..8c0c776 100644 --- a/config/pi-permission-hard-guards.jsonc +++ b/config/pi-permission-hard-guards.jsonc @@ -1,6 +1,7 @@ { - // Bundle default. The installed user-level copy is seeded once and may then - // be edited by the user; project configuration cannot override it. + // Bundle-owned hard guards, read directly from the installed bundle's + // config/ directory. pi update overwrites this file; project configuration + // cannot override it. "version": 1, "permission": { "bash": { @@ -13,5 +14,22 @@ "mkfs*": "deny", "dd * of=/dev/*": "deny" } - } + }, + // Writes to the files below always reach a human prompt — never AutoReview, + // never yolo, never an existing session grant — and are denied outright when + // no confirmation UI is available. Reads are unaffected. `*` crosses `/`, so + // `/**` covers a whole subtree. `{agentDir}` is the Pi agent directory, + // `{configHome}` is $XDG_CONFIG_HOME or ~/.config; a leading `~/` also works. + "protectedWrites": [ + { "pattern": "{agentDir}/auth.json", "label": "Pi 凭据(模型与搜索 API key)" }, + { "pattern": "{agentDir}/settings.json", "label": "Pi 全局设置" }, + { "pattern": "{agentDir}/models.json", "label": "Pi 模型配置" }, + { "pattern": "{agentDir}/trust.json", "label": "项目信任记录" }, + { "pattern": "{agentDir}/hashline.json", "label": "Hashline 配置" }, + { "pattern": "{agentDir}/lsp.json", "label": "LSP 配置" }, + { "pattern": "{agentDir}/git/**", "label": "已安装的组合包(含 config/ 与权限系统源码)" }, + { "pattern": "{agentDir}/npm/**", "label": "已安装的 npm 扩展" }, + { "pattern": "{agentDir}/extensions/**", "label": "Pi 自动加载的扩展目录" }, + { "pattern": "{configHome}/my-pi/**", "label": "pi-ssh 凭据库与 p10k 配置" } + ] } diff --git a/config/pi-permission-policy.jsonc b/config/pi-permission-policy.jsonc index 20c64c3..c1a3c46 100644 --- a/config/pi-permission-policy.jsonc +++ b/config/pi-permission-policy.jsonc @@ -38,13 +38,31 @@ "grep": "allow", "find": "allow", "multi_grep": "allow", + "ls": "allow", "codegraph_explore": "allow", "lsp_diagnostics": "allow", + "lsp_definition": "allow", + "lsp_references": "allow", + "lsp_hover": "allow", + "lsp_symbols": "allow", "ctx_execute": "allow", "ctx_execute_file": "allow", "ctx_batch_execute": "allow", + "ctx_search": "allow", + "ctx_index": "allow", + "ctx_stats": "allow", + "ctx_doctor": "allow", "tool_search": "allow", "ask_user_question": "allow", + "web_search": "allow", + "chrome_snapshot": "allow", + "chrome_find": "allow", + "chrome_inspect": "allow", + "chrome_wait_for": "allow", + "chrome_screenshot": "allow", + "chrome_list_console_messages": "allow", + "chrome_list_network_requests": "allow", + "chrome_get_network_request": "allow", // Hermes memory tools write only to the extension's own agent-directory // stores and never touch the workspace, so they stay allowed. memory_search @@ -55,12 +73,18 @@ "memory_add": "allow", "memory_replace": "allow", "memory_remove": "allow", - "skill_manage": "allow", + // Skills skill_manage writes are loaded into every future session, so + // their creation stays human-reviewed. + "skill_manage": "ask", // pi-compaction's evidence recovery reads only the extension's own segment // index and never touches the workspace. It is pinned in Tool Search's // alwaysEnabled set, so it must not pay an AutoReview round trip per turn. "context_tree_query": "allow", + // Model-chosen URLs can exfiltrate data — same reason curl/wget ask. + "web_fetch": "ask", + "ctx_fetch_and_index": "ask", + "ssh_connect": "ask", "ssh_cd": "ask", "ssh_read": "ask", @@ -91,6 +115,20 @@ "git branch --show-current*": "allow", "git remote -v": "allow", "git remote get-url*": "allow", + "git blame*": "allow", + "git describe*": "allow", + "git rev-list*": "allow", + "git merge-base*": "allow", + "git ls-tree*": "allow", + "git cat-file*": "allow", + "git name-rev*": "allow", + "git shortlog*": "allow", + "git stash list*": "allow", + "git worktree list*": "allow", + "git tag -l*": "allow", + "git tag --list*": "allow", + "git config --get*": "allow", + "git config --list*": "allow", "npm install *": "ask", "npm add *": "ask", "npm uninstall *": "ask", diff --git a/config/pi-worktree.json b/config/pi-worktree.json new file mode 100644 index 0000000..2beb161 --- /dev/null +++ b/config/pi-worktree.json @@ -0,0 +1,4 @@ +{ + "root": "~/.pi/worktrees", + "branchPrefix": "wt/" +} diff --git a/config/rtk-optimizer.json b/config/rtk-optimizer.json new file mode 100644 index 0000000..f671e39 --- /dev/null +++ b/config/rtk-optimizer.json @@ -0,0 +1,30 @@ +{ + "enabled": true, + "commandRewritingEnabled": false, + "mode": "rewrite", + "guardWhenRtkMissing": true, + "showRewriteNotifications": true, + "outputCompaction": { + "enabled": true, + "stripAnsi": true, + "readCompaction": { + "enabled": false + }, + "truncate": { + "enabled": true, + "maxChars": 12000 + }, + "sourceCodeFilteringEnabled": false, + "preserveExactSkillReads": false, + "sourceCodeFiltering": "none", + "smartTruncate": { + "enabled": false, + "maxLines": 220 + }, + "aggregateTestOutput": true, + "filterBuildOutput": true, + "compactGitOutput": true, + "aggregateLinterOutput": true, + "trackSavings": true + } +} diff --git a/configure.sh b/configure.sh new file mode 100755 index 0000000..815e546 --- /dev/null +++ b/configure.sh @@ -0,0 +1,145 @@ +#!/bin/sh + +set -u + +SCRIPT_DIR=$(CDPATH=; cd "$(dirname "$0")" && pwd) || exit 1 + +say() { + printf '%s\n' "$*" +} + +warn() { + printf '错误: %s\n' "$*" >&2 +} + +usage() { + cat >&2 <<'EOF' +用法: + ./configure.sh search [选项] 配置搜索 API key(tavily / exa / keenable) + ./configure.sh ssh [选项] 配置 SSH 主机凭据 +EOF +} + +search_usage() { + cat <<'EOF' +用法: + ./configure.sh search + ./configure.sh search --tavily KEY --exa KEY --keenable KEY + ./configure.sh search --migrate [search.env 路径] + ./configure.sh search --list + +选项: + --tavily KEY 配置 tavily api_key 凭据 + --exa KEY 配置 exa api_key 凭据 + --keenable KEY 配置 keenable api_key 凭据 + --migrate 从旧 search.env 导入缺失的 key + --list 显示各 provider 配置状态(不输出 key) + --auth FILE 覆盖 auth.json 路径 + -h, --help 显示帮助 + +不传 key 参数时进入隐藏输入的交互模式;传入任意 key 参数时不交互, +未指定的 provider 保留已有值。凭据写入 Pi 的 auth.json +($PI_CODING_AGENT_DIR/auth.json 或 ~/.pi/agent/auth.json,权限 600)。 +key 支持 auth.json 原生语义:字面量、$ENV_VAR / ${ENV_VAR} 插值、 +以及 !command(执行命令取 stdout,如 "!op read op://Vault/Tavily/key")。 +EOF +} + +configure_search() { + SEARCH_AUTH_MJS="$SCRIPT_DIR/scripts/search-auth.mjs" + [ -f "$SEARCH_AUTH_MJS" ] || { warn "找不到 ${SEARCH_AUTH_MJS}"; exit 1; } + + SEARCH_AUTH_FILE= + search_mode=interactive + search_did_work=0 + + run_search_auth() { + if [ -n "$SEARCH_AUTH_FILE" ]; then + node "$SEARCH_AUTH_MJS" --auth "$SEARCH_AUTH_FILE" "$@" + else + node "$SEARCH_AUTH_MJS" "$@" + fi + } + + search_has_key() { + run_search_auth --has "$1" >/dev/null 2>&1 + } + + search_prompt_key() { + prompt_label=$1 + prompt_configured=$2 + if [ ! -t 0 ]; then + warn "交互模式需要终端;请改用 --tavily/--exa/--keenable 参数。" + return 2 + fi + if [ "$prompt_configured" -eq 1 ]; then + printf '%s API key(回车保留现有值): ' "$prompt_label" >&2 + else + printf '%s API key(回车跳过): ' "$prompt_label" >&2 + fi + stty -echo + IFS= read -r prompt_value || prompt_status=$? + stty echo + printf '\n' >&2 + [ "${prompt_status:-0}" -eq 0 ] || return "$prompt_status" + printf '%s\n' "$prompt_value" + } + + while [ "$#" -gt 0 ]; do + case $1 in + --tavily|--exa|--keenable) + [ "$#" -ge 2 ] || { warn "$1 缺少 KEY"; exit 2; } + search_mode=noninteractive; search_did_work=1 + run_search_auth --set "${1#--}=$2" || exit $? + shift 2 ;; + --migrate) + search_mode=noninteractive; search_did_work=1 + if [ "$#" -ge 2 ] && [ "${2#--}" = "$2" ]; then + run_search_auth --migrate "$2" || exit $? + shift 2 + else + run_search_auth --migrate || exit $? + shift + fi + say "如确认 auth.json 可用,可手动删除旧 search.env 文件。" ;; + --list) search_mode=noninteractive; search_did_work=1; run_search_auth --list || exit $?; shift ;; + --auth) + [ "$#" -ge 2 ] || { warn "--auth 缺少 FILE"; exit 2; } + SEARCH_AUTH_FILE=$2; shift 2 ;; + -h|--help) search_usage; exit 0 ;; + *) warn "未知参数:$1"; search_usage >&2; exit 2 ;; + esac + done + + if [ "$search_mode" = interactive ]; then + for provider in tavily exa keenable; do + case $provider in + tavily) label=Tavily ;; + exa) label=Exa ;; + keenable) label=Keenable ;; + esac + if search_has_key "$provider"; then configured=1; else configured=0; fi + value=$(search_prompt_key "$label" "$configured") || exit $? + if [ -n "$value" ]; then + run_search_auth --set "$provider=$value" || exit $? + search_did_work=1 + fi + done + [ "$search_did_work" -eq 1 ] || say "未提供任何 key,auth.json 保持不变。" + fi +} + +case ${1:-} in + search) + shift + configure_search "$@" + ;; + ssh) + shift + exec node "$SCRIPT_DIR/pi-ssh/scripts/ssh-config-launcher.mjs" "$@" + ;; + *) + usage + exit 2 + ;; +esac diff --git a/extensions/permission-system.ts b/extensions/permission-system.ts index 304db8b..5708a9a 100644 --- a/extensions/permission-system.ts +++ b/extensions/permission-system.ts @@ -4,9 +4,9 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; /** * Load the bundled permission system directly. * - * Its bundle-owned config (policy.jsonc, hard-guards.jsonc) is deployed by - * install.sh / update.sh into `/my-pi/permission-system/`; the - * extension itself never seeds or migrates configuration. + * Its bundle-owned config (pi-permission-policy.jsonc, pi-permission-hard-guards.jsonc) + * is read directly from the installed bundle's `config/` directory; the + * extension itself never seeds, deploys, or migrates configuration. */ export default function bundledPermissionSystemExtension(pi: ExtensionAPI): void { permissionSystemExtension(pi); diff --git a/extensions/system-prompt-cover-config.ts b/extensions/system-prompt-cover-config.ts index a4ea6c5..69a0f5b 100644 --- a/extensions/system-prompt-cover-config.ts +++ b/extensions/system-prompt-cover-config.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import { fileURLToPath } from "node:url"; -/** File name of the bundle-managed system prompt cover inside `/my-pi/`. */ +/** File name of the bundle-managed system prompt cover inside the bundle's `config/` directory. */ export const COVER_FILE_NAME = "system-prompt-cover.md"; /** @@ -14,9 +14,12 @@ export const COVER_FILE_NAME = "system-prompt-cover.md"; */ const FIRST_SECTION_BOUNDARY = /\n<[a-z][a-z0-9_-]*>\n/; -/** Resolve the cover path inside a given agent directory's bundle-owned `my-pi/` tree. */ -export function resolveCoverPath(agentDir: string): string { - return join(agentDir, "my-pi", COVER_FILE_NAME); +/** + * Resolve the cover path inside the installed bundle's `config/` directory — + * the single source of truth, read directly at runtime with no deployment step. + */ +export function resolveCoverPath(): string { + return fileURLToPath(new URL(`../config/${COVER_FILE_NAME}`, import.meta.url)); } /** diff --git a/extensions/system-prompt-cover.ts b/extensions/system-prompt-cover.ts index 836d5af..7fc0cb0 100644 --- a/extensions/system-prompt-cover.ts +++ b/extensions/system-prompt-cover.ts @@ -1,4 +1,4 @@ -import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { readCoverFile, resolveCoverPath, spliceCover } from "./system-prompt-cover-config.ts"; @@ -19,7 +19,7 @@ export { COVER_FILE_NAME, readCoverFile, resolveCoverPath, spliceCover } from ". */ export default function systemPromptCoverExtension(pi: ExtensionAPI): void { pi.on("before_agent_start", (event) => { - const cover = readCoverFile(resolveCoverPath(getAgentDir())); + const cover = readCoverFile(resolveCoverPath()); if (cover === undefined) { return; } diff --git a/install.sh b/install.sh index b453193..d46667c 100755 --- a/install.sh +++ b/install.sh @@ -3,13 +3,13 @@ set -u SCRIPT_DIR=$(CDPATH=; cd "$(dirname "$0")" && pwd) || exit 1 -MANAGED_CONFIG_HELPERS=$SCRIPT_DIR/scripts/managed-config.sh -if [ ! -f "$MANAGED_CONFIG_HELPERS" ]; then - printf '错误: 找不到受管配置辅助脚本:%s\n' "$MANAGED_CONFIG_HELPERS" >&2 +MY_PI_LIB=$SCRIPT_DIR/scripts/lib.sh +if [ ! -f "$MY_PI_LIB" ]; then + printf '错误: 找不到共享辅助脚本:%s\n' "$MY_PI_LIB" >&2 exit 1 fi -# shellcheck source=scripts/managed-config.sh -. "$MANAGED_CONFIG_HELPERS" +# shellcheck source=scripts/lib.sh +. "$MY_PI_LIB" PACKAGE_SOURCE=${PI_PACKAGE_SOURCE:-git:git@bitbucket.org:siakitem/my-pi.git} POWERLEVEL10K_PRESET_FILE=$SCRIPT_DIR/config/p10k.zsh @@ -21,14 +21,6 @@ ZSH_AUTOSUGGESTIONS_REPOSITORY=https://github.com/zsh-users/zsh-autosuggestions. ZSH_SYNTAX_HIGHLIGHTING_REPOSITORY=https://github.com/zsh-users/zsh-syntax-highlighting.git install_failed=0 -say() { - printf '%s\n' "$*" -} - -warn() { - printf '警告: %s\n' "$*" >&2 -} - ask_yes_no() { prompt=$1 printf '%s [y/N] ' "$prompt" @@ -64,42 +56,6 @@ record_install_failure() { install_failed=1 } -resolve_agent_dir() { - if [ -n "${PI_CODING_AGENT_DIR:-}" ]; then - case $PI_CODING_AGENT_DIR in - ~/*) printf '%s\n' "$HOME/${PI_CODING_AGENT_DIR#~/}" ;; - *) printf '%s\n' "$PI_CODING_AGENT_DIR" ;; - esac - return 0 - fi - printf '%s\n' "$HOME/.pi/agent" -} - -# 整体删除并重建组合包拥有的 /my-pi/ 配置目录;脚本目录内的组合包副本即为来源。 -rebuild_bundle_config() { - rebuild_helper=$SCRIPT_DIR/scripts/rebuild-bundle-config.mjs - if [ ! -f "$rebuild_helper" ]; then - warn "找不到组合包配置重建辅助脚本,已跳过配置重建。" - return 1 - fi - if ! command -v node >/dev/null 2>&1; then - warn "找不到 node,无法重建组合包配置目录。" - return 1 - fi - rebuild_agent_dir=$(resolve_agent_dir) || return 1 - rebuild_status=$(node "$rebuild_helper" "$rebuild_agent_dir" 2>&1) || { - warn "组合包配置目录重建失败:$rebuild_status" - return 1 - } - case $rebuild_status in - rebuilt:*) say "✓ 已重建组合包配置目录:${rebuild_status#rebuilt:}" ;; - *) - warn "组合包配置重建返回未知状态:$rebuild_status" - return 1 - ;; - esac -} - java_major_version() { java_version=$(java -version 2>&1 | awk -F '"' 'NR == 1 { print $2 }') case $java_version in @@ -117,7 +73,13 @@ install_codegraph() { warn "安装 CodeGraph 需要 curl。" return 1 fi - curl -fsSL "$CODEGRAPH_INSTALL_URL" | sh || return 1 + codegraph_installer=$(mktemp "${TMPDIR:-/tmp}/my-pi-codegraph.XXXXXX") || return 1 + if ! curl -fsSL "$CODEGRAPH_INSTALL_URL" -o "$codegraph_installer" || \ + ! sh "$codegraph_installer"; then + rm -f "$codegraph_installer" + return 1 + fi + rm -f "$codegraph_installer" # 官方安装器默认使用 ~/.local/bin;让本次检查立即看到新命令。 if [ -d "$HOME/.local/bin" ]; then @@ -152,21 +114,6 @@ install_jdtls() { command -v jdtls >/dev/null 2>&1 } -detect_kitty() { - kitty_bin= - if command -v kitty >/dev/null 2>&1; then - kitty_bin=$(command -v kitty) - return 0 - fi - for candidate in /Applications/kitty.app/Contents/MacOS/kitty "$HOME/.local/kitty.app/bin/kitty"; do - if [ -x "$candidate" ]; then - kitty_bin=$candidate - return 0 - fi - done - return 1 -} - install_kitty() { case $(uname -s) in Darwin) @@ -178,16 +125,18 @@ install_kitty() { warn "安装 Kitty 需要 curl。" return 1 fi - curl -L "$KITTY_INSTALL_URL" | sh /dev/stdin || return 1 + installer_file=$(mktemp "${TMPDIR:-/tmp}/my-pi-kitty.XXXXXX") || return 1 + if ! curl -fsSL "$KITTY_INSTALL_URL" -o "$installer_file" || \ + ! sh "$installer_file"; then + rm -f "$installer_file" + return 1 + fi + rm -f "$installer_file" ;; esac detect_kitty } -resolve_kitty_config_dir() { - kitty_config_dir=${KITTY_CONFIG_DIRECTORY:-${XDG_CONFIG_HOME:-$HOME/.config}/kitty} -} - kitty_solarized_dark_configured() { resolve_kitty_config_dir [ -f "$kitty_config_dir/current-theme.conf" ] || return 1 @@ -201,14 +150,6 @@ configure_kitty_solarized_dark() { "$kitty_bin" +kitten themes --reload-in=none "Solarized Dark" } -resolve_zsh_paths() { - oh_my_zsh_dir=${ZSH:-$HOME/.oh-my-zsh} - zsh_custom_dir=${ZSH_CUSTOM:-$oh_my_zsh_dir/custom} - zshrc_file=${ZDOTDIR:-$HOME}/.zshrc - my_pi_config_dir=${XDG_CONFIG_HOME:-$HOME/.config}/my-pi - p10k_managed_config_file=$my_pi_config_dir/p10k.zsh -} - oh_my_zsh_installed() { resolve_zsh_paths [ -f "$oh_my_zsh_dir/oh-my-zsh.sh" ] @@ -238,20 +179,6 @@ prepare_zshrc() { [ -f "$zshrc_file" ] || : > "$zshrc_file" || return 1 } -replace_zsh_managed_block() { - managed_id=$1 - managed_placement=$2 - managed_anchor=$3 - shift 3 - managed_content=$(mktemp "${TMPDIR:-/tmp}/my-pi-zsh-block.XXXXXX") || return 1 - printf '%s\n' "$@" > "$managed_content" - my_pi_replace_managed_block "$zshrc_file" "$managed_id" "$managed_content" \ - "$managed_placement" "$managed_anchor" - managed_status=$? - rm -f "$managed_content" - return "$managed_status" -} - zshrc_has_oh_my_zsh_loader() { [ -f "$zshrc_file" ] || return 1 grep -E '^[[:space:]]*(source|\.)[[:space:]].*oh-my-zsh\.sh' "$zshrc_file" >/dev/null 2>&1 @@ -270,7 +197,7 @@ ensure_oh_my_zsh_loader() { set_powerlevel10k_theme() { prepare_zshrc || return 1 replace_zsh_managed_block "powerlevel10k-theme" "before" \ - '^(# >>> my-pi:oh-my-zsh-loader >>>|[[:space:]]*(source|\.)[[:space:]].*oh-my-zsh\.sh)' \ + "$MY_PI_ZSH_LOADER_ANCHOR" \ 'ZSH_THEME="powerlevel10k/powerlevel10k"' } @@ -327,7 +254,7 @@ add_zsh_plugin() { prepare_zshrc || return 1 ensure_oh_my_zsh_loader || return 1 replace_zsh_managed_block "zsh-plugin-$plugin" "before" \ - '^(# >>> my-pi:oh-my-zsh-loader >>>|[[:space:]]*(source|\.)[[:space:]].*oh-my-zsh\.sh)' \ + "$MY_PI_ZSH_LOADER_ANCHOR" \ "plugins+=($plugin)" } @@ -369,9 +296,7 @@ if ! pi install "$PACKAGE_SOURCE"; then exit 1 fi -say "Pi 组合包安装完成,开始部署受管配置。" - -rebuild_bundle_config || record_install_failure "组合包配置重建" +say "Pi 组合包安装完成。" say "开始检查终端环境和机器级 CLI 依赖。" diff --git a/package.json b/package.json index 58a5fbc..b365360 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ "pi-permission-system/LICENSES", "pi-permission-system/docs/auto-review", "pi-permission-system/src", - "pi-permission-system/config", "pi-permission-system/schemas", "pi-permission-system/package.json", "pi-permission-system/README.md", @@ -42,8 +41,7 @@ "install.sh", "update.sh", "uninstall.sh", - "search_config.sh", - "ssh_config.sh", + "configure.sh", "README.md", "AGENTS.md" ], diff --git a/pi-compaction/README.md b/pi-compaction/README.md index 8ed1409..d83a09f 100644 --- a/pi-compaction/README.md +++ b/pi-compaction/README.md @@ -17,40 +17,38 @@ Context Mode remains responsible for preventing large external data from enterin ## Configuration -Set `piCompaction` in the Pi agent `settings.json`: +Configuration lives in the installed bundle's `config/pi-compaction.json` (the bundle repository's `config/` directory is the single source of truth; `pi update` refreshes the installed copy). The extension never reads `settings.json` and never writes config at runtime: ```json { - "piCompaction": { + "enabled": true, + "softRatio": 0.72, + "hardRatio": 0.9, + "targetRatio": 0.55, + "deterministic": { "enabled": true, - "softRatio": 0.72, - "hardRatio": 0.9, - "targetRatio": 0.55, - "deterministic": { - "enabled": true, - "maxStubTokens": 256 - }, - "semantic": { - "enabled": true, - "model": "default", - "thinking": "low", - "concurrency": 1, - "minInputChars": 4000, - "maxInputChars": 24000, - "maxRetries": 2 - }, - "native": { - "enabled": true - } + "maxStubTokens": 1024 + }, + "semantic": { + "enabled": true, + "model": "default", + "thinking": "low", + "concurrency": 1, + "minInputChars": 4000, + "maxInputChars": 24000, + "maxRetries": 2 + }, + "native": { + "enabled": true } } ``` -The extension never writes settings. Invalid or missing fields are normalized in memory. +Invalid or missing fields are normalized in memory. ## Native providers -Native compaction is enabled by `piCompaction.native.enabled` and uses the model selected in the session when compaction starts. It does not select a separate summarization model. +Native compaction is enabled by the `native.enabled` config key and uses the model selected in the session when compaction starts. It does not select a separate summarization model. | Provider | API | Native transport | | --- | --- | --- | diff --git a/pi-compaction/src/config.ts b/pi-compaction/src/config.ts index 740e007..36a378e 100644 --- a/pi-compaction/src/config.ts +++ b/pi-compaction/src/config.ts @@ -1,9 +1,14 @@ import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { getAgentDir } from "@earendil-works/pi-coding-agent"; +import { fileURLToPath } from "node:url"; import type { PiCompactionConfig } from "./types.js"; -export const SETTINGS_KEY = "piCompaction" as const; +// Bundle-level `config/` is the single source of truth: the extension reads +// `config/pi-compaction.json` from inside the installed bundle (resolved +// relative to this source file). It never reads `settings.json` and never +// writes config at runtime. +export const CONFIG_PATH = fileURLToPath( + new URL("../../config/pi-compaction.json", import.meta.url), +); export const DEFAULT_CONFIG: PiCompactionConfig = { enabled: true, @@ -78,10 +83,12 @@ export function normalizeConfig(value: unknown): PiCompactionConfig { }; } -export async function loadConfig(): Promise { +export async function loadConfig( + configPath: string = CONFIG_PATH, +): Promise { try { - const parsed = JSON.parse(await readFile(join(getAgentDir(), "settings.json"), "utf8")); - return normalizeConfig(object(parsed)[SETTINGS_KEY]); + const parsed = JSON.parse(await readFile(configPath, "utf8")); + return normalizeConfig(parsed); } catch { return structuredClone(DEFAULT_CONFIG); } diff --git a/pi-hermes-memory/README.md b/pi-hermes-memory/README.md index ed37b82..29f38b7 100644 --- a/pi-hermes-memory/README.md +++ b/pi-hermes-memory/README.md @@ -511,7 +511,7 @@ Move behavior: ## Configuration -Create `~/.pi/agent/hermes-memory-config.json`: +Configuration is read from the installed bundle's `config/pi-hermes-memory.json` — the bundle repository's `config/` directory is the single source of truth, refreshed by `pi update`. The shipped file only contains machine-independent values; `memoryDir`, `projectsMemoryDir`, and `childExtensionPaths` are machine-dependent and omitted so the code computes them at runtime. A leftover `~/.pi/agent/hermes-memory-config.json` is ignored. All supported keys: ```json { @@ -708,7 +708,6 @@ native install scripts are disabled because the fixture writes no memories. │ │ └── SKILL.md │ └── another-project/ │ └── MEMORY.md -├── hermes-memory-config.json └── ... ``` diff --git a/pi-hermes-memory/UPSTREAM.md b/pi-hermes-memory/UPSTREAM.md index 14f5187..3d9f099 100644 --- a/pi-hermes-memory/UPSTREAM.md +++ b/pi-hermes-memory/UPSTREAM.md @@ -36,8 +36,6 @@ - 全局记忆与 skill:`~/.pi/agent/pi-hermes-memory/` - 项目记忆与 skill:`~/.pi/agent/projects-memory/<项目>/` -- 配置:`~/.pi/agent/hermes-memory-config.json`(可选,缺省时使用内置默认值) - -这些路径是用户数据,不属于组合包,也不应提交或自动清理。 +以上路径是用户数据,不属于组合包,也不应提交或自动清理。组合包配置位于已安装组合包的 `config/pi-hermes-memory.json`,随 `pi update` 刷新;旧 `~/.pi/agent/hermes-memory-config.json` 被忽略。 同步上游时,请从明确 tag/commit 移植改动,并与本地改动对照审查,不得整体覆盖本目录。 diff --git a/pi-hermes-memory/scripts/benchmark-memory-startup.mjs b/pi-hermes-memory/scripts/benchmark-memory-startup.mjs index ad00022..060b474 100644 --- a/pi-hermes-memory/scripts/benchmark-memory-startup.mjs +++ b/pi-hermes-memory/scripts/benchmark-memory-startup.mjs @@ -24,9 +24,10 @@ const emit = async (event, data = {}) => { try { process.env.PI_CODING_AGENT_DIR = root; + process.env.PI_HERMES_MEMORY_CONFIG_PATH = join(root, "hermes-memory-config.json"); await mkdir(globalDir); await mkdir(ctx.cwd); - await writeFile(join(root, "hermes-memory-config.json"), JSON.stringify({ + await writeFile(process.env.PI_HERMES_MEMORY_CONFIG_PATH, JSON.stringify({ lazyInitialization: lazy, memoryMode: "policy-only", reviewEnabled: false, correctionDetection: false, flushOnShutdown: false, flushOnCompact: false, diff --git a/pi-hermes-memory/src/config.ts b/pi-hermes-memory/src/config.ts index d7c9064..0ca1a31 100644 --- a/pi-hermes-memory/src/config.ts +++ b/pi-hermes-memory/src/config.ts @@ -1,5 +1,5 @@ import * as fs from "node:fs"; -import * as path from "node:path"; +import { fileURLToPath } from "node:url"; import type { MemoryConfig, MemoryOverflowStrategy, ReviewTransport, SessionSearchVariant, ThinkingLevel } from "./types.js"; import { DEFAULT_MEMORY_CHAR_LIMIT, @@ -21,7 +21,7 @@ import { DEFAULT_FAILURE_INJECTION_MAX_ENTRIES, DEFAULT_SESSION_RETENTION_DAYS, } from "./constants.js"; -import { AGENT_ROOT, normalizeConfiguredMemoryDir, normalizeProjectsMemoryDir } from "./paths.js"; +import { normalizeConfiguredMemoryDir, normalizeProjectsMemoryDir } from "./paths.js"; const MEMORY_OVERFLOW_STRATEGIES: readonly MemoryOverflowStrategy[] = ["auto-consolidate", "reject", "fifo-evict"]; const SESSION_SEARCH_VARIANTS: readonly SessionSearchVariant[] = ["legacy", "anchors"]; @@ -81,12 +81,24 @@ const DEFAULT_CONFIG: MemoryConfig = { sessionRetentionDays: DEFAULT_SESSION_RETENTION_DAYS, }; -export const DEFAULT_CONFIG_PATH = path.join( - AGENT_ROOT, - "hermes-memory-config.json", +// Bundle-level `config/` is the single source of truth: the extension reads +// `config/pi-hermes-memory.json` from inside the installed bundle (resolved +// relative to this source file). Memory, sessions, and credentials stay under +// the agent dir — they are user data, not bundle config. +export const DEFAULT_CONFIG_PATH = fileURLToPath( + new URL("../../config/pi-hermes-memory.json", import.meta.url), ); -export function loadConfig(configPath = DEFAULT_CONFIG_PATH): MemoryConfig { +/** + * Resolve the effective config path. `PI_HERMES_MEMORY_CONFIG_PATH` relocates + * the file (tests point it at a temp file; it is never a persisted config + * value). + */ +export function resolveConfigPath(): string { + return process.env.PI_HERMES_MEMORY_CONFIG_PATH ?? DEFAULT_CONFIG_PATH; +} + +export function loadConfig(configPath = resolveConfigPath()): MemoryConfig { try { if (fs.existsSync(configPath)) { const raw = fs.readFileSync(configPath, "utf-8"); diff --git a/pi-hermes-memory/tests/lazy-startup.test.ts b/pi-hermes-memory/tests/lazy-startup.test.ts index 4d92f2f..eba4cd2 100644 --- a/pi-hermes-memory/tests/lazy-startup.test.ts +++ b/pi-hermes-memory/tests/lazy-startup.test.ts @@ -8,7 +8,9 @@ import { after, beforeEach, afterEach, describe, it } from "node:test"; // Bind the entry point to a disposable agent root, never the user's memory. const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-lazy-startup-")); const previousRoot = process.env.PI_CODING_AGENT_DIR; +const previousConfigPath = process.env.PI_HERMES_MEMORY_CONFIG_PATH; process.env.PI_CODING_AGENT_DIR = root; +process.env.PI_HERMES_MEMORY_CONFIG_PATH = path.join(root, "hermes-memory-config.json"); const { default: registerExtension } = await import("../src/index.js"); const { MemoryStore } = await import("../src/store/memory-store.js"); const { DatabaseManager } = await import("../src/store/db.js"); @@ -85,6 +87,8 @@ afterEach(async () => { after(async () => { if (previousRoot === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = previousRoot; + if (previousConfigPath === undefined) delete process.env.PI_HERMES_MEMORY_CONFIG_PATH; + else process.env.PI_HERMES_MEMORY_CONFIG_PATH = previousConfigPath; await fs.rm(root, { recursive: true, force: true }); }); diff --git a/pi-hermes-memory/tests/project-rebinding.test.ts b/pi-hermes-memory/tests/project-rebinding.test.ts index 82e7742..30d1b40 100644 --- a/pi-hermes-memory/tests/project-rebinding.test.ts +++ b/pi-hermes-memory/tests/project-rebinding.test.ts @@ -18,6 +18,7 @@ describe("session project memory rebinding", () => { const launchDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-project-rebind-launch-")); const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-project-rebind-target-")); const previousAgentRoot = process.env.PI_CODING_AGENT_DIR; + const previousConfigPath = process.env.PI_HERMES_MEMORY_CONFIG_PATH; const previousCwd = process.cwd(); try { await fs.writeFile( @@ -46,6 +47,7 @@ describe("session project memory rebinding", () => { ); process.env.PI_CODING_AGENT_DIR = agentRoot; + process.env.PI_HERMES_MEMORY_CONFIG_PATH = path.join(agentRoot, "hermes-memory-config.json"); process.chdir(launchDir); // Import after setting PI_CODING_AGENT_DIR so AGENT_ROOT is test-local. const { default: registerExtension } = await import("../src/index.js"); @@ -101,6 +103,8 @@ describe("session project memory rebinding", () => { process.chdir(previousCwd); if (previousAgentRoot === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = previousAgentRoot; + if (previousConfigPath === undefined) delete process.env.PI_HERMES_MEMORY_CONFIG_PATH; + else process.env.PI_HERMES_MEMORY_CONFIG_PATH = previousConfigPath; await fs.rm(agentRoot, { recursive: true, force: true }); await fs.rm(launchDir, { recursive: true, force: true }); await fs.rm(targetDir, { recursive: true, force: true }); diff --git a/pi-hermes-memory/tests/prompt-injection.test.ts b/pi-hermes-memory/tests/prompt-injection.test.ts index fb15453..a446d40 100644 --- a/pi-hermes-memory/tests/prompt-injection.test.ts +++ b/pi-hermes-memory/tests/prompt-injection.test.ts @@ -22,7 +22,9 @@ import { after, beforeEach, describe, it } from "node:test"; // Bind the entry point to a disposable agent root, never the user's memory. const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-prompt-injection-")); const previousRoot = process.env.PI_CODING_AGENT_DIR; +const previousConfigPath = process.env.PI_HERMES_MEMORY_CONFIG_PATH; process.env.PI_CODING_AGENT_DIR = root; +process.env.PI_HERMES_MEMORY_CONFIG_PATH = path.join(root, "hermes-memory-config.json"); const { default: registerExtension } = await import("../src/index.js"); const globalDir = path.join(root, "pi-hermes-memory"); const cwd = path.join(root, "workspace"); @@ -111,6 +113,8 @@ beforeEach(async () => { after(async () => { if (previousRoot === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = previousRoot; + if (previousConfigPath === undefined) delete process.env.PI_HERMES_MEMORY_CONFIG_PATH; + else process.env.PI_HERMES_MEMORY_CONFIG_PATH = previousConfigPath; try { await fs.rm(root, { recursive: true, force: true }); } catch { diff --git a/pi-permission-system/README.md b/pi-permission-system/README.md index 7d60c8a..6575171 100644 --- a/pi-permission-system/README.md +++ b/pi-permission-system/README.md @@ -19,6 +19,7 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin - **Uses one global ordinary policy file**; project `policy.jsonc` files are not loaded - **Chooses the most specific matching pattern**, so a specific deny wins over a broad `"*": "allow"` regardless of declaration order - **Applies a separate hard-guard policy first** so session grants, per-agent rules, and model review cannot override high-risk denies +- **Keeps protected writes human-only**: files listed in `protectedWrites` (Pi credentials, settings, the installed bundle itself, …) always reach the local human prompt — never AutoReview, never yolo, never a session grant — and are denied when no confirmation UI exists. Reads are unaffected. - **Controls Bash, paths, MCP, skills, and tool surfaces** while leaving active-tool ownership entirely to Tool Search - **Reviews ordinary `ask` requests automatically** with integrated AutoReview; its `allow` verdict is final for ordinary tools and `path`, while `defer` reaches the next authorizer or human terminal - **Follows the session's current model**: AutoReview asks the model you are already chatting with to judge each ask, so the review always reflects the model the operator actually selected; set `autoReview.provider` and `autoReview.model` together to pin a different reviewer @@ -34,7 +35,7 @@ pi install npm:@gotgenes/pi-permission-system ## Quick Start -1. Create ordinary policy at `~/.pi/agent/my-pi/permission-system/policy.jsonc`: +1. Ordinary policy lives in the installed bundle at `config/pi-permission-policy.jsonc` (the bundle repository's `config/` directory — edit it there for durable changes): ```jsonc { @@ -51,7 +52,7 @@ pi install npm:@gotgenes/pi-permission-system } ``` -2. Create independent hard guards at `~/.pi/agent/my-pi/permission-system/hard-guards.jsonc`: +2. Independent hard guards live beside it at `config/pi-permission-hard-guards.jsonc`: ```jsonc { @@ -62,7 +63,10 @@ pi install npm:@gotgenes/pi-permission-system "*.env.*": "deny", "*.env.example": "allow", "~/.ssh/**": "deny" - } + }, + "protectedWrites": [ + { "pattern": "{agentDir}/auth.json", "label": "Pi credentials" } + ] } ``` @@ -86,12 +90,12 @@ The integrated extension uses two independent global JSONC files: | Purpose | Scope | Path | | ------- | ----- | ---- | -| Ordinary policy, runtime knobs, AutoReview | Global only | `~/.pi/agent/my-pi/permission-system/policy.jsonc` | -| Non-overridable hard guards | Global only | `~/.pi/agent/my-pi/permission-system/hard-guards.jsonc` | +| Ordinary policy, runtime knobs, AutoReview | Global only | `config/pi-permission-policy.jsonc` inside the installed bundle | +| Non-overridable hard guards | Global only | `config/pi-permission-hard-guards.jsonc` inside the installed bundle | Project-level `policy.jsonc` files are ignored. Project agent Markdown remains a separate per-agent restriction mechanism. -The bundle deploys both files with mode `0600` via `scripts/rebuild-bundle-config.mjs`: every `install.sh` / `update.sh` run rebuilds the whole `/my-pi/` tree, overwriting local edits without a backup — keep durable changes in the repository's `config/` sources. Files left at the former `extensions/pi-permission-system/` location are ignored. +Both files are read directly from the bundle's `config/` directory — the single source of truth. Nothing is deployed to, seeded into, or migrated from the agent directory; `pi update` refreshes them with the bundle. Files left at the former `~/.pi/agent/my-pi/permission-system/` location are ignored. Within a pattern map, the most specific match wins: exact patterns, then more literal characters, fewer wildcards, and longer patterns. Declaration order is only a tie-breaker. @@ -99,7 +103,7 @@ There is no `external_directory` gate. Outside-CWD paths use the same configured Tool Search alone owns Pi's active tool list. Permission System only decides whether an invoked tool call is allowed, denied, or requires confirmation. -See [docs/configuration.md](docs/configuration.md) for the complete reference and [docs/hard-guards.md](docs/hard-guards.md) for the independent hard-guard format. +See [docs/configuration.md](docs/configuration.md) for the complete reference, including the [protected writes](docs/configuration.md#protected-writes) format in the hard-guard file. ## Documentation diff --git a/pi-permission-system/config/hard-guards.default.jsonc b/pi-permission-system/config/hard-guards.default.jsonc deleted file mode 100644 index 3aacaa1..0000000 --- a/pi-permission-system/config/hard-guards.default.jsonc +++ /dev/null @@ -1,17 +0,0 @@ -{ - // User-configurable, global-only hard boundary. Project policy, session - // grants and AutoReview cannot override a matching deny. - "version": 1, - "permission": { - "bash": { - "rm -rf /": "deny", - "rm -rf /*": "deny", - "rm -rf ~": "deny", - "rm -rf ~/*": "deny", - "sudo rm -rf /": "deny", - "sudo rm -rf /*": "deny", - "mkfs*": "deny", - "dd * of=/dev/*": "deny" - } - } -} diff --git a/pi-permission-system/docs/architecture/architecture.md b/pi-permission-system/docs/architecture/architecture.md index e9cd065..8ec85ae 100644 --- a/pi-permission-system/docs/architecture/architecture.md +++ b/pi-permission-system/docs/architecture/architecture.md @@ -517,6 +517,8 @@ The public authorizer registry remains available for other named authorities. Co Authorizers are named capabilities registered on `permissions:ready`; the integrated `auto-review` authorizer uses the same chain contract as third-party links. Once configured, a link's `allow` or `deny` is final for any ordinary-policy `ask`, including `path`; `defer` advances to the next link or terminal. +One configured path bypasses the chain entirely: `protectedWrites` in the hard-guard file marks write targets as human-only. The protected-write gate produces its `ask` last in the tool-call pipeline (a hard deny still blocks first); the descriptor's `humanOnly` marker skips the runner's session-approval and yolo fast paths, and `AuthorizerSelection` composes it with zero links so no link — including `auto-review` — is consulted. A `LocalUserAuthorizer` terminal prompts without a "for this session" option; any other terminal (`DenyingAuthorizer`, a `ParentAuthorizer` relay) denies without prompting. Reads never reach the gate. + This is the principled successor to the per-command argument-position work deferred from [#509]. The bash path projection surfaces a bare token that names a real file ([#645]) and deliberately accepts a fail-safe false positive (`grep id_rsa secrets.txt` prompts when an `id_rsa` file happens to exist); that false positive lives on the *ask-producing* side of `evaluate()`, and the model link dismisses it on the *ask-consuming* side without hard-coding per-command file-argument tables. This split is the layering principle of [ADR 0009](../decisions/0009-bash-path-projection-completeness-contract.md): the deterministic layer biases toward surfacing because over-suppression is unrecoverable, and the judge absorbs the surplus. @@ -578,7 +580,7 @@ src/ ├── index.ts Composition root and Pi event registration ├── config-loader.ts Global ordinary policy loading ├── policy-loader.ts Global policy + active-agent frontmatter gateway -├── hard-guard-policy-loader.ts Independent global-only hard-guard loading +├── hard-guard-loader.ts Independent global-only hard-guard + protectedWrites loading ├── rule.ts Pattern matching and specificity ranking ├── permission-manager.ts Unified intent evaluation ├── access-intent/ Resolved intent types and resolver diff --git a/pi-permission-system/docs/configuration.md b/pi-permission-system/docs/configuration.md index 150fcda..6724e68 100644 --- a/pi-permission-system/docs/configuration.md +++ b/pi-permission-system/docs/configuration.md @@ -2,16 +2,16 @@ ## Config File Locations -Pi Permission System reads two user-level JSONC files: +Pi Permission System reads two JSONC files from the installed bundle's `config/` directory (the single source of truth; `pi update` refreshes them with the bundle): | Purpose | Scope | Path | | ------- | ----- | ---- | -| Ordinary policy + runtime | Global only | `~/.pi/agent/my-pi/permission-system/policy.jsonc` | -| Hard guards | Global only | `~/.pi/agent/my-pi/permission-system/hard-guards.jsonc` | +| Ordinary policy + runtime | Global only | `config/pi-permission-policy.jsonc` | +| Hard guards | Global only | `config/pi-permission-hard-guards.jsonc` | -`PI_CODING_AGENT_DIR` replaces `~/.pi/agent` when set. Project-level `policy.jsonc` files are not loaded. Project agent Markdown may still provide per-agent restrictions; it is not a second ordinary policy file. +Project-level `policy.jsonc` files are not loaded. Project agent Markdown may still provide per-agent restrictions; it is not a second ordinary policy file. -Hard guards are evaluated first. Their matching `deny` decisions cannot be overridden by ordinary policy, session approvals, per-agent rules, or AutoReview. +Hard guards are evaluated first. Their matching `deny` decisions cannot be overridden by ordinary policy, session approvals, per-agent rules, or AutoReview. The same file's `protectedWrites` list marks files whose *writes* always reach a human prompt — see [Protected writes](#protected-writes). Tool Search is the only extension that controls Pi's active tool list. Permission System never hides, activates, or replaces tools; it only evaluates a tool when Pi calls it. @@ -37,6 +37,27 @@ This makes a broad `"*": "allow"` a fallback rather than an ordering trick. For There is no separate `external_directory` decision. A path outside the current working directory is evaluated by the same configured tool, `path`, and Bash rules as any other path. +## Protected writes + +`protectedWrites` in the hard-guard file lists files and subtrees whose **writes** must always be confirmed by a human: + +```jsonc +{ + "version": 1, + "permission": { "bash": { "rm -rf /": "deny" } }, + "protectedWrites": [ + { "pattern": "{agentDir}/auth.json", "label": "Pi credentials" }, + { "pattern": "{configHome}/my-pi/**", "label": "pi-ssh credential store" } + ] +} +``` + +Each entry is `{ "pattern": string, "label": string }`. `pattern` uses the same wildcard syntax as the `path` surface (`*` crosses `/`, so a trailing `/**` covers the whole subtree) and is matched against both the lexical and canonical (symlink-resolved) forms of a write target. `{agentDir}` expands to the Pi agent directory (`PI_CODING_AGENT_DIR`), `{configHome}` to `$XDG_CONFIG_HOME` or `~/.config`, and a leading `~/` to the home directory. `label` is the name shown in the prompt and review log. + +Counts as a write: a `write`/`edit` tool call's `input.path`, and a local `bash` command's redirect-out destinations and non-read-only program arguments — including a `bash -c`/`eval`-style opaque payload that textually contains a protected path, an unparseable command, and a relative write after `cd` into a protected directory. Reads of protected paths are unaffected: `cat` and friends are not writes, and neither is a redirect-in (`<`). Aliased shell tools (`shellTools`, e.g. `ssh_bash`) address remote filesystems and are never matched. + +A protected write produces a human-only `ask`: it skips the session-approval and yolo fast paths, composes with zero `authorizerChain` links (AutoReview is never consulted), and the dialog offers no "for this session" option. With no local confirmation UI — a headless session or a subagent relay terminal — the write is denied without prompting. A malformed `protectedWrites` entry makes the whole hard-guard file invalid and follows its fail-closed semantics. + ## Full Example ```jsonc @@ -244,6 +265,8 @@ Three invariants govern the chain: A named authorizer may return a final `allow` or `deny` for any request that ordinary policy resolved to `ask`, including `path`. There is no additional surface-based delegation cap. `defer` advances to the next configured authorizer and ultimately the human terminal. +The one exception is a [protected write](#protected-writes): its `ask` is human-only and never reaches the chain — `authorizerChain` links are not consulted, and a terminal that is not the local user prompt denies without prompting. + Extension authors register a link from `permissions:ready` via `getPermissionsService().registerAuthorizer(name, authorize)`. For integration details, see [Cross-Extension API](cross-extension-api.md). --- diff --git a/pi-permission-system/docs/opencode-compatibility.md b/pi-permission-system/docs/opencode-compatibility.md index 61b5cb1..94991b3 100644 --- a/pi-permission-system/docs/opencode-compatibility.md +++ b/pi-permission-system/docs/opencode-compatibility.md @@ -49,7 +49,7 @@ If your OpenCode config uses these features, the equivalent works in this extens | `mcp` surface | Not a documented permission surface | First-class with server/tool-level granularity | | Top-level string shorthand | `"permission": "allow"` sets all surfaces | Not supported; must use an object | | Per-agent config location | `agent` key in config JSON or YAML frontmatter | YAML frontmatter in agent `.md` files only | -| Config file paths | `~/.config/opencode/opencode.json` | `~/.pi/agent/my-pi/permission-system/policy.jsonc` plus global `hard-guards.jsonc` | +| Config file paths | `~/.config/opencode/opencode.json` | `config/pi-permission-policy.jsonc` plus `config/pi-permission-hard-guards.jsonc` inside the installed bundle | | Subagent prompt forwarding | Not documented | `ask` policies work in non-UI subagent contexts | | Infrastructure auto-allow | N/A | Read-only tools to Pi infra dirs bypass the gate | | Permission review log | No equivalent documented | Writes decisions to a JSONL audit log | diff --git a/pi-permission-system/docs/troubleshooting.md b/pi-permission-system/docs/troubleshooting.md index 84f0a42..f27f944 100644 --- a/pi-permission-system/docs/troubleshooting.md +++ b/pi-permission-system/docs/troubleshooting.md @@ -4,13 +4,15 @@ | Problem | Cause | Solution | | ----------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| Config not applied (everything asks) | File not found or parse error | Verify the global policy at `~/.pi/agent/my-pi/permission-system/policy.jsonc` (respects `PI_CODING_AGENT_DIR`); check JSONC syntax | +| Config not applied (everything asks) | File not found or parse error | Verify the global policy at `config/pi-permission-policy.jsonc` inside the installed bundle; check JSONC syntax | | Per-agent override not applied | Frontmatter parsing issue | Ensure `---` delimiters at file top; keep YAML simple; restart session | | Tool blocked as unregistered | Unknown tool name | Server tools arrive as `mcp____` under Pi's built-in MCP (check `/mcp`); a unified `mcp` proxy tool uses `{ "tool": "server:tool" }` | | `/skill:` blocked | Deny policy or confirmation unavailable | Check merged `skill` policy (global/project/agent layers). `ask` still requires UI or forwarded confirmation. | | External file path blocked | `external_directory` is `ask` without UI or `deny` | Allow/ask the permission or keep file tools inside the active working directory. | | Spurious external-path prompt for `cd && grep … ../path` | Relative path was resolved against cwd instead of the `cd` target | Fixed in current version — paths after a leading `cd &&` are resolved against the cd target, matching actual shell behavior. | | Permission prompt is too verbose | Generic extension tool input is large | Built-in file tools are summarized automatically; third-party tools are capped to a bounded one-line JSON preview. | +| A file write keeps prompting despite a session grant or yolo | The target is in `protectedWrites` | Working as designed — protected writes are human-only and never take a session grant. Remove the entry from `protectedWrites` in `config/pi-permission-hard-guards.jsonc` to lift it. | +| A file write is denied without any prompt | `protectedWrites` target, no confirmation UI (headless or subagent relay) | Working as designed — a human-only ask cannot be answered without a local UI. Run the write from an interactive session instead. | ## Diagnostic Logging @@ -22,17 +24,12 @@ This makes it easy to verify which files the extension actually loaded: ```jsonc { "event": "config.resolved", - "globalConfigPath": "/…/.pi/agent/my-pi/permission-system/policy.jsonc", + "globalConfigPath": "/…/bundle/config/pi-permission-policy.jsonc", "globalConfigExists": true, - "projectConfigPath": "/…/my-project/.pi/extensions/pi-permission-system/policy.jsonc", - "projectConfigExists": false, "agentsDir": "/…/.pi/agent/agents", "agentsDirExists": true, "projectAgentsDir": "/…/my-project/.pi/agents", - "projectAgentsDirExists": false, - "legacyGlobalPolicyDetected": false, - "legacyProjectPolicyDetected": false, - "legacyExtensionConfigDetected": false + "projectAgentsDirExists": false } ``` diff --git a/pi-permission-system/package.json b/pi-permission-system/package.json index c993531..f140aa2 100644 --- a/pi-permission-system/package.json +++ b/pi-permission-system/package.json @@ -18,7 +18,6 @@ "src", "dist", "config/config.example.json", - "config/hard-guards.default.jsonc", "schemas/permissions.schema.json", "schemas/hard-guards.schema.json", "docs/*.md", diff --git a/pi-permission-system/schemas/hard-guards.schema.json b/pi-permission-system/schemas/hard-guards.schema.json index a0db9e5..e9926eb 100644 --- a/pi-permission-system/schemas/hard-guards.schema.json +++ b/pi-permission-system/schemas/hard-guards.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://raw.githubusercontent.com/siakitem/my-pi/main/pi-permission-system/schemas/hard-guards.schema.json", "title": "Pi Permission System Hard Guards", - "description": "Global-only allow exceptions and non-overridable deny rules evaluated before ordinary policy, session grants, and AutoReview.", + "description": "Global-only allow exceptions and non-overridable deny rules evaluated before ordinary policy, session grants, and AutoReview, plus protectedWrites whose writes always escalate to a human prompt.", "type": "object", "properties": { "$schema": { "type": "string" }, @@ -18,6 +18,19 @@ } ] } + }, + "protectedWrites": { + "type": "array", + "description": "Files and subtrees whose writes are human-only: every write goes to a local human prompt, skipping AutoReview, yolo, and session grants, and is denied when no UI is available. Patterns use the path surface wildcard syntax; {agentDir}, {configHome} and a leading ~/ are expanded at load.", + "items": { + "type": "object", + "properties": { + "pattern": { "type": "string", "minLength": 1 }, + "label": { "type": "string", "minLength": 1 } + }, + "required": ["pattern", "label"], + "additionalProperties": false + } } }, "required": ["version", "permission"], diff --git a/pi-permission-system/schemas/permissions.schema.json b/pi-permission-system/schemas/permissions.schema.json index 6ff6476..6d37c86 100644 --- a/pi-permission-system/schemas/permissions.schema.json +++ b/pi-permission-system/schemas/permissions.schema.json @@ -266,7 +266,7 @@ "additionalProperties": false, "title": "PI Permission System Configuration", "description": "Ordinary JSONC policy combining runtime knobs, AutoReview settings, and flat permission rules for pi-permission-system.", - "markdownDescription": "Global ordinary JSONC policy for [pi-permission-system](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system).\n\nPlace at `~/.pi/agent/my-pi/permission-system/policy.jsonc`. Project-level policy files are not loaded. Global-only hard guards use the separate `hard-guards.jsonc` schema.", + "markdownDescription": "Global ordinary JSONC policy for [pi-permission-system](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system).\n\nRead directly from the installed bundle's `config/pi-permission-policy.jsonc`. Project-level policy files are not loaded. Global-only hard guards use the separate `config/pi-permission-hard-guards.jsonc` schema.", "$defs": { "permissionState": { "anyOf": [ diff --git a/pi-permission-system/src/access-intent/bash/bash-path-resolver.ts b/pi-permission-system/src/access-intent/bash/bash-path-resolver.ts index cb1cc21..dc9929a 100644 --- a/pi-permission-system/src/access-intent/bash/bash-path-resolver.ts +++ b/pi-permission-system/src/access-intent/bash/bash-path-resolver.ts @@ -9,9 +9,11 @@ import { classifyTokenAsRuleCandidate, } from "#src/access-intent/bash/token-classification"; import { - collectCommandTokens, - collectPathCandidateTokens, - collectRedirectTokens, + type BashPathTokenRole, + type CollectedPathToken, + collectCommandTokensDetailed, + collectPathCandidateTokensDetailed, + collectRedirectTokensDetailed, extractCommandName, } from "#src/access-intent/bash/token-collection"; import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalization"; @@ -40,6 +42,9 @@ type EffectiveBase = interface PathCandidate { readonly token: string; readonly base: EffectiveBase; + readonly role: BashPathTokenRole; + readonly program: string | null; + readonly wrapped: boolean; } // ── Public output types ────────────────────────────────────────────────────── @@ -49,13 +54,41 @@ export interface BashPathRuleCandidate { readonly token: string; /** The path's lexical and canonical forms for permission policy matching. */ readonly path: AccessPath; + /** + * How the command uses the token — `redirect-out`/`redirect-in` for + * redirect destinations, `argument` otherwise. Only the protected-write + * gate reads this; the ordinary path gate is role-agnostic. + */ + readonly role: BashPathTokenRole; + /** The enclosing command's program name, or `null` outside a command. */ + readonly program: string | null; + /** + * True inside a wrapper payload (`bash -c`, `eval`, `sudo`, `xargs`, …) or a + * command/process substitution — the enclosing program cannot vouch for the + * token's use. + */ + readonly wrapped: boolean; } /** Cd-aware path-rule candidates extracted from a bash program. */ export interface ResolvedBashPaths { readonly ruleCandidates: readonly BashPathRuleCandidate[]; + /** + * The protected-write slice: every `ruleCandidates` entry plus every + * `redirect-out` destination, which is admitted unconditionally — a redirect + * target need not exist nor look like a path to still be written (`cd dir + * && echo 1 > new.json`). Role metadata is what the gate classifies on. + */ + readonly writeCandidates: readonly BashPathRuleCandidate[]; } +/** + * Redirect targets that are really file-descriptor plumbing, not files: + * `>&1`, `2>&-` and friends write to a descriptor, so they never enter the + * write slice. + */ +const FD_DUPLICATE_TARGET = /^&?[\d-]+$/; + // ── Walk-time constants ────────────────────────────────────────────────────── /** The working directory in force at the start of a program (`cwd`). */ @@ -101,9 +134,7 @@ export class BashPathResolver { ? CWD_BASE : this.deriveBaseFromCdTarget(CWD_BASE, this.workdir); const candidates = this.collectPathCandidates(rootNode, initialBase); - return { - ruleCandidates: this.projectRuleCandidates(candidates), - }; + return this.projectCandidates(candidates); } // ── AST walk — collect PathCandidates ────────────────────────────────── @@ -145,7 +176,7 @@ export class BashPathResolver { case "redirected_statement": return this.walkCurrentShellSequence(node, base, out); case "command": - tagTokens(collectCommandTokens(node), base, out); + tagTokens(collectCommandTokensDetailed(node), base, out); return this.foldCd(node, base); case "pipeline": // tree-sitter-bash mis-groups a redirect-bearing `&&`/`;` list as the @@ -170,7 +201,7 @@ export class BashPathResolver { // substitution interiors: collect every candidate in the subtree tagged // with the enclosing base and do not fold their internal `cd`s. (Folding // inside substitutions is deferred — conservative, never under-flags.) - tagTokens(collectPathCandidateTokens(node), base, out); + tagTokens(collectPathCandidateTokensDetailed(node), base, out); return base; } } @@ -230,7 +261,7 @@ export class BashPathResolver { } // Downstream stage (after a `|`): subshell — collect against the folded // base, do not fold. - tagTokens(collectPathCandidateTokens(child), current, out); + tagTokens(collectPathCandidateTokensDetailed(child), current, out); } return current; } @@ -260,7 +291,7 @@ export class BashPathResolver { if (child.type === "file_redirect") { // Redirect destinations are part of the piped stage; collect them // against the folded base without folding. - tagTokens(collectRedirectTokens(child), current, out); + tagTokens(collectRedirectTokensDetailed(child), current, out); continue; } // The inner statement is the `list`/`command` being redirected; fold its @@ -270,7 +301,7 @@ export class BashPathResolver { return current; } // Bare `command` or any other shape: a true subshell first stage. - tagTokens(collectPathCandidateTokens(node), base, out); + tagTokens(collectPathCandidateTokensDetailed(node), base, out); return base; } @@ -298,7 +329,7 @@ export class BashPathResolver { current = this.walkForCandidates(child, current, out); } else { // Terminal child = the real pipe stage; collect without folding. - tagTokens(collectPathCandidateTokens(child), current, out); + tagTokens(collectPathCandidateTokensDetailed(child), current, out); } } return current; @@ -363,34 +394,67 @@ export class BashPathResolver { /** * Project candidates into policy lookup values while preserving literal * values after a non-literal `cd`, where no reliable absolute base exists. + * + * `ruleCandidates` keeps the long-standing admission rule — path-shaped + * tokens plus bare tokens an existence probe confirms. `writeCandidates` + * additionally admits every `redirect-out` destination: a redirect writes + * its target whether or not the file exists yet, so existence and shape + * cannot gate it. The write slice is deduplicated per role so the same + * path appearing as both a read argument and a write destination keeps + * both classifications. */ - private projectRuleCandidates( + private projectCandidates( candidates: readonly PathCandidate[], - ): BashPathRuleCandidate[] { - const seen = new Set(); - const result: BashPathRuleCandidate[] = []; + ): ResolvedBashPaths { + const seenRules = new Set(); + const seenWrites = new Set(); + const ruleCandidates: BashPathRuleCandidate[] = []; + const writeCandidates: BashPathRuleCandidate[] = []; - for (const { token, base } of candidates) { + const pushWrite = ( + candidate: BashPathRuleCandidate, + matchValues: readonly string[], + ): void => { + const key = `${candidate.role}${matchValues.join("")}`; + if (seenWrites.has(key)) return; + seenWrites.add(key); + writeCandidates.push(candidate); + }; + + for (const candidate of candidates) { + const { token, base, role, program, wrapped } = candidate; const shaped = classifyTokenAsRuleCandidate( token, this.normalizer.flavor, ); - const candidate = + const resolved = shaped === null - ? this.probeBareToken(token, base) + ? this.probeBareToken(token, base, { role, program, wrapped }) : { token: shaped, path: this.buildRuleCandidatePath(shaped, base) }; - if (!candidate) continue; - - const matchValues = candidate.path.matchValues(); - if (matchValues.length === 0) continue; - - const key = matchValues.join("\0"); - if (seen.has(key)) continue; - seen.add(key); - result.push(candidate); + if (resolved) { + const matchValues = resolved.path.matchValues(); + if (matchValues.length === 0) continue; + const projected = { ...resolved, role, program, wrapped }; + const key = matchValues.join(""); + if (!seenRules.has(key)) { + seenRules.add(key); + ruleCandidates.push(projected); + } + pushWrite(projected, matchValues); + continue; + } + // A redirect-out destination that fails the shape/probe admission is + // still written — admit it to the write slice unconditionally (#393's + // literal-only fallback covers the unknown-base case). + if (role === "redirect-out" && !FD_DUPLICATE_TARGET.test(token)) { + const path = this.buildRuleCandidatePath(token, base); + const matchValues = path.matchValues(); + if (matchValues.length === 0) continue; + pushWrite({ token, path, role, program, wrapped }, matchValues); + } } - return result; + return { ruleCandidates, writeCandidates }; } /** @@ -414,6 +478,11 @@ export class BashPathResolver { private probeBareToken( token: string, base: EffectiveBase, + meta: { + role: BashPathTokenRole; + program: string | null; + wrapped: boolean; + }, ): BashPathRuleCandidate | null { const bare = classifyBareTokenCandidate(token); if (bare === null) return null; @@ -424,7 +493,7 @@ export class BashPathResolver { }); const lexical = path.value(); if (!lexical || !this.normalizer.entryExists(lexical)) return null; - return { token: bare, path }; + return { token: bare, path, ...meta }; } private buildRuleCandidatePath( @@ -474,11 +543,19 @@ function isBackgrounded(seqNode: TSNode, index: number): boolean { } function tagTokens( - tokens: readonly string[], + tokens: readonly CollectedPathToken[], base: EffectiveBase, out: PathCandidate[], ): void { - for (const token of tokens) out.push({ token, base }); + for (const token of tokens) { + out.push({ + token: token.text, + base, + role: token.role, + program: token.program, + wrapped: token.wrapped, + }); + } } /** diff --git a/pi-permission-system/src/access-intent/bash/command-enumeration.ts b/pi-permission-system/src/access-intent/bash/command-enumeration.ts index 160bc83..8b9b716 100644 --- a/pi-permission-system/src/access-intent/bash/command-enumeration.ts +++ b/pi-permission-system/src/access-intent/bash/command-enumeration.ts @@ -176,8 +176,11 @@ function makeCommandUnit( * A leading `variable_assignment` prefix is skipped (matching * `commandUnitText`), so offsets are relative to the `command_name`. An empty * list means a pure assignment with no `command_name`. + * + * Exported for the token collector, which runs the same wrapper classification + * to mark wrapper payloads as wrapped path candidates. */ -function readCommandWords(node: TSNode): CommandWord[] { +export function readCommandWords(node: TSNode): CommandWord[] { const words: CommandWord[] = []; let unitStart: number | undefined; for (let i = 0; i < node.childCount; i++) { diff --git a/pi-permission-system/src/access-intent/bash/program.ts b/pi-permission-system/src/access-intent/bash/program.ts index 87f19d5..ebca941 100644 --- a/pi-permission-system/src/access-intent/bash/program.ts +++ b/pi-permission-system/src/access-intent/bash/program.ts @@ -26,6 +26,7 @@ export class BashProgram { private readonly sourceCommand: string, private readonly commandUnits: readonly BashCommand[], private readonly resolvedRuleCandidates: readonly BashPathRuleCandidate[], + private readonly resolvedWriteCandidates: readonly BashPathRuleCandidate[], ) {} /** @@ -52,10 +53,10 @@ export class BashProgram { ): Promise { const parser = await getParser(); const tree = parser.parse(command); - if (!tree) return new BashProgram(command, [], []); + if (!tree) return new BashProgram(command, [], [], []); try { - const { ruleCandidates } = new BashPathResolver( + const { ruleCandidates, writeCandidates } = new BashPathResolver( normalizer, options?.workdir, ).resolve(tree.rootNode); @@ -63,6 +64,7 @@ export class BashProgram { command, collectCommands(tree.rootNode), ruleCandidates, + writeCandidates, ); } finally { tree.delete(); @@ -112,4 +114,14 @@ export class BashProgram { pathRuleCandidates(): BashPathRuleCandidate[] { return [...this.resolvedRuleCandidates]; } + + /** + * The protected-write slice: all path-rule candidates plus every + * `redirect-out` destination (admitted unconditionally), each carrying its + * `role`/`program`/`wrapped` metadata for the protected-write gate's + * read-vs-write classification. + */ + writeCandidates(): BashPathRuleCandidate[] { + return [...this.resolvedWriteCandidates]; + } } diff --git a/pi-permission-system/src/access-intent/bash/token-collection.ts b/pi-permission-system/src/access-intent/bash/token-collection.ts index 547168a..6a5ff9b 100644 --- a/pi-permission-system/src/access-intent/bash/token-collection.ts +++ b/pi-permission-system/src/access-intent/bash/token-collection.ts @@ -1,4 +1,5 @@ import { basename } from "node:path"; +import { readCommandWords } from "#src/access-intent/bash/command-enumeration"; import { EXECUTION_HOST_TYPES, forEachNestedExecution, @@ -10,6 +11,50 @@ import { SKIP_SUBTREE_TYPES, } from "#src/access-intent/bash/node-text"; import type { TSNode } from "#src/access-intent/bash/parser"; +import { classifyWrapperWords } from "#src/access-intent/bash/wrapper-analysis"; + +// ── Token roles and context ───────────────────────────────────────────────── + +/** + * How the enclosing command uses a collected token. + * + * `redirect-out` covers every operator that writes a file (`>`, `>>`, `&>`, + * `&>>`, `>|`, `<>`, `>&`); `redirect-in` is a `<` read; `argument` is any + * other candidate — the protected-write gate decides read vs write from the + * program name. + */ +export type BashPathTokenRole = "argument" | "redirect-out" | "redirect-in"; + +/** + * A collected token together with the metadata the protected-write gate + * needs: the enclosing command's program name (after stripping env + * assignments), how the command uses it, and whether it sits inside a wrapper + * payload (`bash -c`, `eval`, `sudo`, `xargs`, …) or a nested execution + * context (command/process substitution). + */ +export interface CollectedPathToken { + readonly text: string; + readonly role: BashPathTokenRole; + readonly program: string | null; + readonly wrapped: boolean; +} + +/** + * Enclosing-context facts carried down the token walk. + * `program` is the `command_name` of the enclosing command unit — `null` for + * tokens outside any command (e.g. a redirect on a subshell). `wrapped` + * becomes true inside a nested execution context and inside a wrapper + * command's payload. + */ +export interface TokenCollectionContext { + readonly program: string | null; + readonly wrapped: boolean; +} + +const TOP_LEVEL_CONTEXT: TokenCollectionContext = { + program: null, + wrapped: false, +}; // ── Public surface ───────────────────────────────────────────────────────── @@ -30,17 +75,33 @@ import type { TSNode } from "#src/access-intent/bash/parser"; * arguments generically. */ export function collectPathCandidateTokens(node: TSNode): string[] { - if (node.type === "command") return collectCommandTokens(node); - if (node.type === "file_redirect") return collectRedirectTokens(node); + return collectPathCandidateTokensDetailed(node).map((token) => token.text); +} + +/** + * The metadata-carrying form of {@link collectPathCandidateTokens}; every + * token keeps its role, enclosing program, and wrapped flag for the + * protected-write write classification. + */ +export function collectPathCandidateTokensDetailed( + node: TSNode, + context: TokenCollectionContext = TOP_LEVEL_CONTEXT, +): CollectedPathToken[] { + if (node.type === "command") + return collectCommandTokensDetailed(node, context); + if (node.type === "file_redirect") + return collectRedirectTokensDetailed(node, context); if (EXECUTION_HOST_TYPES.has(node.type)) { - return collectHostedExecutionTokens(node); + return collectHostedExecutionTokens(node, context); } if (SKIP_SUBTREE_TYPES.has(node.type)) return []; - const tokens: string[] = []; + const tokens: CollectedPathToken[] = []; for (let i = 0; i < node.childCount; i++) { const child = node.child(i); - if (child) tokens.push(...collectPathCandidateTokens(child)); + if (child) { + tokens.push(...collectPathCandidateTokensDetailed(child, context)); + } } return tokens; } @@ -51,14 +112,41 @@ export function collectPathCandidateTokens(node: TSNode): string[] { * `collectGenericCommandTokens`. */ export function collectCommandTokens(node: TSNode): string[] { - const commandName = extractCommandName(node); - const config = commandName - ? PATTERN_FIRST_COMMANDS.get(commandName) - : undefined; + return collectCommandTokensDetailed(node).map((token) => token.text); +} + +/** + * The metadata-carrying form of {@link collectCommandTokens}. The command's + * own name becomes the tokens' `program`, and the whole argument set is + * `wrapped` when the command is itself a wrapper unit (`bash -c`, `eval`, + * `sudo`, `xargs`, …) or sits inside a nested execution context. + */ +export function collectCommandTokensDetailed( + node: TSNode, + context: TokenCollectionContext = TOP_LEVEL_CONTEXT, +): CollectedPathToken[] { + const program = extractCommandName(node) ?? null; + const commandContext: TokenCollectionContext = { + program, + wrapped: + context.wrapped || + classifyWrapperWords(readCommandWords(node)) !== undefined, + }; + const config = program ? PATTERN_FIRST_COMMANDS.get(program) : undefined; const tokens = config - ? collectPatternCommandTokens(node, config) - : collectGenericCommandTokens(node); - return [...tokens, ...collectEmbeddedOptionValues(node)]; + ? collectPatternCommandTokens(node, config, commandContext) + : collectGenericCommandTokens(node, commandContext); + return [ + ...tokens, + ...collectEmbeddedOptionValues(node).map((text) => ({ + text, + role: "argument" as const, + program, + // A value hidden inside `--flag=PATH` is indirection — the read-only + // program allowance must not extend to it. + wrapped: true, + })), + ]; } /** @@ -74,18 +162,54 @@ export function collectCommandTokens(node: TSNode): string[] { * argument node. */ export function collectRedirectTokens(node: TSNode): string[] { - const tokens: string[] = []; + return collectRedirectTokensDetailed(node).map((token) => token.text); +} + +/** + * The metadata-carrying form of {@link collectRedirectTokens}. The redirect + * operator decides the role: an operator containing `>` writes + * (`redirect-out`), `<` reads (`redirect-in`); when `node` is not a + * `file_redirect` the tokens keep the neutral `argument` role. + */ +export function collectRedirectTokensDetailed( + node: TSNode, + context: TokenCollectionContext = TOP_LEVEL_CONTEXT, +): CollectedPathToken[] { + const role = + node.type === "file_redirect" ? redirectRole(node) : "argument"; + const tokens: CollectedPathToken[] = []; for (let i = 0; i < node.childCount; i++) { const child = node.child(i); if (!child) continue; if (ARG_NODE_TYPES.has(child.type)) { - tokens.push(resolveNodeText(child)); + tokens.push({ + text: resolveNodeText(child), + role, + program: context.program, + wrapped: context.wrapped, + }); } - tokens.push(...collectHostedExecutionTokens(child)); + tokens.push(...collectHostedExecutionTokens(child, context)); } return tokens; } +/** + * The write/read role a `file_redirect` node's operator implies: any operator + * containing `>` (`>`, `>>`, `&>`, `&>>`, `>|`, `<>`, `>&`) writes; + * `<` reads. A missing operator defaults to the write role — the + * conservative answer for the protected-write slice. + */ +function redirectRole(node: TSNode): "redirect-out" | "redirect-in" { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (!child || child.isNamed) continue; + if (child.text.includes(">")) return "redirect-out"; + if (child.text.includes("<")) return "redirect-in"; + } + return "redirect-out"; +} + /** * Collect the path-candidate tokens of every command nested inside `node`'s * execution contexts, reading none of the host subtree's own text. @@ -95,15 +219,27 @@ export function collectRedirectTokens(node: TSNode): string[] { * * `node` may be a context outright (`> $(cmd)`) or merely contain one * (`> ${DIR}/$(cmd)`); `forEachNestedExecution` searches strictly within a - * subtree, so the first case is checked here. + * subtree, so the first case is checked here. Everything found inside is + * `wrapped` — the enclosing program cannot vouch for what a substitution runs. */ -function collectHostedExecutionTokens(node: TSNode): string[] { +function collectHostedExecutionTokens( + node: TSNode, + context: TokenCollectionContext, +): CollectedPathToken[] { if (NESTED_EXECUTION_CONTEXTS.has(node.type)) { - return collectPathCandidateTokens(node); + return collectPathCandidateTokensDetailed(node, { + ...context, + wrapped: true, + }); } - const tokens: string[] = []; + const tokens: CollectedPathToken[] = []; forEachNestedExecution(node, (contextNode) => { - tokens.push(...collectPathCandidateTokens(contextNode)); + tokens.push( + ...collectPathCandidateTokensDetailed(contextNode, { + ...context, + wrapped: true, + }), + ); }); return tokens; } @@ -187,7 +323,10 @@ const PATTERN_FIRST_COMMANDS: ReadonlyMap = [ "sed", { - argConsumingFlags: new Set(["-e", "-i"]), + // `-i` is deliberately absent: GNU sed's in-place flag takes an + // attached suffix, never a separate argument, so treating it as + // arg-consuming would swallow the script and hide the file operand. + argConsumingFlags: new Set(["-e"]), fileConsumingFlags: new Set(["-f"]), }, ], @@ -322,13 +461,23 @@ function classifyPatternCommandFlag( function collectPatternCommandTokens( node: TSNode, config: PatternCommandConfig, -): string[] { + context: TokenCollectionContext, +): CollectedPathToken[] { const patternPositionals = config.patternPositionals ?? 1; let hasExplicitScript = false; let positionalsSeen = 0; let nextArgAction: "skip" | "extract" | null = null; let pastEndOfFlags = false; - const tokens: string[] = []; + const tokens: CollectedPathToken[] = []; + + const pushArgument = (text: string): void => { + tokens.push({ + text, + role: "argument", + program: context.program, + wrapped: context.wrapped, + }); + }; for (let i = 0; i < node.childCount; i++) { const child = node.child(i); @@ -341,7 +490,7 @@ function collectPatternCommandTokens( // Only process argument-like nodes; recurse into others // (e.g. command_substitution) for nested commands. if (!ARG_NODE_TYPES.has(child.type)) { - tokens.push(...collectPathCandidateTokens(child)); + tokens.push(...collectPathCandidateTokensDetailed(child, context)); continue; } @@ -353,7 +502,7 @@ function collectPatternCommandTokens( continue; } if (nextArgAction === "extract") { - tokens.push(text); + pushArgument(text); nextArgAction = null; continue; } @@ -387,7 +536,7 @@ function collectPatternCommandTokens( } // File argument — collect as path candidate. - tokens.push(text); + pushArgument(text); } return tokens; @@ -397,8 +546,11 @@ function collectPatternCommandTokens( * Collect all argument tokens from a generic (non-pattern-first) command node, * skipping the command name and variable assignments. */ -function collectGenericCommandTokens(node: TSNode): string[] { - const tokens: string[] = []; +function collectGenericCommandTokens( + node: TSNode, + context: TokenCollectionContext, +): CollectedPathToken[] { + const tokens: CollectedPathToken[] = []; let seenCommandName = false; for (let i = 0; i < node.childCount; i++) { @@ -421,12 +573,17 @@ function collectGenericCommandTokens(node: TSNode): string[] { // Argument nodes: resolve their text and collect. if (ARG_NODE_TYPES.has(child.type)) { - tokens.push(resolveNodeText(child)); + tokens.push({ + text: resolveNodeText(child), + role: "argument", + program: context.program, + wrapped: context.wrapped, + }); continue; } // Recurse into other children (e.g. command_substitution nested in args) - tokens.push(...collectPathCandidateTokens(child)); + tokens.push(...collectPathCandidateTokensDetailed(child, context)); } return tokens; diff --git a/pi-permission-system/src/authority/authorizer-selection.ts b/pi-permission-system/src/authority/authorizer-selection.ts index d620041..df28c6b 100644 --- a/pi-permission-system/src/authority/authorizer-selection.ts +++ b/pi-permission-system/src/authority/authorizer-selection.ts @@ -6,9 +6,11 @@ import { type NamedAuthorizer, type SelectedAuthority, selectAuthorizer, + type TerminalAuthorizer, } from "./authorizer"; import { composeAuthorizerChain } from "./authorizer-chain"; import type { AuthorizerLookup } from "./authorizer-registry"; +import { LocalUserAuthorizer } from "./local-user-authorizer"; import type { PermissionPrompterApi, PromptPermissionDetails, @@ -172,6 +174,27 @@ export class AuthorizerSelection new Error("escalate called before the session was activated"), ); } + // A protected-write ask is human-only: it composes with zero links (no + // AutoReview), and a terminal that is not the local UI — a denying node, + // a `ParentAuthorizer` relay, or a session with no UI — cannot confirm + // it, so it denies without prompting. + if (details.humanOnly) { + if (!(authority.terminal instanceof LocalUserAuthorizer)) { + return this.deps.prompter.prompt( + new ProtectedWriteUnavailableAuthorizer(), + details, + ); + } + return this.deps.prompter.prompt( + composeAuthorizerChain( + [], + authority.terminal, + this.deps.getPermissionQuery(), + this.deps.logger, + ), + details, + ); + } const chain = composeAuthorizerChain( this.linksFor(authority, details.requestId), authority.terminal, @@ -181,3 +204,26 @@ export class AuthorizerSelection return this.deps.prompter.prompt(chain, details); } } + +/** + * The human-only terminal for a protected-write ask on a node with no local + * UI: answers with a fixed denial, marked `confirmationUnavailable` so the + * review log and decision event record that no human ever ruled rather than + * a user denial (#719). Routed through `PermissionPrompter` so the + * waiting/denied review entries are written like any other escalated ask. + */ +class ProtectedWriteUnavailableAuthorizer implements TerminalAuthorizer { + authorize(): Promise { + return Promise.resolve({ + approved: false, + state: "denied_with_reason", + denialReason: + "写入受保护文件需要人工确认,但当前没有可用的确认界面", + confirmationUnavailable: true, + decidedBy: { + kind: "unavailable", + reason: "protected_write_no_local_ui", + }, + }); + } +} diff --git a/pi-permission-system/src/authority/local-user-authorizer.ts b/pi-permission-system/src/authority/local-user-authorizer.ts index 3adbe38..11609c5 100644 --- a/pi-permission-system/src/authority/local-user-authorizer.ts +++ b/pi-permission-system/src/authority/local-user-authorizer.ts @@ -76,6 +76,11 @@ function buildRequestOptions( const notes = details.deferNotes; const withNotes = notes && notes.length > 0 ? { deferNotes: notes } : undefined; + // A protected-write ask offers no session grant at all — a human must + // confirm every write, so the option is hidden rather than unrecorded. + if (details.humanOnly) { + return { hideSessionOption: true, ...withNotes }; + } if (details.forwarding && details.sessionApproval && pattern) { return { sessionScope: buildForwardedScopeLabels( diff --git a/pi-permission-system/src/authority/permission-dialog.ts b/pi-permission-system/src/authority/permission-dialog.ts index bf1c0df..0ea5478 100644 --- a/pi-permission-system/src/authority/permission-dialog.ts +++ b/pi-permission-system/src/authority/permission-dialog.ts @@ -119,6 +119,12 @@ export interface RequestPermissionOptions { * deterministic, code-authored sentence. */ deferNotes?: readonly PromptDecisionNote[]; + /** + * Protected-write asks only: suppress the "for this session" option + * entirely — a human-only write must be confirmed every time, so the + * session grant is not offered rather than merely unrecorded. + */ + hideSessionOption?: boolean; } export async function requestPermissionDecisionFromUi( @@ -130,7 +136,7 @@ export async function requestPermissionDecisionFromUi( const sessionOption = options?.sessionLabel ?? APPROVE_FOR_SESSION_OPTION; const decisionOptions = [ APPROVE_OPTION, - sessionOption, + ...(options?.hideSessionOption ? [] : [sessionOption]), DENY_OPTION, DENY_WITH_REASON_OPTION, ] as const; diff --git a/pi-permission-system/src/authority/permission-prompt-component.ts b/pi-permission-system/src/authority/permission-prompt-component.ts index 9b5e804..40fe09a 100644 --- a/pi-permission-system/src/authority/permission-prompt-component.ts +++ b/pi-permission-system/src/authority/permission-prompt-component.ts @@ -16,6 +16,7 @@ import { type UnattributedDecision, } from "#src/authority/permission-dialog"; import { + enabledPromptKeys, initialPromptState, type PromptEvent, type PromptKey, @@ -139,8 +140,6 @@ const OPTION_LABELS: Record = { r: "No, provide reason", }; -const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "n", "r"]; - export function presentInlinePermissionPrompt( view: PermissionPromptView, title: string, @@ -151,6 +150,7 @@ export function presentInlinePermissionPrompt( doublePressToConfirm: view.doublePressToConfirm, sessionLabel: options?.sessionLabel ?? DEFAULT_SESSION_LABEL, sessionScope: options?.sessionScope, + hideSessionOption: options?.hideSessionOption, }; return view.ui.custom( (tui, theme, keybindings, done) => @@ -341,7 +341,9 @@ class PermissionPromptComponent implements Component { return { type: "cancel" }; } if (this.state.step === "decision") { - const key = OPTION_ORDER.find((option) => matchesKey(data, option)); + const key = enabledPromptKeys(this.config).find((option) => + matchesKey(data, option), + ); if (key) { return { type: "hotkey", key }; } @@ -365,7 +367,7 @@ class PermissionPromptComponent implements Component { private renderDecision(width: number): string[] { const ask = this.renderAsk(width); const lines = [this.theme.fg("accent", this.title), ...ask.lines, ""]; - for (const key of OPTION_ORDER) { + for (const key of enabledPromptKeys(this.config)) { const label = key === "s" ? this.config.sessionLabel : OPTION_LABELS[key]; const selected = this.state.highlightedKey === key; const marker = selected ? "▶" : " "; diff --git a/pi-permission-system/src/authority/permission-prompt-decision.ts b/pi-permission-system/src/authority/permission-prompt-decision.ts index ada05d9..8b2da43 100644 --- a/pi-permission-system/src/authority/permission-prompt-decision.ts +++ b/pi-permission-system/src/authority/permission-prompt-decision.ts @@ -30,6 +30,21 @@ const OPTION_VERBS: Record = { r: "deny with a reason", }; +/** + * The keys this prompt offers: `OPTION_ORDER` minus `s` when + * {@link PromptModelConfig.hideSessionOption} suppresses the session grant + * (a protected-write ask must be confirmed every time). Shared by the + * reducer and the component renderer so the two never disagree about which + * options exist. + */ +export function enabledPromptKeys( + config: PromptModelConfig, +): readonly PromptKey[] { + return config.hideSessionOption + ? OPTION_ORDER.filter((key) => key !== "s") + : OPTION_ORDER; +} + /** Static configuration for a single prompt presentation. */ export interface PromptModelConfig { /** When true, a letter hotkey arms first and commits only on a second press. */ @@ -42,6 +57,11 @@ export interface PromptModelConfig { * default) or the whole serving session. */ sessionScope?: NonNullable; + /** + * Protected-write asks only: suppress the `s` option entirely — a + * human-only write must be confirmed every time. + */ + hideSessionOption?: boolean; } /** The re-render view state the component draws from. */ @@ -116,11 +136,18 @@ function reduceDecisionStep( case "nav": return render({ ...state, - highlightedKey: shiftKey(state.highlightedKey, event.direction), + highlightedKey: shiftKey( + state.highlightedKey, + event.direction, + enabledPromptKeys(config), + ), armedKey: undefined, hint: "", }); case "hotkey": + if (!enabledPromptKeys(config).includes(event.key)) { + return render(state); + } return pressHotkey(config, state, event.key); case "confirm": return commit(config, state, state.highlightedKey); @@ -245,11 +272,16 @@ function reduceScopeStep( } } -function shiftKey(current: PromptKey, direction: "up" | "down"): PromptKey { - const index = OPTION_ORDER.indexOf(current); +function shiftKey( + current: PromptKey, + direction: "up" | "down", + keys: readonly PromptKey[], +): PromptKey { + const index = keys.indexOf(current); + if (index === -1) return keys[0] ?? current; const delta = direction === "down" ? 1 : -1; - const next = (index + delta + OPTION_ORDER.length) % OPTION_ORDER.length; - return OPTION_ORDER[next] ?? current; + const next = (index + delta + keys.length) % keys.length; + return keys[next] ?? current; } function render(state: PromptViewState): PromptOutcome { diff --git a/pi-permission-system/src/authority/permission-prompter.ts b/pi-permission-system/src/authority/permission-prompter.ts index 512a413..e13baac 100644 --- a/pi-permission-system/src/authority/permission-prompter.ts +++ b/pi-permission-system/src/authority/permission-prompter.ts @@ -79,6 +79,13 @@ export interface PromptPermissionDetails { * model-generated), `system` is a code-authored sentence. */ deferNotes?: readonly DeferNote[]; + /** + * Marks the protected-write escalation path: the ask must reach the local + * human terminal directly — no `authorizerChain` links (AutoReview), no + * session-grant option, and a terminal without a local UI denies without + * prompting. Set by `GateRunner` from the descriptor's `humanOnly` marker. + */ + humanOnly?: boolean; } /** diff --git a/pi-permission-system/src/config-loader.ts b/pi-permission-system/src/config-loader.ts index 63d8758..48e6bdf 100644 --- a/pi-permission-system/src/config-loader.ts +++ b/pi-permission-system/src/config-loader.ts @@ -1,11 +1,6 @@ import { existsSync, readFileSync } from "node:fs"; -import { normalize } from "node:path"; import type { ZodError } from "zod"; -import { - getGlobalConfigPath, - getLegacyExtensionConfigPath, - getLegacyGlobalPolicyPath, -} from "./config-paths"; +import { getGlobalConfigPath } from "./config-paths"; import { type ShellToolsConfig, type UnifiedPermissionConfig, @@ -285,47 +280,21 @@ export interface MergedConfigResult { } /** - * Load the user-global config, detect legacy global files, and collect issues. - * Project-level `policy.jsonc` files are intentionally not loaded. + * Load the global bundle policy config and collect issues. + * + * The installed bundle's `config/pi-permission-policy.jsonc` is the single + * source of truth. Project-level `policy.jsonc` files and leftover files at + * legacy locations are ignored — never merged and never migrated. */ export function loadAndMergeConfigs( - agentDir: string, - _cwd: string, - extensionRoot: string, + globalConfigPath: string = getGlobalConfigPath(), ): MergedConfigResult { const allIssues: string[] = []; - const newGlobalPath = getGlobalConfigPath(agentDir); - const legacyGlobalPolicyPath = getLegacyGlobalPolicyPath(agentDir); - const legacyExtConfigPath = getLegacyExtensionConfigPath(extensionRoot); - let merged: UnifiedPermissionConfig = {}; - if (existsSync(legacyGlobalPolicyPath)) { - const legacy = loadUnifiedConfig(legacyGlobalPolicyPath); - allIssues.push( - `Legacy global policy found at '${legacyGlobalPolicyPath}'. ` + - `Move it to '${newGlobalPath}':\n` + - ` mv '${legacyGlobalPolicyPath}' '${newGlobalPath}'`, - ); - merged = mergeUnifiedConfigs(merged, legacy.config); - } - - if ( - normalize(legacyExtConfigPath) !== normalize(newGlobalPath) && - existsSync(legacyExtConfigPath) - ) { - const legacy = loadUnifiedConfig(legacyExtConfigPath); - allIssues.push( - `Legacy extension config found at '${legacyExtConfigPath}'. ` + - `Move it to '${newGlobalPath}':\n` + - ` mv '${legacyExtConfigPath}' '${newGlobalPath}'`, - ); - merged = mergeUnifiedConfigs(merged, legacy.config); - } - - const globalResult = loadUnifiedConfig(newGlobalPath); + const globalResult = loadUnifiedConfig(globalConfigPath); allIssues.push(...globalResult.issues); const globalConfig = globalResult.config; - merged = mergeUnifiedConfigs(merged, globalConfig); + const merged = mergeUnifiedConfigs({}, globalConfig); const bashFallbackIssue = detectPermissiveBashFallback(merged.permission); if (bashFallbackIssue) allIssues.push(bashFallbackIssue); diff --git a/pi-permission-system/src/config-modal.ts b/pi-permission-system/src/config-modal.ts index 72b714c..49c5a5b 100644 --- a/pi-permission-system/src/config-modal.ts +++ b/pi-permission-system/src/config-modal.ts @@ -30,7 +30,7 @@ const COMMAND_ARGUMENTS = [ { value: "path", label: "Show config path", - description: "Display the policy.jsonc path used by pi-permission-system", + description: "Display the pi-permission-policy.jsonc path used by pi-permission-system", }, { value: "reset", diff --git a/pi-permission-system/src/config-paths.ts b/pi-permission-system/src/config-paths.ts index ee9450d..575f1d1 100644 --- a/pi-permission-system/src/config-paths.ts +++ b/pi-permission-system/src/config-paths.ts @@ -1,37 +1,41 @@ import { join } from "node:path"; +import { fileURLToPath } from "node:url"; const EXTENSION_ID = "pi-permission-system"; /** - * Bundle-owned config tree under the agent directory: install.sh / update.sh - * delete and rebuild `my-pi/` wholesale on every run, so policy, hard guards, - * and logs all live beneath it. Files at the former - * `extensions/pi-permission-system/` location are ignored — never migrated. + * Bundle-level `config/` is the single source of truth for policy and hard + * guards: paths resolve relative to this source file so the installed bundle + * is read directly, with no deployment, seeding, or migration into the agent + * dir. Only disposable runtime state (logs) stays under `/my-pi/`. */ -const BUNDLE_CONFIG_DIR = "my-pi"; +const BUNDLE_CONFIG_DIR = fileURLToPath(new URL("../../config", import.meta.url)); +const BUNDLE_MANAGED_DIR = "my-pi"; const BUNDLE_PERMISSION_DIR = "permission-system"; export const DEBUG_LOG_FILENAME = `${EXTENSION_ID}-debug.jsonl`; export const REVIEW_LOG_FILENAME = `${EXTENSION_ID}-permission-review.jsonl`; +/** + * Bundle-owned runtime directory under the agent dir — logs only. Stale files + * left here from earlier deployments are ignored, never migrated. + */ export function getGlobalConfigDir(agentDir: string): string { - return join(agentDir, BUNDLE_CONFIG_DIR, BUNDLE_PERMISSION_DIR); + return join(agentDir, BUNDLE_MANAGED_DIR, BUNDLE_PERMISSION_DIR); } -export function getGlobalConfigPath(agentDir: string): string { - return join(getGlobalConfigDir(agentDir), "policy.jsonc"); +export function getGlobalConfigPath(): string { + return join(BUNDLE_CONFIG_DIR, "pi-permission-policy.jsonc"); } -export function getHardGuardConfigPath(agentDir: string): string { - return join(getGlobalConfigDir(agentDir), "hard-guards.jsonc"); +export function getHardGuardConfigPath(): string { + return join(BUNDLE_CONFIG_DIR, "pi-permission-hard-guards.jsonc"); } - export function getGlobalLogsDir(agentDir: string): string { return join(getGlobalConfigDir(agentDir), "logs"); } - /** * Directory holding project-scoped custom agent definition files. * @@ -44,12 +48,3 @@ export function getGlobalLogsDir(agentDir: string): string { export function getProjectAgentsDir(cwd: string): string { return join(cwd, ".pi", "agents"); } - -export function getLegacyGlobalPolicyPath(agentDir: string): string { - return join(agentDir, "pi-permissions.jsonc"); -} - - -export function getLegacyExtensionConfigPath(extensionRoot: string): string { - return join(extensionRoot, "config.json"); -} diff --git a/pi-permission-system/src/config-reporter.ts b/pi-permission-system/src/config-reporter.ts index 27536b0..02658ff 100644 --- a/pi-permission-system/src/config-reporter.ts +++ b/pi-permission-system/src/config-reporter.ts @@ -1,29 +1,13 @@ import type { ResolvedPolicyPaths } from "./permission-manager"; -export interface ResolvedConfigLogEntry { - globalConfigPath: string; - globalConfigExists: boolean; - agentsDir: string; - agentsDirExists: boolean; - projectAgentsDir: string | null; - projectAgentsDirExists: boolean; - legacyGlobalPolicyDetected: boolean; - legacyExtensionConfigDetected: boolean; -} +export type ResolvedConfigLogEntry = ResolvedPolicyPaths; export interface BuildResolvedConfigLogEntryOptions { policyPaths: ResolvedPolicyPaths; - legacyGlobalPolicyDetected?: boolean; - legacyExtensionConfigDetected?: boolean; } export function buildResolvedConfigLogEntry( options: BuildResolvedConfigLogEntryOptions, ): ResolvedConfigLogEntry { - return { - ...options.policyPaths, - legacyGlobalPolicyDetected: options.legacyGlobalPolicyDetected ?? false, - legacyExtensionConfigDetected: - options.legacyExtensionConfigDetected ?? false, - }; + return { ...options.policyPaths }; } diff --git a/pi-permission-system/src/config-schema.ts b/pi-permission-system/src/config-schema.ts index b65bec1..5fa4e5d 100644 --- a/pi-permission-system/src/config-schema.ts +++ b/pi-permission-system/src/config-schema.ts @@ -262,7 +262,7 @@ export const unifiedConfigSchema = z description: "Ordinary JSONC policy combining runtime knobs, AutoReview settings, and flat permission rules for pi-permission-system.", markdownDescription: - "Global ordinary JSONC policy for [pi-permission-system](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system).\n\nPlace at `~/.pi/agent/my-pi/permission-system/policy.jsonc`. Project-level policy files are not loaded. Global-only hard guards use the separate `hard-guards.jsonc` schema.", + "Global ordinary JSONC policy for [pi-permission-system](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system).\n\nRead directly from the installed bundle's `config/pi-permission-policy.jsonc`. Project-level policy files are not loaded. Global-only hard guards use the separate `config/pi-permission-hard-guards.jsonc` schema.", }); /** A permission decision. */ diff --git a/pi-permission-system/src/config-store.ts b/pi-permission-system/src/config-store.ts index af8610a..3af60ea 100644 --- a/pi-permission-system/src/config-store.ts +++ b/pi-permission-system/src/config-store.ts @@ -5,22 +5,17 @@ import { unlinkSync, writeFileSync, } from "node:fs"; -import { dirname, normalize } from "node:path"; +import { dirname } from "node:path"; import type { ExtensionCommandContext, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { loadAndMergeConfigs, loadUnifiedConfig } from "./config-loader"; -import { - getGlobalConfigPath, - getLegacyExtensionConfigPath, - getLegacyGlobalPolicyPath, -} from "./config-paths"; +import { getGlobalConfigPath } from "./config-paths"; import { buildResolvedConfigLogEntry } from "./config-reporter"; import { DEFAULT_EXTENSION_CONFIG, - EXTENSION_ROOT, normalizePermissionSystemConfig, type PermissionSystemExtensionConfig, } from "./extension-config"; @@ -66,6 +61,8 @@ export interface ConfigStoreDeps { agentDir: string; policyPaths: ResolvedPolicyPathProvider; logger: DebugReviewLogger; + /** Override the policy file `save()` writes — tests inject a temp path. */ + configPath?: string; } /** @@ -98,12 +95,7 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore { * for lifecycle logging and active-agent trust decisions owned elsewhere. */ refresh(ctx: ExtensionContext | undefined, projectTrusted: boolean): void { - const cwd = ctx?.cwd ?? null; - const mergeResult = loadAndMergeConfigs( - this.deps.agentDir, - cwd ?? "", - EXTENSION_ROOT, - ); + const mergeResult = loadAndMergeConfigs(this.deps.configPath); const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged); this.config = runtimeConfig; @@ -143,7 +135,7 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore { ctx: ExtensionCommandContext, ): void { const normalized = normalizePermissionSystemConfig(next); - const globalPath = getGlobalConfigPath(this.deps.agentDir); + const globalPath = this.deps.configPath ?? getGlobalConfigPath(); const existing = loadUnifiedConfig(globalPath); const merged = { @@ -192,20 +184,7 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore { */ logResolvedPaths(_cwd?: string): void { const policyPaths = this.deps.policyPaths.getResolvedPolicyPaths(); - const { agentDir } = this.deps; - const legacyGlobalPolicyDetected = existsSync( - getLegacyGlobalPolicyPath(agentDir), - ); - const legacyExtConfigPath = getLegacyExtensionConfigPath(EXTENSION_ROOT); - const newGlobalPath = getGlobalConfigPath(agentDir); - const legacyExtensionConfigDetected = - normalize(legacyExtConfigPath) !== normalize(newGlobalPath) && - existsSync(legacyExtConfigPath); - const entry = buildResolvedConfigLogEntry({ - policyPaths, - legacyGlobalPolicyDetected, - legacyExtensionConfigDetected, - }); + const entry = buildResolvedConfigLogEntry({ policyPaths }); this.deps.logger.review( "config.resolved", entry as unknown as Record, diff --git a/pi-permission-system/src/extension-config.ts b/pi-permission-system/src/extension-config.ts index 2a3c40b..b4cae34 100644 --- a/pi-permission-system/src/extension-config.ts +++ b/pi-permission-system/src/extension-config.ts @@ -1,6 +1,4 @@ import { mkdirSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; import { normalizeAutoReviewConfig, type AutoReviewConfig, @@ -44,12 +42,6 @@ export const DEFAULT_EXTENSION_CONFIG: PermissionSystemExtensionConfig = { doublePressToConfirm: true, }; -function resolveExtensionRoot(moduleUrl = import.meta.url): string { - return join(dirname(fileURLToPath(moduleUrl)), ".."); -} - -export const EXTENSION_ROOT = resolveExtensionRoot(); - const PERMISSION_POLICY_KEYS: ReadonlySet = new Set([ "defaultPolicy", "tools", diff --git a/pi-permission-system/src/handlers/gates/descriptor.ts b/pi-permission-system/src/handlers/gates/descriptor.ts index c147aa3..214806d 100644 --- a/pi-permission-system/src/handlers/gates/descriptor.ts +++ b/pi-permission-system/src/handlers/gates/descriptor.ts @@ -7,6 +7,22 @@ import type { PermissionCheckResult, PermissionState } from "#src/types"; // ── Descriptor types ─────────────────────────────────────────────────────── +/** + * The protected-write match a `humanOnly` descriptor carries: the first + * protected target's display facts plus how many additional protected targets + * the same call writes. + */ +export interface ProtectedWriteTarget { + /** First protected target's display path. */ + readonly path: string; + /** The `protectedWrites` pattern that matched it. */ + readonly pattern: string; + /** The pattern's human-facing label. */ + readonly label: string; + /** How many additional protected targets the same call writes. */ + readonly extraCount: number; +} + /** * Pure output of a gate function — describes what to check and how to present it. * @@ -33,6 +49,15 @@ export interface GateDescriptor { * interface — the runner never needs to know which case applies. */ sessionApproval?: SessionApproval; + /** + * Set only by the protected-write gate: the write must reach the local + * human prompt directly — the runner skips the session-approval and yolo + * fast paths, the escalation bypasses `authorizerChain` links (AutoReview), + * no session-grant option is offered, and a terminal without a local UI + * denies without prompting. Carries the first protected target's facts for + * the system note and review log. + */ + humanOnly?: ProtectedWriteTarget; /** * Details passed to the interactive permission prompt. * diff --git a/pi-permission-system/src/handlers/gates/protected-write.ts b/pi-permission-system/src/handlers/gates/protected-write.ts new file mode 100644 index 0000000..b2b5f17 Binary files /dev/null and b/pi-permission-system/src/handlers/gates/protected-write.ts differ diff --git a/pi-permission-system/src/handlers/gates/runner.ts b/pi-permission-system/src/handlers/gates/runner.ts index 1497559..7a604af 100644 --- a/pi-permission-system/src/handlers/gates/runner.ts +++ b/pi-permission-system/src/handlers/gates/runner.ts @@ -23,6 +23,7 @@ import { deriveResolution, resolveYoloGrant, } from "./helpers"; +import { protectedWriteDeferNote } from "./protected-write"; import type { GateOutcome } from "./types"; // ── GateRunner class ─────────────────────────────────────────────────────── @@ -129,8 +130,13 @@ export class GateRunner { // context holds what every resolution of this gate shares, and who decided // is by definition not shared (#726). + // A protected-write descriptor never takes the session or yolo fast path: + // the write reaches a human prompt even when a session grant covers the + // path or yolo mode is on. + const humanOnly = descriptor.humanOnly; + // 2. Session-hit fast path - if (check.source === "session") { + if (!humanOnly && check.source === "session") { this.reporter.writeReviewLog("permission_request.session_approved", { ...logContext, resolution: "session_approved", @@ -158,7 +164,9 @@ export class GateRunner { // "yolo" on the matched rule, #526) or, under yolo, an ask synthesized // after resolution (#712). Auto-approve without prompting, preserving the // single auto_approved review entry + decision event so log parity holds. - const yoloGrant = resolveYoloGrant(check, this.isYoloEnabled()); + const yoloGrant = humanOnly + ? undefined + : resolveYoloGrant(check, this.isYoloEnabled()); if (yoloGrant) { this.reporter.writeReviewLog("permission_request.auto_approved", { ...logContext, @@ -208,6 +216,12 @@ export class GateRunner { ...(descriptor.sessionApproval ? { sessionApproval: descriptor.sessionApproval.toForwardedData() } : {}), + ...(humanOnly + ? { + humanOnly: true, + deferNotes: [protectedWriteDeferNote(humanOnly)], + } + : {}), }); autoApproved = decision.autoApproved === true; confirmationUnavailable = decision.confirmationUnavailable === true; diff --git a/pi-permission-system/src/handlers/gates/tool-call-gate-pipeline.ts b/pi-permission-system/src/handlers/gates/tool-call-gate-pipeline.ts index cc48566..36860ca 100644 --- a/pi-permission-system/src/handlers/gates/tool-call-gate-pipeline.ts +++ b/pi-permission-system/src/handlers/gates/tool-call-gate-pipeline.ts @@ -6,6 +6,7 @@ import { type ShellInvocation, } from "#src/access-intent/tool-kind"; import type { ShellToolsConfig } from "#src/config-schema"; +import type { ProtectedWriteRule } from "#src/hard-guard-loader"; import type { PathNormalizer } from "#src/path-normalizer"; import type { ScopedPermissionResolver } from "#src/permission-resolver"; import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer"; @@ -20,6 +21,7 @@ import { resolveBashCommandCheck } from "./bash-command"; import { describeBashPathGate } from "./bash-path"; import type { GateResult } from "./descriptor"; import { describePathGate } from "./path"; +import { describeProtectedWriteGate } from "./protected-write"; import type { GateRunner } from "./runner"; import { describeSkillReadGate } from "./skill-read"; import { describeToolGate } from "./tool"; @@ -67,6 +69,8 @@ export class ToolCallGatePipeline { private readonly inputs: ToolCallGateInputs, private readonly customFormatters?: ToolInputFormatterLookup, private readonly customExtractors?: ToolAccessExtractorLookup, + private readonly getProtectedWrites: () => readonly ProtectedWriteRule[] = () => + [], ) {} async evaluate( @@ -119,6 +123,16 @@ export class ToolCallGatePipeline { toolDescriptor.preCheck = toolCheck; return toolDescriptor; }, + // 5. Protected-write gate — last so ordinary/hard-guard denies from the + // producers above block first; its descriptor escalates to the human. + () => + describeProtectedWriteGate( + tcc, + bashProgram, + shell, + normalizer, + this.getProtectedWrites(), + ), ]; for (const produce of gateProducers) { diff --git a/pi-permission-system/src/hard-guard-loader.ts b/pi-permission-system/src/hard-guard-loader.ts index 2d13bf0..79db318 100644 --- a/pi-permission-system/src/hard-guard-loader.ts +++ b/pi-permission-system/src/hard-guard-loader.ts @@ -1,16 +1,72 @@ import { existsSync, readFileSync, statSync } from "node:fs"; -import { dirname } from "node:path"; -import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { normalizeFlatPermissionValue, stripJsonComments, } from "./config-loader"; +import { expandHomePath } from "./expand-home"; import type { PolicyLoader, ResolvedPolicyPaths } from "./policy-loader"; import type { FlatPermissionConfig, PatternValue, ScopeConfig } from "./types"; interface HardGuardDocument { version?: number; permission?: unknown; + protectedWrites?: unknown; +} + +/** + * One entry of the hard-guard file's `protectedWrites` array. + * + * `pattern` uses the same wildcard syntax as the `path` surface; `{agentDir}`, + * `{configHome}` and a leading `~/` are expanded at load time. `label` is the + * human-facing name shown by the protected-write prompt and review log. + */ +export interface ProtectedWriteRule { + readonly pattern: string; + readonly label: string; +} + +function configHome(): string { + const xdg = process.env.XDG_CONFIG_HOME; + return xdg && xdg.trim() !== "" ? xdg : join(homedir(), ".config"); +} + +function expandProtectedWritePattern(pattern: string): string { + return expandHomePath( + pattern + .replaceAll("{agentDir}", getAgentDir()) + .replaceAll("{configHome}", configHome()), + ); +} + +function parseProtectedWrites(value: unknown): ProtectedWriteRule[] { + if (value === undefined) return []; + if (!Array.isArray(value)) { + throw new Error("hard guard 'protectedWrites' must be an array"); + } + return value.map((entry, index) => { + const record = + typeof entry === "object" && entry !== null + ? (entry as Record) + : undefined; + if ( + !record || + typeof record.pattern !== "string" || + record.pattern.trim() === "" || + typeof record.label !== "string" || + record.label.trim() === "" + ) { + throw new Error( + `hard guard 'protectedWrites[${index}]' must be { "pattern": string, "label": string }`, + ); + } + return { + pattern: expandProtectedWritePattern(record.pattern), + label: record.label, + }; + }); } const EMPTY_SCOPE: ScopeConfig = {}; @@ -38,7 +94,10 @@ function assertHardGuardValues(permission: FlatPermissionConfig): void { } } -function readHardGuards(path: string): ScopeConfig { +function readHardGuards(path: string): { + scope: ScopeConfig; + protectedWrites: ProtectedWriteRule[]; +} { const parsed = JSON.parse(stripJsonComments(readFileSync(path, "utf8"))) as HardGuardDocument; if (parsed.version !== undefined && parsed.version !== 1) { throw new Error(`unsupported hard guard version: ${String(parsed.version)}`); @@ -48,24 +107,23 @@ function readHardGuards(path: string): ScopeConfig { throw new Error("hard guard permission must be an object"); } assertHardGuardValues(normalized); - return { permission: { "*": "allow", ...normalized } }; + return { + scope: { permission: { "*": "allow", ...normalized } }, + protectedWrites: parseProtectedWrites(parsed.protectedWrites), + }; } /** - * Global-only hard boundary: deny rules plus same-file allow exceptions. It keeps the last valid snapshot and - * falls back to the bundled defaults; if both are invalid it denies all. + * Global-only hard boundary: deny rules plus same-file allow exceptions. It keeps the last valid snapshot; + * when the bundle config file is missing or invalid it fails closed to deny-all. */ export class HardGuardPolicyLoader implements PolicyLoader { private lastStamp = ""; private lastGood: ScopeConfig | undefined; + private lastGoodProtectedWrites: ProtectedWriteRule[] = []; private issues: string[] = []; - constructor( - private readonly userPath: string, - private readonly fallbackPath = fileURLToPath( - new URL("../config/hard-guards.default.jsonc", import.meta.url), - ), - ) {} + constructor(private readonly userPath: string) {} loadGlobalConfig(): ScopeConfig { const stamp = this.getCacheStamp(); @@ -73,15 +131,15 @@ export class HardGuardPolicyLoader implements PolicyLoader { this.lastStamp = stamp; this.issues = []; - for (const candidate of [this.userPath, this.fallbackPath]) { - if (!existsSync(candidate)) continue; + if (existsSync(this.userPath)) { try { - const loaded = readHardGuards(candidate); - this.lastGood = loaded; - return loaded; + const loaded = readHardGuards(this.userPath); + this.lastGood = loaded.scope; + this.lastGoodProtectedWrites = loaded.protectedWrites; + return loaded.scope; } catch (error) { const message = error instanceof Error ? error.message : String(error); - this.issues.push(`Failed to load hard guards '${candidate}': ${message}`); + this.issues.push(`Failed to load hard guards '${this.userPath}': ${message}`); } } @@ -90,21 +148,27 @@ export class HardGuardPolicyLoader implements PolicyLoader { return DENY_ALL; } + /** + * The `protectedWrites` snapshot of the last valid file (empty when the file + * was never valid — in which case every request is already denied). Reloads + * through the same mtime cache stamp as {@link loadGlobalConfig}. + */ + getProtectedWrites(): ProtectedWriteRule[] { + this.loadGlobalConfig(); + return [...this.lastGoodProtectedWrites]; + } + loadAgentConfig(): ScopeConfig { return EMPTY_SCOPE; } loadProjectAgentConfig(): ScopeConfig { return EMPTY_SCOPE; } getConfiguredMcpServerNames(): readonly string[] { return []; } getCacheStamp(): string { - return [this.userPath, this.fallbackPath] - .map((path) => { - try { - const stat = statSync(path); - return `${path}:${stat.mtimeMs}:${stat.size}`; - } catch { - return `${path}:missing`; - } - }) - .join("|"); + try { + const stat = statSync(this.userPath); + return `${this.userPath}:${stat.mtimeMs}:${stat.size}`; + } catch { + return `${this.userPath}:missing`; + } } getConfigIssues(): string[] { return [...this.issues]; } diff --git a/pi-permission-system/src/index.ts b/pi-permission-system/src/index.ts index de257f2..c4249e1 100644 --- a/pi-permission-system/src/index.ts +++ b/pi-permission-system/src/index.ts @@ -55,8 +55,14 @@ import { SessionRules } from "./session-rules"; import { ToolAccessExtractorRegistry } from "./tool-access-extractor-registry"; import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry"; -export default function piPermissionSystemExtension(pi: ExtensionAPI): void { +export default function piPermissionSystemExtension( + pi: ExtensionAPI, + options?: { configPath?: string; hardGuardConfigPath?: string }, +): void { const agentDir = getAgentDir(); + const globalConfigPath = options?.configPath ?? getGlobalConfigPath(); + const hardGuardConfigPath = + options?.hardGuardConfigPath ?? getHardGuardConfigPath(); const paths = computeExtensionPaths(agentDir); // The single process.platform read for the whole extension, resolved once // into the path-language flavor that every consumer shares (the session's @@ -101,6 +107,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void { const permissionManager = new PermissionManager({ agentDir, + globalConfigPath, flavor: hostFlavor, isYoloEnabled, }); @@ -115,6 +122,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void { agentDir, policyPaths: permissionManager, logger, + configPath: globalConfigPath, }); const prompter = new PermissionPrompter({ logger }); @@ -164,8 +172,9 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void { // access-path → path-values unwrap. Constructed here (before `session`) so // the forwarded-request server's ServingPolicy can resolve against it; the // service and gates below share this one instance. + const hardGuardLoader = new HardGuardPolicyLoader(hardGuardConfigPath); const hardGuardManager = new PermissionManager({ - policyLoader: new HardGuardPolicyLoader(getHardGuardConfigPath(agentDir)), + policyLoader: hardGuardLoader, flavor: hostFlavor, }); const resolver = new PermissionResolver( @@ -224,7 +233,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void { // scope (fail closed); session_start reloads with the real trust decision. configStore.refresh(undefined, false); - const configPath = getGlobalConfigPath(agentDir); + const configPath = globalConfigPath; registerPermissionSystemCommand(pi, { config: configStore, configPath, @@ -295,6 +304,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void { session, formatterRegistry, accessExtractorRegistry, + () => hardGuardLoader.getProtectedWrites(), ); const skillInputGatePipeline = new SkillInputGatePipeline(resolver); const gates = new PermissionGateHandler( diff --git a/pi-permission-system/src/permission-manager.ts b/pi-permission-system/src/permission-manager.ts index f47bda2..4485ee7 100644 --- a/pi-permission-system/src/permission-manager.ts +++ b/pi-permission-system/src/permission-manager.ts @@ -108,6 +108,7 @@ export interface PermissionManagerOptions extends PolicyLoaderOptions { export class PermissionManager implements ScopedPermissionManager { private readonly agentDir: string | undefined; + private readonly globalConfigPath: string; private readonly flavor: PathFlavor; private readonly isYoloEnabled: () => boolean; private loader: PolicyLoader; @@ -118,13 +119,18 @@ export class PermissionManager implements ScopedPermissionManager { constructor(options: PermissionManagerOptions = {}) { this.agentDir = options.agentDir; + this.globalConfigPath = options.globalConfigPath ?? getGlobalConfigPath(); this.flavor = options.flavor ?? posixPathFlavor; this.isYoloEnabled = options.isYoloEnabled ?? YOLO_DISABLED; this.loader = options.policyLoader ?? new FilePolicyLoader( options.agentDir !== undefined - ? derivePolicyLoaderOptions(options.agentDir, undefined) + ? derivePolicyLoaderOptions( + options.agentDir, + undefined, + this.globalConfigPath, + ) : options, ); } @@ -139,7 +145,7 @@ export class PermissionManager implements ScopedPermissionManager { configureForCwd(cwd: string | undefined | null): void { if (this.agentDir !== undefined) { this.loader = new FilePolicyLoader( - derivePolicyLoaderOptions(this.agentDir, cwd), + derivePolicyLoaderOptions(this.agentDir, cwd, this.globalConfigPath), ); } this.resolvedPermissionsCache.clear(); @@ -372,9 +378,10 @@ function buildCheckResult( function derivePolicyLoaderOptions( agentDir: string, cwd: string | undefined | null, + globalConfigPath: string = getGlobalConfigPath(), ): PolicyLoaderOptions { return { - globalConfigPath: getGlobalConfigPath(agentDir), + globalConfigPath, agentsDir: join(agentDir, "agents"), projectAgentsDir: cwd ? getProjectAgentsDir(cwd) : undefined, }; diff --git a/pi-permission-system/src/policy-loader.ts b/pi-permission-system/src/policy-loader.ts index 66ac82b..7acec02 100644 --- a/pi-permission-system/src/policy-loader.ts +++ b/pi-permission-system/src/policy-loader.ts @@ -101,7 +101,7 @@ export interface PolicyLoader { // --------------------------------------------------------------------------- function defaultGlobalConfigPath(): string { - return getGlobalConfigPath(getAgentDir()); + return getGlobalConfigPath(); } function defaultAgentsDir(): string { return join(getAgentDir(), "agents"); diff --git a/pi-permission-system/test/authority/authorizer-selection.test.ts b/pi-permission-system/test/authority/authorizer-selection.test.ts index 159842e..e2e03ef 100644 --- a/pi-permission-system/test/authority/authorizer-selection.test.ts +++ b/pi-permission-system/test/authority/authorizer-selection.test.ts @@ -467,4 +467,75 @@ describe("AuthorizerSelection", () => { ); }); }); + + describe("human-only asks (protected writes)", () => { + const humanOnlyDetails = (): PromptPermissionDetails => + makeDetails({ humanOnly: true }); + + it("goes straight to the local user and never runs configured links", async () => { + const link = vi + .fn() + .mockResolvedValue({ kind: "allow" }); + const registry = new AuthorizerRegistry(); + registry.register("judge", link); + const prompter = makePrompterApi(); + const selection = new AuthorizerSelection( + makeDeps({ + prompter, + authorizerRegistry: registry, + getAuthorizerChain: () => ["judge"], + }), + ); + selection.activate(makeCtx({ hasUI: true })); + + await selection.escalate(humanOnlyDetails()); + + expect(link).not.toHaveBeenCalled(); + expect(prompter.prompt).toHaveBeenCalledWith( + expect.any(LocalUserAuthorizer), + expect.anything(), + ); + }); + + it("denies without prompting when the terminal is DenyingAuthorizer", async () => { + const prompter = makeInvokingPrompter(); + const selection = new AuthorizerSelection(makeDeps({ prompter })); + // No UI, not a subagent → DenyingAuthorizer terminal. + selection.activate(makeCtx({ hasUI: false })); + + const decision = await selection.escalate(humanOnlyDetails()); + + expect(decision).toEqual({ + approved: false, + state: "denied_with_reason", + denialReason: + "写入受保护文件需要人工确认,但当前没有可用的确认界面", + confirmationUnavailable: true, + decidedBy: { + kind: "unavailable", + reason: "protected_write_no_local_ui", + }, + }); + expect(prompter.prompt).toHaveBeenCalledOnce(); + }); + + it("denies instead of relaying to a parent on a subagent node", async () => { + const prompter = makeInvokingPrompter(); + const selection = new AuthorizerSelection( + makeDeps({ detection: makeDetection(true), prompter }), + ); + selection.activate(makeCtx({ hasUI: false })); + + const decision = await selection.escalate(humanOnlyDetails()); + + // ParentAuthorizer.forward would have been invoked if relaying were + // still allowed; instead the human-only denial is returned. + expect(decision.approved).toBe(false); + expect(decision.state).toBe("denied_with_reason"); + expect(decision.denialReason).toBe( + "写入受保护文件需要人工确认,但当前没有可用的确认界面", + ); + expect(prompter.prompt).toHaveBeenCalledOnce(); + }); + }); }); diff --git a/pi-permission-system/test/authority/local-user-authorizer.test.ts b/pi-permission-system/test/authority/local-user-authorizer.test.ts index aed6e00..dc4da09 100644 --- a/pi-permission-system/test/authority/local-user-authorizer.test.ts +++ b/pi-permission-system/test/authority/local-user-authorizer.test.ts @@ -149,6 +149,33 @@ describe("LocalUserAuthorizer", () => { ); }); + it("hides the session option and forwards the system note on a human-only ask", async () => { + const { deps, decisionFn } = makeDeps(); + const authorizer = new LocalUserAuthorizer(deps); + const deferNotes = [ + { + origin: "system" as const, + text: "⚠ 该操作将写入受保护文件:/vault/auth.json(凭据,匹配 /vault/auth.json)。", + }, + ]; + + await authorizer.authorize( + makeDetails({ + humanOnly: true, + deferNotes, + // A stale label must not resurrect the session option. + sessionLabel: "Yes, for 'read' tool", + }), + ); + + expect(decisionFn).toHaveBeenCalledWith( + expect.anything(), + expect.any(String), + expect.anything(), + { hideSessionOption: true, deferNotes }, + ); + }); + it("emits the UI event before calling requestPermissionDecision", async () => { const calls: string[] = []; const events = { diff --git a/pi-permission-system/test/authority/permission-dialog.test.ts b/pi-permission-system/test/authority/permission-dialog.test.ts index 0a375a5..1763a7a 100644 --- a/pi-permission-system/test/authority/permission-dialog.test.ts +++ b/pi-permission-system/test/authority/permission-dialog.test.ts @@ -178,6 +178,37 @@ describe("requestPermissionDecisionFromUi", () => { expect(options[1]).toBe("Yes, for this session"); }); + describe("hideSessionOption (human-only asks)", () => { + it("omits the session option when hideSessionOption is set", async () => { + const selectFn = vi.fn().mockResolvedValue("Yes"); + const ui: PermissionDecisionUi = { select: selectFn, input: vi.fn() }; + await requestPermissionDecisionFromUi(ui, "Title", "Message", { + hideSessionOption: true, + }); + const options = selectFn.mock.calls[0][1] as string[]; + expect(options).toEqual(["Yes", "No", "No, provide reason"]); + }); + + it("does not open the scope select when the session option is hidden", async () => { + const selectFn = vi.fn().mockResolvedValue("Yes"); + const ui: PermissionDecisionUi = { select: selectFn, input: vi.fn() }; + const result = await requestPermissionDecisionFromUi( + ui, + "Title", + "Message", + { + hideSessionOption: true, + sessionScope: { + subagentLabel: "This subagent only", + servingSessionLabel: "The whole session", + }, + }, + ); + expect(selectFn).toHaveBeenCalledTimes(1); + expect(result).toEqual({ approved: true, state: "approved" }); + }); + }); + describe("sessionScope two-step (forwarded asks)", () => { const sessionScope = { subagentLabel: "This subagent only", diff --git a/pi-permission-system/test/composition-root.test.ts b/pi-permission-system/test/composition-root.test.ts index 0083db9..b16418b 100644 --- a/pi-permission-system/test/composition-root.test.ts +++ b/pi-permission-system/test/composition-root.test.ts @@ -28,7 +28,6 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SUBAGENT_CHILD_SESSION_CREATED } from "#src/authority/subagent-lifecycle-events"; import { getSubagentSessionRegistry } from "#src/authority/subagent-registry"; -import { getGlobalConfigPath } from "#src/config-paths"; import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config"; import piPermissionSystemExtension from "#src/index"; import { PERMISSIONS_READY_CHANNEL } from "#src/permission-events"; @@ -55,9 +54,30 @@ const EXPECTED_HANDLERS = [ let agentDir: string; +/** + * Policy and hard-guard files live under the per-test temp dir and are passed + * to the factory explicitly — the bundle `config/` files are never touched. + */ +function testPolicyPath(): string { + return join(agentDir, "pi-permission-policy.jsonc"); +} +function testHardGuardsPath(): string { + return join(agentDir, "pi-permission-hard-guards.jsonc"); +} +function testConfigPaths(): { configPath: string; hardGuardConfigPath: string } { + return { + configPath: testPolicyPath(), + hardGuardConfigPath: testHardGuardsPath(), + }; +} + beforeEach(() => { agentDir = mkdtempSync(join(tmpdir(), "pi-perm-comp-root-")); vi.stubEnv("PI_CODING_AGENT_DIR", agentDir); + writeFileSync( + testHardGuardsPath(), + JSON.stringify({ version: 1, permission: { "*": "allow" } }), + ); }); afterEach(() => { @@ -75,9 +95,9 @@ afterEach(() => { // ── Shared helpers ────────────────────────────────────────────────────────── -/** Write the global config file under the stubbed agent dir. */ +/** Write the global policy file under the stubbed temp dir. */ function writeGlobalConfig(config: Record): void { - const globalConfigPath = getGlobalConfigPath(agentDir); + const globalConfigPath = testPolicyPath(); mkdirSync(dirname(globalConfigPath), { recursive: true }); writeFileSync( globalConfigPath, @@ -174,7 +194,7 @@ function fireSessionStart( describe("event-handler registration completeness", () => { it("registers a handler for every required event exactly once", () => { const pi = makeFakePi(); - piPermissionSystemExtension(pi as unknown as ExtensionAPI); + piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths()); expect([...pi.handlers.keys()].sort()).toEqual(EXPECTED_HANDLERS); }); @@ -186,7 +206,7 @@ describe("shutdown teardown chain", () => { it("unpublishes the service and unsubscribes the lifecycle on shutdown", async () => { const cwd = mkdtempSync(join(tmpdir(), "pi-perm-teardown-cwd-")); const pi = makeFakePi(); - piPermissionSystemExtension(pi as unknown as ExtensionAPI); + piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths()); // The service is published at session_start, not at factory init. await fireSessionStart(pi, makeChildCtx(cwd, "top-session")); @@ -219,7 +239,7 @@ describe("service and gate share one formatter registry", () => { const cwd = mkdtempSync(join(tmpdir(), "pi-perm-ui-cwd-")); const pi = makeFakePi({ toolNames: ["demo"] }); - piPermissionSystemExtension(pi as unknown as ExtensionAPI); + piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths()); const capturedTitles: string[] = []; const { ctx } = makeUiCtx(cwd, capturedTitles); @@ -257,7 +277,7 @@ describe("service and gate share one access extractor registry", () => { const cwd = mkdtempSync(join(tmpdir(), "pi-perm-ext-cwd-")); const pi = makeFakePi({ toolNames: ["ffgrep"] }); - piPermissionSystemExtension(pi as unknown as ExtensionAPI); + piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths()); const { ctx } = makeUiCtx(cwd, []); await fireSessionStart(pi, ctx); @@ -295,7 +315,7 @@ describe("service and chain share one authorizer registry", () => { const cwd = mkdtempSync(join(tmpdir(), "pi-perm-auth-cwd-")); const pi = makeFakePi({ toolNames: ["demo"] }); - piPermissionSystemExtension(pi as unknown as ExtensionAPI); + piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths()); const capturedTitles: string[] = []; const { ctx } = makeUiCtx(cwd, capturedTitles); @@ -327,7 +347,7 @@ describe("service and chain share one authorizer registry", () => { const cwd = mkdtempSync(join(tmpdir(), "pi-perm-auth-optin-")); const pi = makeFakePi({ toolNames: ["demo"] }); - piPermissionSystemExtension(pi as unknown as ExtensionAPI); + piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths()); const capturedTitles: string[] = []; const { ctx } = makeUiCtx(cwd, capturedTitles); @@ -364,7 +384,7 @@ describe("ready emitted after service publication", () => { seen.push(getPermissionsService() ? "present" : "missing"); }); - piPermissionSystemExtension(pi as unknown as ExtensionAPI); + piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths()); // ready is not emitted at load; only after session_start publishes. expect(seen).toEqual([]); @@ -389,7 +409,7 @@ describe("single source of truth for session state", () => { const cwd = mkdtempSync(join(tmpdir(), "pi-perm-sot-cwd-")); const pi = makeFakePi({ toolNames: ["demo"] }); - piPermissionSystemExtension(pi as unknown as ExtensionAPI); + piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths()); // UI ctx that approves the gate prompt for this session (options[1]). // Return the second option label-agnostically — always the "for this @@ -436,7 +456,7 @@ describe("service path queries evaluate the supplied path (#503)", () => { writeGlobalConfig({ permission: { path: { [target]: "deny" } } }); const pi = makeFakePi({ events: createEventBus() }); - piPermissionSystemExtension(pi as unknown as ExtensionAPI); + piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths()); await fireSessionStart(pi, makeChildCtx(cwd, "svc-path-session")); const result = getPermissionsService()!.checkPermission("path", target); @@ -453,7 +473,7 @@ describe("project trust gates project-scoped config (#644)", () => { writeProjectConfig(cwd, { permission: { bash: "allow" } }); const pi = makeFakePi({ events: createEventBus() }); - piPermissionSystemExtension(pi as unknown as ExtensionAPI); + piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths()); await fireSessionStart( pi, makeBaseCtx(cwd, "untrusted-session", { isProjectTrusted: false }), @@ -473,7 +493,7 @@ describe("project trust gates project-scoped config (#644)", () => { writeProjectConfig(cwd, { permission: { bash: "allow" } }); const pi = makeFakePi({ events: createEventBus() }); - piPermissionSystemExtension(pi as unknown as ExtensionAPI); + piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths()); await fireSessionStart( pi, makeBaseCtx(cwd, "trusted-session", { isProjectTrusted: true }), @@ -516,7 +536,7 @@ describe("bash bare-token path gating (#509, #645)", () => { }); const pi = makeFakePi({ events: createEventBus() }); - piPermissionSystemExtension(pi as unknown as ExtensionAPI); + piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths()); const ctx = makeChildCtx(cwd, "bare-token-session-deny"); await fireSessionStart(pi, ctx); @@ -534,7 +554,7 @@ describe("bash bare-token path gating (#509, #645)", () => { }); const pi = makeFakePi({ events: createEventBus() }); - piPermissionSystemExtension(pi as unknown as ExtensionAPI); + piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths()); const ctx = makeChildCtx(cwd, "bare-token-session-wildcard"); await fireSessionStart(pi, ctx); @@ -556,7 +576,7 @@ describe("bash bare-token path gating (#509, #645)", () => { }); const pi = makeFakePi({ events: createEventBus() }); - piPermissionSystemExtension(pi as unknown as ExtensionAPI); + piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths()); const ctx = makeChildCtx(cwd, "bare-symlink-session-deny"); await fireSessionStart(pi, ctx); @@ -573,7 +593,7 @@ describe("bash bare-token path gating (#509, #645)", () => { }); const pi = makeFakePi({ events: createEventBus() }); - piPermissionSystemExtension(pi as unknown as ExtensionAPI); + piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths()); const ctx = makeChildCtx(cwd, "bare-token-session-unaffected"); await fireSessionStart(pi, ctx); @@ -592,7 +612,7 @@ describe("bash bare-token path gating (#509, #645)", () => { }); const pi = makeFakePi({ events: createEventBus() }); - piPermissionSystemExtension(pi as unknown as ExtensionAPI); + piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths()); const ctx = makeChildCtx(cwd, "bare-token-session-absent"); await fireSessionStart(pi, ctx); @@ -613,7 +633,7 @@ describe("bash bare-token path gating (#509, #645)", () => { }); const pi = makeFakePi({ events: createEventBus() }); - piPermissionSystemExtension(pi as unknown as ExtensionAPI); + piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths()); const ctx = makeChildCtx(cwd, "bare-token-session-norule"); await fireSessionStart(pi, ctx); @@ -637,9 +657,9 @@ describe("multi-instance global service interplay", () => { const childSessionId = "child-session-mi"; const parentPi = makeFakePi({ events: createEventBus() }); - piPermissionSystemExtension(parentPi as unknown as ExtensionAPI); + piPermissionSystemExtension(parentPi as unknown as ExtensionAPI, testConfigPaths()); const childPi = makeFakePi({ events: createEventBus() }); - piPermissionSystemExtension(childPi as unknown as ExtensionAPI); + piPermissionSystemExtension(childPi as unknown as ExtensionAPI, testConfigPaths()); // The parent is not a registered child, so it publishes its service. await fireSessionStart( @@ -703,7 +723,7 @@ describe("session approvals do not leak across same-cwd session switches", () => // ── Session #1: approve `demo` for the session, then shut down ────────── const firstPi = makeFakePi({ toolNames: ["demo"] }); - piPermissionSystemExtension(firstPi as unknown as ExtensionAPI); + piPermissionSystemExtension(firstPi as unknown as ExtensionAPI, testConfigPaths()); const firstCtx = makeSessionApprovingCtx(cwd, "switch-session-1"); await fireSessionStart(firstPi, firstCtx); @@ -724,7 +744,7 @@ describe("session approvals do not leak across same-cwd session switches", () => // ── Session #2: the re-invoked cached factory, same cwd ──────────────── const secondPi = makeFakePi({ toolNames: ["demo"] }); - piPermissionSystemExtension(secondPi as unknown as ExtensionAPI); + piPermissionSystemExtension(secondPi as unknown as ExtensionAPI, testConfigPaths()); await fireSessionStart(secondPi, makeChildCtx(cwd, "switch-session-2")); @@ -776,12 +796,12 @@ describe("forwarded grant-scope selection round-trip", () => { const parentBus = createEventBus(); const parentPi = makeFakePi({ events: parentBus, toolNames: ["demo"] }); - piPermissionSystemExtension(parentPi as unknown as ExtensionAPI); + piPermissionSystemExtension(parentPi as unknown as ExtensionAPI, testConfigPaths()); const childPi = makeFakePi({ events: createEventBus(), toolNames: ["demo"], }); - piPermissionSystemExtension(childPi as unknown as ExtensionAPI); + piPermissionSystemExtension(childPi as unknown as ExtensionAPI, testConfigPaths()); // The parent starts serving (its poll drains the inbox using the UI ctx). const parentCtx = makeServingCtx( @@ -841,12 +861,12 @@ describe("forwarded grant-scope selection round-trip", () => { const parentBus = createEventBus(); const parentPi = makeFakePi({ events: parentBus, toolNames: ["demo"] }); - piPermissionSystemExtension(parentPi as unknown as ExtensionAPI); + piPermissionSystemExtension(parentPi as unknown as ExtensionAPI, testConfigPaths()); const childPi = makeFakePi({ events: createEventBus(), toolNames: ["demo"], }); - piPermissionSystemExtension(childPi as unknown as ExtensionAPI); + piPermissionSystemExtension(childPi as unknown as ExtensionAPI, testConfigPaths()); const parentCtx = makeServingCtx( parentCwd, @@ -905,7 +925,7 @@ describe("yolo grants asks synthesized after resolution", () => { writeGlobalConfig(config); const cwd = mkdtempSync(join(tmpdir(), "pi-perm-yolo-cwd-")); const pi = makeFakePi({ toolNames: ["bash"] }); - piPermissionSystemExtension(pi as unknown as ExtensionAPI); + piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths()); const prompts: string[] = []; const { ctx } = makeUiCtx(cwd, prompts); diff --git a/pi-permission-system/test/config-loader.test.ts b/pi-permission-system/test/config-loader.test.ts index 6ccd694..e0de040 100644 --- a/pi-permission-system/test/config-loader.test.ts +++ b/pi-permission-system/test/config-loader.test.ts @@ -1,6 +1,6 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { @@ -673,13 +673,14 @@ describe("loadAndMergeConfigs", () => { let tempDir: string; let agentDir: string; let cwd: string; - let extensionRoot: string; + let globalConfigPath: string; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "config-merge-test-")); agentDir = join(tempDir, "agent"); cwd = join(tempDir, "project"); - extensionRoot = join(tempDir, "ext"); + // Tests inject a temp policy path; the bundle config/ file is never touched. + globalConfigPath = join(tempDir, "config", "pi-permission-policy.jsonc"); }); afterEach(() => { @@ -687,9 +688,8 @@ describe("loadAndMergeConfigs", () => { }); function writeGlobal(content: Record): void { - const dir = join(agentDir, "my-pi", "permission-system"); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "policy.jsonc"), JSON.stringify(content)); + mkdirSync(dirname(globalConfigPath), { recursive: true }); + writeFileSync(globalConfigPath, JSON.stringify(content)); } function writeProject(content: Record): void { @@ -712,72 +712,26 @@ describe("loadAndMergeConfigs", () => { writeFileSync(join(dir, "pi-permissions.jsonc"), JSON.stringify(content)); } - function writeLegacyExtensionConfig(content: Record): void { - mkdirSync(extensionRoot, { recursive: true }); - writeFileSync(join(extensionRoot, "config.json"), JSON.stringify(content)); - } - - it("detects legacy global policy and emits migration issue", () => { + it("ignores leftover files at legacy locations", () => { writeLegacyGlobalPolicy({ defaultPolicy: { tools: "allow" }, tools: { read: "allow" }, }); - - const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot); - expect(result.issues).toHaveLength(1); - expect(result.issues[0]).toContain("pi-permissions.jsonc"); - expect(result.issues[0]).toContain("my-pi/permission-system"); - // Legacy file has no flat-format permission key — no rules extracted - expect(result.merged.permission).toBeUndefined(); - }); - - - it("detects legacy extension runtime config and emits migration issue", () => { - writeLegacyExtensionConfig({ - debugLog: true, - yoloMode: true, - }); - - const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot); - expect(result.issues).toHaveLength(1); - expect(result.issues[0]).toContain(extensionRoot); - expect(result.merged.debugLog).toBe(true); - expect(result.merged.yoloMode).toBe(true); - }); - - it("does not emit legacy extension config issue when its path equals the runtime legacy path", () => { - const newGlobalDir = join(agentDir, "extensions", "pi-permission-system"); - mkdirSync(newGlobalDir, { recursive: true }); - writeFileSync( - join(newGlobalDir, "config.json"), - JSON.stringify({ debugLog: true }), - ); - - const result = loadAndMergeConfigs(agentDir, cwd, newGlobalDir); - expect(result.issues.filter((i) => i.includes("legacy"))).toHaveLength(0); - }); - - it("emits no issues when no legacy files exist and no new files exist", () => { - const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot); - expect(result.issues).toEqual([]); - }); - - it("new-layout config takes precedence over legacy config at same scope", () => { writeGlobal({ permission: { "*": "deny" }, }); - writeLegacyGlobalPolicy({ - permission: { "*": "allow" }, - }); - const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot); - // New layout wins (legacy loaded first, new layout loaded second → new wins) + const result = loadAndMergeConfigs(globalConfigPath); + // The legacy file is simply ignored — never merged, never flagged. + expect(result.issues).toEqual([]); expect(result.merged.permission).toEqual({ "*": "deny" }); - // But legacy still emits a migration warning - expect(result.issues.some((i) => i.includes("pi-permissions.jsonc"))).toBe( - true, - ); + }); + + it("emits no issues when no config file exists", () => { + const result = loadAndMergeConfigs(globalConfigPath); + expect(result.issues).toEqual([]); + expect(result.merged).toEqual({}); }); it("warns when the merged config leaves bash inheriting a permissive top-level '*'", () => { @@ -785,7 +739,7 @@ describe("loadAndMergeConfigs", () => { permission: { "*": "allow", read: "allow" }, }); - const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot); + const result = loadAndMergeConfigs(globalConfigPath); expect(result.issues.some((i) => i.includes("bash"))).toBe(true); }); @@ -794,7 +748,7 @@ describe("loadAndMergeConfigs", () => { permission: { "*": "allow", bash: { "*": "ask" } }, }); - const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot); + const result = loadAndMergeConfigs(globalConfigPath); expect(result.issues).toEqual([]); }); @@ -803,7 +757,7 @@ describe("loadAndMergeConfigs", () => { writeProject({ permission: { bash: "allow" } }); writeLegacyProjectPolicy({ permission: { "*": "allow" } }); - const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot); + const result = loadAndMergeConfigs(globalConfigPath); expect(result.merged.permission).toEqual({ "*": "ask", bash: "deny" }); expect(result.issues).toEqual([]); @@ -816,7 +770,7 @@ describe("loadAndMergeConfigs", () => { toolInputPreviewMaxLength: 400, }); - const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot); + const result = loadAndMergeConfigs(globalConfigPath); expect(result.issues).toHaveLength(1); expect(result.issues[0]).toContain("toolInputPreviewMaxLength"); @@ -828,7 +782,7 @@ describe("loadAndMergeConfigs", () => { toolTextSummaryMaxLength: 120, }); - const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot); + const result = loadAndMergeConfigs(globalConfigPath); expect(result.merged.permission).toEqual({ "*": "ask", bash: "deny" }); }); @@ -836,7 +790,7 @@ describe("loadAndMergeConfigs", () => { it("stays silent when neither cap is set", () => { writeGlobal({ permission: { "*": "ask", bash: "deny" } }); - const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot); + const result = loadAndMergeConfigs(globalConfigPath); expect(result.issues).toEqual([]); }); diff --git a/pi-permission-system/test/config-paths.test.ts b/pi-permission-system/test/config-paths.test.ts index 8ea7078..470b914 100644 --- a/pi-permission-system/test/config-paths.test.ts +++ b/pi-permission-system/test/config-paths.test.ts @@ -1,30 +1,35 @@ import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { getGlobalConfigPath, getGlobalLogsDir, - getLegacyGlobalPolicyPath, + getHardGuardConfigPath, } from "#src/config-paths"; +// Bundle-level `config/` is the single source of truth — two levels up from +// both this test file and src/config-paths.ts. +const BUNDLE_CONFIG_DIR = fileURLToPath(new URL("../../config", import.meta.url)); + describe("config-paths", () => { const agentDir = "/home/user/.pi/agent"; - it("places the global policy at my-pi/permission-system/policy.jsonc under the agent dir", () => { - expect(getGlobalConfigPath(agentDir)).toBe( - join(agentDir, "my-pi", "permission-system", "policy.jsonc"), + it("reads the global policy from the bundle-level config directory", () => { + expect(getGlobalConfigPath()).toBe( + join(BUNDLE_CONFIG_DIR, "pi-permission-policy.jsonc"), ); }); - it("places logs next to the global config dir", () => { + it("reads hard guards from the bundle-level config directory", () => { + expect(getHardGuardConfigPath()).toBe( + join(BUNDLE_CONFIG_DIR, "pi-permission-hard-guards.jsonc"), + ); + }); + + it("keeps logs under the disposable agent-dir my-pi tree", () => { expect(getGlobalLogsDir(agentDir)).toBe( join(agentDir, "my-pi", "permission-system", "logs"), ); }); - - it("keeps the legacy global policy path for migration detection", () => { - expect(getLegacyGlobalPolicyPath(agentDir)).toBe( - join(agentDir, "pi-permissions.jsonc"), - ); - }); }); diff --git a/pi-permission-system/test/config-pipeline.test.ts b/pi-permission-system/test/config-pipeline.test.ts index 1794bcc..fff9c1f 100644 --- a/pi-permission-system/test/config-pipeline.test.ts +++ b/pi-permission-system/test/config-pipeline.test.ts @@ -1,6 +1,6 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { loadAndMergeConfigs } from "#src/config-loader"; @@ -20,15 +20,12 @@ import { */ describe("config pipeline seam", () => { let tempDir: string; - let agentDir: string; - let cwd: string; - let extensionRoot: string; + let globalConfigPath: string; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "config-pipeline-test-")); - agentDir = join(tempDir, "agent"); - cwd = join(tempDir, "project"); - extensionRoot = join(tempDir, "ext"); + // Tests inject a temp policy path; the bundle config/ file is never touched. + globalConfigPath = join(tempDir, "config", "pi-permission-policy.jsonc"); }); afterEach(() => { @@ -36,15 +33,14 @@ describe("config pipeline seam", () => { }); function writeGlobal(content: Record): void { - const dir = join(agentDir, "my-pi", "permission-system"); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "policy.jsonc"), JSON.stringify(content)); + mkdirSync(dirname(globalConfigPath), { recursive: true }); + writeFileSync(globalConfigPath, JSON.stringify(content)); } it("a runtime knob survives the full pipeline", () => { writeGlobal({ debugLog: true }); - const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot); + const mergeResult = loadAndMergeConfigs(globalConfigPath); const config = normalizePermissionSystemConfig(mergeResult.merged); expect(config.debugLog).toBe(true); @@ -56,7 +52,7 @@ describe("config pipeline seam", () => { it("a deprecated preview cap reaches the merge intermediate but not the runtime config", () => { writeGlobal({ toolInputPreviewMaxLength: 1000 }); - const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot); + const mergeResult = loadAndMergeConfigs(globalConfigPath); const config = normalizePermissionSystemConfig(mergeResult.merged); expect(mergeResult.merged.toolInputPreviewMaxLength).toBe(1000); @@ -66,7 +62,7 @@ describe("config pipeline seam", () => { it("dialog budget fields survive the full pipeline and resolve to a render budget", () => { writeGlobal({ promptMaxRows: 12, promptFieldMaxWidth: 80 }); - const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot); + const mergeResult = loadAndMergeConfigs(globalConfigPath); const config = normalizePermissionSystemConfig(mergeResult.merged); expect(config.promptMaxRows).toBe(12); @@ -78,7 +74,7 @@ describe("config pipeline seam", () => { }); it("falls back to the default render budget when the config names neither field", () => { - const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot); + const mergeResult = loadAndMergeConfigs(globalConfigPath); const config = normalizePermissionSystemConfig(mergeResult.merged); expect(resolveRenderBudget(config)).toEqual(DEFAULT_RENDER_BUDGET); @@ -87,7 +83,7 @@ describe("config pipeline seam", () => { it("a deprecated text-summary cap likewise stops at the merge intermediate", () => { writeGlobal({ toolTextSummaryMaxLength: 250 }); - const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot); + const mergeResult = loadAndMergeConfigs(globalConfigPath); const config = normalizePermissionSystemConfig(mergeResult.merged); expect(mergeResult.merged.toolTextSummaryMaxLength).toBe(250); @@ -96,8 +92,8 @@ describe("config pipeline seam", () => { it("defaults apply when config file is absent", () => { - // No config files written — agentDir and cwd directories don't exist. - const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot); + // No config file written — the injected path does not exist. + const mergeResult = loadAndMergeConfigs(globalConfigPath); const config = normalizePermissionSystemConfig(mergeResult.merged); expect(config.debugLog).toBe(false); diff --git a/pi-permission-system/test/config-reporter.test.ts b/pi-permission-system/test/config-reporter.test.ts index 3aae14e..8b0b6b6 100644 --- a/pi-permission-system/test/config-reporter.test.ts +++ b/pi-permission-system/test/config-reporter.test.ts @@ -15,7 +15,7 @@ import { PermissionManager } from "#src/permission-manager"; test("buildResolvedConfigLogEntry reports global and agent policy paths", () => { const policyPaths: ResolvedPolicyPaths = { - globalConfigPath: "/home/user/.pi/agent/my-pi/permission-system/policy.jsonc", + globalConfigPath: "/bundle/config/pi-permission-policy.jsonc", globalConfigExists: true, agentsDir: "/home/user/.pi/agent/agents", agentsDirExists: true, @@ -25,11 +25,7 @@ test("buildResolvedConfigLogEntry reports global and agent policy paths", () => const result = buildResolvedConfigLogEntry({ policyPaths }); - expect(result).toEqual({ - ...policyPaths, - legacyGlobalPolicyDetected: false, - legacyExtensionConfigDetected: false, - }); + expect(result).toEqual(policyPaths); }); test("config.resolved entry appears in review log via logger", () => { @@ -78,8 +74,6 @@ test("config.resolved entry appears in review log via logger", () => { expect(parsed.agentsDirExists).toBe(false); expect(parsed.projectAgentsDir).toBe(null); expect(parsed.projectAgentsDirExists).toBe(false); - expect(parsed.legacyGlobalPolicyDetected).toBe(false); - expect(parsed.legacyExtensionConfigDetected).toBe(false); } finally { rmSync(tempDir, { recursive: true, force: true }); } diff --git a/pi-permission-system/test/config-store.test.ts b/pi-permission-system/test/config-store.test.ts index 4f76c78..9aaf272 100644 --- a/pi-permission-system/test/config-store.test.ts +++ b/pi-permission-system/test/config-store.test.ts @@ -122,6 +122,7 @@ function makeStore(overrides: Partial = {}): { agentDir: "/test/agent", policyPaths: makePolicyPathProvider(), logger, + configPath: "/test/bundle-config/pi-permission-policy.jsonc", ...overrides, }; return { store: new ConfigStore(deps), logger }; @@ -159,34 +160,26 @@ describe("ConfigStore", () => { // ── refresh() ───────────────────────────────────────────────────────── describe("refresh()", () => { - it("uses the passed ctx cwd for global-only config loading", () => { + it("loads the configured global policy path", () => { const { store } = makeStore(); store.refresh(makeCtx({ cwd: "/my/project" }), true); expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith( - "/test/agent", - "/my/project", - expect.any(String), + "/test/bundle-config/pi-permission-policy.jsonc", ); }); - it("does not add a project config scope when the project is untrusted", () => { + it("loads the same global policy regardless of project trust", () => { const { store } = makeStore(); store.refresh(makeCtx({ cwd: "/my/project" }), false); expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith( - "/test/agent", - "/my/project", - expect.any(String), + "/test/bundle-config/pi-permission-policy.jsonc", ); }); - it("uses empty string cwd when no ctx is provided", () => { - const { store } = makeStore(); + it("falls back to the bundle policy path when no override is configured", () => { + const { store } = makeStore({ configPath: undefined }); store.refresh(undefined, true); - expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith( - "/test/agent", - "", - expect.any(String), - ); + expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(undefined); }); it("updates current() with normalized merged result", () => { @@ -433,30 +426,14 @@ describe("ConfigStore", () => { expect(mockProvider.getResolvedPolicyPaths).toHaveBeenCalled(); }); - it("passes legacy detection results to buildResolvedConfigLogEntry", () => { + it("passes the resolved policy paths to buildResolvedConfigLogEntry", () => { const { store } = makeStore(); - // Make one legacy path exist - mockExistsSync.mockImplementation((p: string) => - p.includes("policies.json"), - ); store.logResolvedPaths("/some/project"); - expect(mockBuildResolvedConfigLogEntry).toHaveBeenCalledWith( - expect.objectContaining({ - legacyGlobalPolicyDetected: expect.any(Boolean), - legacyExtensionConfigDetected: expect.any(Boolean), + expect(mockBuildResolvedConfigLogEntry).toHaveBeenCalledWith({ + policyPaths: expect.objectContaining({ + globalConfigPath: "/agent/config.json", }), - ); - }); - - it("checks only supported global legacy paths", () => { - const { store } = makeStore(); - store.logResolvedPaths(); // no cwd - // existsSync called for global and ext-config legacy paths only (not project) - const calls = mockExistsSync.mock.calls.map(([p]: [string]) => p); - const projectCalls = calls.filter( - (p) => p.includes("/null/") || p.includes("null"), - ); - expect(projectCalls).toHaveLength(0); + }); }); }); }); diff --git a/pi-permission-system/test/handlers/gates/protected-write-runner.test.ts b/pi-permission-system/test/handlers/gates/protected-write-runner.test.ts new file mode 100644 index 0000000..df62771 --- /dev/null +++ b/pi-permission-system/test/handlers/gates/protected-write-runner.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from "vitest"; + +import { protectedWriteDeferNote } from "#src/handlers/gates/protected-write"; +import type { ProtectedWriteTarget } from "#src/handlers/gates/descriptor"; +import type { PermissionCheckResult } from "#src/types"; + +import { DECIDED_BY_HUMAN } from "#test/helpers/decision-fixtures"; +import { makeDescriptor, makeGateRunner } from "#test/helpers/gate-fixtures"; +import { makeCheckResult } from "#test/helpers/handler-fixtures"; + +const MARKER: ProtectedWriteTarget = { + path: "/vault/auth.json", + pattern: "/vault/auth.json", + label: "Pi 凭据", + extraCount: 0, +}; + +function protectedDescriptor(check: PermissionCheckResult) { + return makeDescriptor({ + humanOnly: MARKER, + sessionApproval: undefined, + preCheck: check, + }); +} + +describe("GateRunner — humanOnly protected writes", () => { + it("still escalates when the resolved check is a session grant", async () => { + const { runner, deps } = makeGateRunner(); + const outcome = await runner.run( + protectedDescriptor( + makeCheckResult({ + state: "ask", + source: "session", + matchedPattern: "/vault/**", + }), + ), + null, + ); + + expect(deps.escalate).toHaveBeenCalledOnce(); + expect(outcome).toEqual({ action: "allow" }); + expect(deps.reporter.writeReviewLog).not.toHaveBeenCalledWith( + "permission_request.session_approved", + expect.anything(), + ); + }); + + it("still escalates when yolo mode is enabled", async () => { + const { runner, deps } = makeGateRunner({ yolo: true }); + await runner.run( + protectedDescriptor( + makeCheckResult({ state: "ask", source: "tool", origin: "builtin" }), + ), + null, + ); + + expect(deps.escalate).toHaveBeenCalledOnce(); + expect(deps.reporter.writeReviewLog).not.toHaveBeenCalledWith( + "permission_request.auto_approved", + expect.anything(), + ); + }); + + it("escalates with humanOnly, the system note, and no session approval", async () => { + const { runner, deps } = makeGateRunner(); + await runner.run( + protectedDescriptor( + makeCheckResult({ state: "ask", source: "tool", origin: "builtin" }), + ), + null, + ); + + expect(deps.escalate).toHaveBeenCalledWith( + expect.objectContaining({ + humanOnly: true, + deferNotes: [protectedWriteDeferNote(MARKER)], + }), + ); + const details = vi.mocked(deps.escalate).mock.calls[0][0]; + expect(details.sessionApproval).toBeUndefined(); + }); + + it("records no session grant when the user approves", async () => { + const { runner, deps } = makeGateRunner({ + escalate: vi.fn().mockResolvedValue({ + approved: true, + state: "approved_for_session", + decidedBy: DECIDED_BY_HUMAN, + }), + }); + const outcome = await runner.run( + protectedDescriptor( + makeCheckResult({ state: "ask", source: "tool", origin: "builtin" }), + ), + null, + ); + + // A session-shaped approval cannot be recorded: the descriptor carries no + // sessionApproval to grant. + expect(deps.recordSessionApproval).not.toHaveBeenCalled(); + expect(outcome).toEqual({ action: "allow" }); + }); +}); + +describe("protectedWriteDeferNote", () => { + it("renders the exact system note", () => { + expect(protectedWriteDeferNote(MARKER)).toEqual({ + origin: "system", + text: "⚠ 该操作将写入受保护文件:/vault/auth.json(Pi 凭据,匹配 /vault/auth.json)。此类写入不经 AutoReview,必须由你人工确认。", + }); + }); + + it("adds the extra-target count", () => { + const note = protectedWriteDeferNote({ ...MARKER, extraCount: 2 }); + expect(note.text).toContain(",另有 2 个受保护目标"); + }); +}); diff --git a/pi-permission-system/test/handlers/gates/protected-write.test.ts b/pi-permission-system/test/handlers/gates/protected-write.test.ts new file mode 100644 index 0000000..c41631a --- /dev/null +++ b/pi-permission-system/test/handlers/gates/protected-write.test.ts @@ -0,0 +1,300 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Mock node:fs so realpathSync (used by canonicalizePath) is controllable for +// the symlink-into-protected-directory case; identity by default. +const realpathSync = vi.hoisted(() => + vi.fn<(path: string) => string>((p) => p), +); +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + realpathSync, + default: { ...actual, realpathSync }, + }; +}); + +import { BashProgram } from "#src/access-intent/bash/program"; +import type { ShellInvocation } from "#src/access-intent/tool-kind"; +import { + type GateDescriptor, + type GateResult, + isGateDescriptor, +} from "#src/handlers/gates/descriptor"; +import { describeProtectedWriteGate } from "#src/handlers/gates/protected-write"; +import type { ProtectedWriteRule } from "#src/hard-guard-loader"; +import { pathFlavorForPlatform } from "#src/path/path-flavor"; +import { PathNormalizer } from "#src/path-normalizer"; + +import { makeTcc } from "#test/helpers/gate-fixtures"; + +const CWD = "/test/project"; + +beforeEach(() => { + realpathSync.mockReset(); + realpathSync.mockImplementation((p: string) => p); +}); + +const PROTECTED_WRITES: ProtectedWriteRule[] = [ + { pattern: "/vault/auth.json", label: "Pi 凭据" }, + { pattern: "/protected/**", label: "受保护目录" }, +]; + +function normalizer(): PathNormalizer { + return new PathNormalizer(pathFlavorForPlatform(process.platform), CWD); +} + +async function describeBashCall( + command: string, + rules: readonly ProtectedWriteRule[] = PROTECTED_WRITES, +) { + const tcc = makeTcc({ toolName: "bash", input: { command } }); + const bashProgram = command + ? await BashProgram.parse(command, normalizer()) + : null; + const shell: ShellInvocation = { command, workdir: undefined }; + return describeProtectedWriteGate( + tcc, + bashProgram, + shell, + normalizer(), + rules, + ); +} + +function describeToolCall( + toolName: string, + input: unknown, + rules: readonly ProtectedWriteRule[] = PROTECTED_WRITES, +) { + const tcc = makeTcc({ toolName, input }); + return describeProtectedWriteGate(tcc, null, null, normalizer(), rules); +} + +function asDescriptor(result: GateResult): GateDescriptor { + expect(isGateDescriptor(result)).toBe(true); + return result as GateDescriptor; +} + +describe("describeProtectedWriteGate", () => { + describe("write/edit tools", () => { + it("prompts on a write tool targeting a protected file", () => { + const desc = asDescriptor( + describeToolCall("write", { path: "/vault/auth.json" }), + ); + expect(desc.humanOnly).toEqual({ + path: "/vault/auth.json", + pattern: "/vault/auth.json", + label: "Pi 凭据", + extraCount: 0, + }); + expect(desc.preCheck?.state).toBe("ask"); + expect(desc.sessionApproval).toBeUndefined(); + expect(desc.logContext.protectedWrite).toEqual({ + path: "/vault/auth.json", + pattern: "/vault/auth.json", + label: "Pi 凭据", + }); + }); + + it("prompts on an edit tool targeting a protected file", () => { + expect( + isGateDescriptor( + describeToolCall("edit", { path: "/vault/auth.json" }), + ), + ).toBe(true); + }); + + it("does not gate a read of a protected path", () => { + expect( + describeToolCall("read", { path: "/vault/auth.json" }), + ).toBeNull(); + }); + + it("covers a whole subtree, not just the directory root", () => { + expect( + isGateDescriptor( + describeToolCall("write", { path: "/protected/deep/nested/x.json" }), + ), + ).toBe(true); + }); + + it("prompts on a symlink whose target is protected", () => { + realpathSync.mockImplementation((p: string) => + p === "/work/link" ? "/vault/auth.json" : p, + ); + const desc = asDescriptor( + describeToolCall("write", { path: "/work/link" }), + ); + expect(desc.humanOnly?.pattern).toBe("/vault/auth.json"); + }); + }); + + describe("bash writes", () => { + it("prompts on a redirect into a protected file", async () => { + expect( + isGateDescriptor(await describeBashCall("echo x > /vault/auth.json")), + ).toBe(true); + }); + + it("prompts on a redirect into a not-yet-existing protected path", async () => { + expect( + isGateDescriptor( + await describeBashCall("echo 1 > /protected/new-file.json"), + ), + ).toBe(true); + }); + + it("does not prompt on cat of a protected file", async () => { + expect(await describeBashCall("cat /vault/auth.json")).toBeNull(); + }); + + it("does not prompt when a protected read feeds an unprotected write", async () => { + expect( + await describeBashCall("cat /vault/auth.json > /tmp/x"), + ).toBeNull(); + }); + + it("does not prompt on a redirect-in read of a protected file", async () => { + expect(await describeBashCall("cat < /vault/auth.json")).toBeNull(); + }); + + it("prompts on tee of a protected file", async () => { + expect( + isGateDescriptor(await describeBashCall("tee /vault/auth.json")), + ).toBe(true); + }); + + it("prompts on cp into a protected file", async () => { + expect( + isGateDescriptor( + await describeBashCall("cp /tmp/x /vault/auth.json"), + ), + ).toBe(true); + }); + + it("prompts on sed -i of a protected file", async () => { + expect( + isGateDescriptor( + await describeBashCall("sed -i s/a/b/ /vault/auth.json"), + ), + ).toBe(true); + }); + + it("prompts on an opaque bash -c payload writing a protected file", async () => { + expect( + isGateDescriptor( + await describeBashCall('bash -c "echo > /vault/auth.json"'), + ), + ).toBe(true); + }); + + it("prompts on a relative redirect after cd into a protected directory", async () => { + expect( + isGateDescriptor( + await describeBashCall("cd /protected && echo 1 > rel.json"), + ), + ).toBe(true); + }); + + it("prompts on an unparseable command containing a protected path", async () => { + const tcc = makeTcc({ + toolName: "bash", + input: { command: "echo broken > /vault/auth.json" }, + }); + // Parser failure is injected: the gate must scan the text directly. + const shell: ShellInvocation = { + command: "echo broken > /vault/auth.json", + workdir: undefined, + }; + const result = describeProtectedWriteGate( + tcc, + null, + shell, + normalizer(), + PROTECTED_WRITES, + ); + expect(isGateDescriptor(result)).toBe(true); + }); + + it("reports the extra protected targets in the same call", async () => { + const desc = asDescriptor( + await describeBashCall( + "echo x > /vault/auth.json && echo y > /protected/a.json", + ), + ); + expect(desc.humanOnly?.extraCount).toBe(1); + }); + }); + + describe("non-local tools and empty rules", () => { + it("does not gate an ssh_bash-style aliased shell tool", async () => { + const tcc = makeTcc({ + toolName: "ssh_bash", + input: { command: "echo x > /vault/auth.json" }, + }); + const bashProgram = await BashProgram.parse( + "echo x > /vault/auth.json", + normalizer(), + ); + const shell: ShellInvocation = { + command: "echo x > /vault/auth.json", + workdir: undefined, + }; + expect( + describeProtectedWriteGate( + tcc, + bashProgram, + shell, + normalizer(), + PROTECTED_WRITES, + ), + ).toBeNull(); + }); + + it("returns null when no protectedWrites are configured", async () => { + expect( + await describeBashCall("echo x > /vault/auth.json", []), + ).toBeNull(); + expect( + describeToolCall("write", { path: "/vault/auth.json" }, []), + ).toBeNull(); + }); + }); + + describe("bare token existence probe", () => { + // `auth.json` is a bare token (no path shape), so it only becomes a + // candidate when the resolver's existence probe finds it on disk; the + // probe must carry the same role/program metadata as shaped tokens. + it("does not prompt on a read of an existing bare-token file in a protected directory", async () => { + const dir = mkdtempSync(join(tmpdir(), "pw-probe-")); + try { + writeFileSync(join(dir, "auth.json"), "x"); + const rules = [{ pattern: `${dir}/**`, label: "受保护目录" }]; + expect( + await describeBashCall(`cd ${dir} && cat auth.json`, rules), + ).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("prompts on rm of an existing bare-token file in a protected directory", async () => { + const dir = mkdtempSync(join(tmpdir(), "pw-probe-")); + try { + writeFileSync(join(dir, "auth.json"), "x"); + const rules = [{ pattern: `${dir}/**`, label: "受保护目录" }]; + expect( + isGateDescriptor( + await describeBashCall(`cd ${dir} && rm auth.json`, rules), + ), + ).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + }); +}); diff --git a/pi-permission-system/test/handlers/gates/tool-call-gate-pipeline.test.ts b/pi-permission-system/test/handlers/gates/tool-call-gate-pipeline.test.ts index 191a197..eabaabc 100644 --- a/pi-permission-system/test/handlers/gates/tool-call-gate-pipeline.test.ts +++ b/pi-permission-system/test/handlers/gates/tool-call-gate-pipeline.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { AccessPath } from "#src/access-intent/access-path"; import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline"; import { PathNormalizer } from "#src/path-normalizer"; @@ -37,7 +36,7 @@ function makeMockBashProgram(command = "echo hello") { commandText: vi.fn(() => command), commands: vi.fn<() => []>(() => []), pathRuleCandidates: vi.fn<() => []>(() => []), - externalPaths: vi.fn<() => AccessPath[]>(() => []), + writeCandidates: vi.fn<() => []>(() => []), }; } @@ -200,7 +199,7 @@ describe("ToolCallGatePipeline", () => { commandText: vi.fn(() => text), commands: vi.fn(() => [{ text }]), pathRuleCandidates: vi.fn<() => []>(() => []), - externalPaths: vi.fn<() => AccessPath[]>(() => []), + writeCandidates: vi.fn<() => []>(() => []), }; } diff --git a/pi-permission-system/test/hard-guard-loader.test.ts b/pi-permission-system/test/hard-guard-loader.test.ts index c01e796..2a24926 100644 --- a/pi-permission-system/test/hard-guard-loader.test.ts +++ b/pi-permission-system/test/hard-guard-loader.test.ts @@ -1,5 +1,5 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -7,10 +7,25 @@ import { HardGuardPolicyLoader } from "#src/hard-guard-loader"; const roots: string[] = []; +const ENV_KEYS = ["PI_CODING_AGENT_DIR", "XDG_CONFIG_HOME"] as const; +const savedEnv: Partial> = + {}; + afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + delete savedEnv[key]; + } }); +function setEnv(key: (typeof ENV_KEYS)[number], value: string | undefined) { + if (!(key in savedEnv)) savedEnv[key] = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; +} + describe("HardGuardPolicyLoader", () => { it("loads only the global JSONC hard-guard file and preserves allow exceptions", () => { const root = mkdtempSync(join(tmpdir(), "hard-guard-loader-")); @@ -27,7 +42,7 @@ describe("HardGuardPolicyLoader", () => { } }`); - const loader = new HardGuardPolicyLoader(userPath, join(root, "missing-fallback.jsonc")); + const loader = new HardGuardPolicyLoader(userPath); const config = loader.loadGlobalConfig(); expect(config.permission).toEqual({ @@ -38,13 +53,24 @@ describe("HardGuardPolicyLoader", () => { expect(loader.getConfigIssues()).toEqual([]); }); - it("fails closed when neither the user file nor fallback is valid", () => { + it("fails closed with an issue when the file is missing", () => { + const root = mkdtempSync(join(tmpdir(), "hard-guard-loader-")); + roots.push(root); + const userPath = join(root, "hard-guards.jsonc"); + + const loader = new HardGuardPolicyLoader(userPath); + + expect(loader.loadGlobalConfig().permission).toEqual({ "*": "deny" }); + expect(loader.getConfigIssues().join("\n")).toContain("denying all"); + }); + + it("fails closed when the file is invalid", () => { const root = mkdtempSync(join(tmpdir(), "hard-guard-loader-")); roots.push(root); const userPath = join(root, "hard-guards.jsonc"); writeFileSync(userPath, `{ "version": 1, "permission": { "*": "ask" } }`); - const loader = new HardGuardPolicyLoader(userPath, join(root, "missing-fallback.jsonc")); + const loader = new HardGuardPolicyLoader(userPath); expect(loader.loadGlobalConfig().permission).toEqual({ "*": "deny" }); expect(loader.getConfigIssues().join("\n")).toContain("allow or deny"); @@ -59,10 +85,7 @@ describe("HardGuardPolicyLoader", () => { `{ "version": 2, "permission": { "*": "allow" } }`, ); - const loader = new HardGuardPolicyLoader( - userPath, - join(root, "missing-fallback.jsonc"), - ); + const loader = new HardGuardPolicyLoader(userPath); expect(loader.loadGlobalConfig().permission).toEqual({ "*": "deny" }); expect(loader.getConfigIssues().join("\n")).toContain( @@ -79,10 +102,7 @@ describe("HardGuardPolicyLoader", () => { `{ "version": 1, "permission": { "*": "allow", "~/.ssh/**": "deny" } }`, ); - const loader = new HardGuardPolicyLoader( - userPath, - join(root, "missing-fallback.jsonc"), - ); + const loader = new HardGuardPolicyLoader(userPath); const first = loader.loadGlobalConfig(); expect(first.permission).toEqual({ "*": "allow", @@ -107,10 +127,7 @@ describe("HardGuardPolicyLoader", () => { `{ "version": 1, "permission": { "*": "allow", "~/.ssh/**": "deny" } }`, ); - const loader = new HardGuardPolicyLoader( - userPath, - join(root, "missing-fallback.jsonc"), - ); + const loader = new HardGuardPolicyLoader(userPath); loader.loadGlobalConfig(); writeFileSync(userPath, "{ not json"); loader.loadGlobalConfig(); @@ -126,4 +143,106 @@ describe("HardGuardPolicyLoader", () => { "~/.aws/**": "deny", }); }); + + describe("protectedWrites", () => { + function writeConfig(userPath: string, protectedWrites: unknown): void { + writeFileSync( + userPath, + JSON.stringify({ + version: 1, + permission: { "*": "allow" }, + protectedWrites, + }), + ); + } + + function makeLoader() { + const root = mkdtempSync(join(tmpdir(), "hard-guard-loader-")); + roots.push(root); + const userPath = join(root, "hard-guards.jsonc"); + return { root, userPath, loader: new HardGuardPolicyLoader(userPath) }; + } + + it("expands {agentDir} honoring PI_CODING_AGENT_DIR", () => { + setEnv("PI_CODING_AGENT_DIR", "/tmp/fake-agent-dir"); + const { userPath, loader } = makeLoader(); + writeConfig(userPath, [ + { pattern: "{agentDir}/auth.json", label: "Pi 凭据" }, + ]); + + expect(loader.getProtectedWrites()).toEqual([ + { pattern: "/tmp/fake-agent-dir/auth.json", label: "Pi 凭据" }, + ]); + }); + + it("expands {configHome} honoring XDG_CONFIG_HOME", () => { + setEnv("XDG_CONFIG_HOME", "/tmp/fake-config-home"); + const { userPath, loader } = makeLoader(); + writeConfig(userPath, [ + { pattern: "{configHome}/my-pi/**", label: "pi-ssh 凭据库" }, + ]); + + expect(loader.getProtectedWrites()).toEqual([ + { pattern: "/tmp/fake-config-home/my-pi/**", label: "pi-ssh 凭据库" }, + ]); + }); + + it("falls back to ~/.config for {configHome} and expands leading ~/", () => { + setEnv("XDG_CONFIG_HOME", undefined); + const { userPath, loader } = makeLoader(); + writeConfig(userPath, [ + { pattern: "{configHome}/my-pi/**", label: "xdg" }, + { pattern: "~/secret.txt", label: "home" }, + ]); + + expect(loader.getProtectedWrites()).toEqual([ + { pattern: join(homedir(), ".config", "my-pi/**"), label: "xdg" }, + { pattern: join(homedir(), "secret.txt"), label: "home" }, + ]); + }); + + it("returns empty rules when no protectedWrites key exists", () => { + const { userPath, loader } = makeLoader(); + writeFileSync( + userPath, + `{ "version": 1, "permission": { "*": "allow" } }`, + ); + + expect(loader.getProtectedWrites()).toEqual([]); + expect(loader.getConfigIssues()).toEqual([]); + }); + + it("returns empty rules when the file is missing (requests deny anyway)", () => { + const { loader } = makeLoader(); + + expect(loader.getProtectedWrites()).toEqual([]); + expect(loader.getConfigIssues().join("\n")).toContain("denying all"); + }); + + it("treats a malformed protectedWrites as an invalid file", () => { + const { userPath, loader } = makeLoader(); + writeConfig(userPath, { pattern: "/x", label: "missing array" }); + + expect(loader.loadGlobalConfig().permission).toEqual({ "*": "deny" }); + expect(loader.getConfigIssues().join("\n")).toContain( + "protectedWrites", + ); + }); + + it("keeps the last good protectedWrites when the file turns invalid", () => { + const { userPath, loader } = makeLoader(); + writeConfig(userPath, [ + { pattern: "/vault/auth.json", label: "Pi 凭据" }, + ]); + loader.loadGlobalConfig(); + + writeFileSync(userPath, "{ definitely not json at all"); + loader.loadGlobalConfig(); + + expect(loader.getProtectedWrites()).toEqual([ + { pattern: "/vault/auth.json", label: "Pi 凭据" }, + ]); + expect(loader.getConfigIssues().join("\n")).toContain("Failed to load"); + }); + }); }); diff --git a/pi-permission-system/test/helpers/manager-harness.ts b/pi-permission-system/test/helpers/manager-harness.ts index 2049721..623b513 100644 --- a/pi-permission-system/test/helpers/manager-harness.ts +++ b/pi-permission-system/test/helpers/manager-harness.ts @@ -8,7 +8,6 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { getGlobalConfigPath } from "#src/config-paths"; import { PermissionManager, type PolicyLoader } from "#src/permission-manager"; import type { ResolvedPolicyPaths } from "#src/policy-loader"; import type { Rule } from "#src/rule"; @@ -231,7 +230,7 @@ export function createAgentDirHarness(opts: { const agentDir = join(baseDir, "agent"); const cwd = join(baseDir, "project"); - const globalConfigPath = getGlobalConfigPath(agentDir); + const globalConfigPath = join(agentDir, "pi-permission-policy.jsonc"); mkdirSync(dirname(globalConfigPath), { recursive: true, }); diff --git a/pi-permission-system/test/permission-events.test.ts b/pi-permission-system/test/permission-events.test.ts index 00ca626..c22a962 100644 --- a/pi-permission-system/test/permission-events.test.ts +++ b/pi-permission-system/test/permission-events.test.ts @@ -1,9 +1,8 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { getGlobalConfigPath } from "#src/config-paths"; import piPermissionSystemExtension from "#src/index"; import type { PermissionDecisionEvent, @@ -213,18 +212,26 @@ describe("emitDecisionEvent", () => { describe("piPermissionSystemExtension ready event wiring", () => { let baseDir: string; let originalAgentDir: string | undefined; + // Policy and hard-guard files are injected through the factory options so + // the bundle `config/` files are never touched. + let policyPath: string; + let hardGuardsPath: string; beforeEach(() => { baseDir = mkdtempSync(join(tmpdir(), "pi-perm-events-test-")); originalAgentDir = process.env.PI_CODING_AGENT_DIR; - const globalConfigPath = getGlobalConfigPath(baseDir); - mkdirSync(dirname(globalConfigPath), { recursive: true }); + policyPath = join(baseDir, "pi-permission-policy.jsonc"); + hardGuardsPath = join(baseDir, "pi-permission-hard-guards.jsonc"); mkdirSync(join(baseDir, "agents"), { recursive: true }); writeFileSync( - globalConfigPath, + policyPath, `${JSON.stringify({ permission: { "*": "ask" } })}\n`, "utf8", ); + writeFileSync( + hardGuardsPath, + JSON.stringify({ version: 1, permission: { "*": "allow" } }), + ); process.env.PI_CODING_AGENT_DIR = baseDir; }); @@ -255,7 +262,10 @@ describe("piPermissionSystemExtension ready event wiring", () => { setActiveTools: vi.fn(), registerProvider: vi.fn(), events: { emit: emitSpy, on: vi.fn().mockReturnValue(() => undefined) }, - } as never); + } as never, { + configPath: policyPath, + hardGuardConfigPath: hardGuardsPath, + }); // ready is not emitted at load — only after session_start publishes. expect( diff --git a/pi-permission-system/test/permission-manager-unified.test.ts b/pi-permission-system/test/permission-manager-unified.test.ts index 735b685..5302a94 100644 --- a/pi-permission-system/test/permission-manager-unified.test.ts +++ b/pi-permission-system/test/permission-manager-unified.test.ts @@ -1112,12 +1112,16 @@ describe("cross-cutting path surface — home-expanded values", () => { // --------------------------------------------------------------------------- test("project policy files are ignored", () => { - const { agentDir, cwd, cleanup } = createAgentDirHarness({ + const { agentDir, cwd, globalConfigPath, cleanup } = createAgentDirHarness({ globalPermission: { "*": "ask", bash: "deny" }, projectPermission: { bash: "allow" }, }); try { - const manager = new PermissionManager({ agentDir }); + const manager = new PermissionManager({ + globalConfigPath, + agentsDir: join(agentDir, "agents"), + projectAgentsDir: join(cwd, ".pi", "agents"), + }); manager.configureForCwd(cwd); expect(checkTool(manager, "bash", { command: "echo hi" }).state).toBe( "deny", @@ -1898,27 +1902,17 @@ permission: // PI_CODING_AGENT_DIR support — moved from catch-all (#342) // --------------------------------------------------------------------------- -test("PermissionManager reads config from PI_CODING_AGENT_DIR when set", () => { +test("PermissionManager reads the bundle-level policy regardless of PI_CODING_AGENT_DIR", () => { + // The bundle config/ directory is the single source of truth; the agent dir + // env var no longer influences the ordinary-policy location. const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-envdir-")); - const agentsDir = join(baseDir, "agents"); - const newConfigPath = getGlobalConfigPath(baseDir); - mkdirSync(agentsDir, { recursive: true }); - mkdirSync(dirname(newConfigPath), { recursive: true }); - - const config: ScopeConfig = { - permission: { "*": "deny", read: "allow" }, - }; - writeFileSync(newConfigPath, JSON.stringify(config), "utf8"); - const original = process.env.PI_CODING_AGENT_DIR; process.env.PI_CODING_AGENT_DIR = baseDir; try { const manager = new PermissionManager(); - const result = checkTool(manager, "read", {}); - expect(result.state).toBe("allow"); - - const result2 = checkTool(manager, "write", {}); - expect(result2.state).toBe("deny"); + const resolved = manager.getResolvedPolicyPaths(); + expect(resolved.globalConfigPath).toBe(getGlobalConfigPath()); + expect(resolved.globalConfigPath).not.toContain(baseDir); } finally { if (original !== undefined) { process.env.PI_CODING_AGENT_DIR = original; diff --git a/pi-permission-system/test/seed-config.test.ts b/pi-permission-system/test/seed-config.test.ts index 43b301a..9d586cc 100644 --- a/pi-permission-system/test/seed-config.test.ts +++ b/pi-permission-system/test/seed-config.test.ts @@ -69,9 +69,9 @@ describe("bundled policy seed", () => { describe("backward compatibility with a deployed config", () => { it("still accepts the retired annotators key and ignores it", () => { - // The bundle never overwrites a deployed policy.jsonc, so a machine that - // ran the annotator era keeps `annotators` in its file. Rejecting it - // would fail that machine closed instead of ignoring the key. + // A config file saved by an older bundle revision may still carry the + // retired `annotators` key. Rejecting it would fail that file closed + // instead of ignoring the key. const result = unifiedConfigSchema.safeParse({ annotators: ["model-explanation"], }); diff --git a/pi-permission-system/test/session-start.test.ts b/pi-permission-system/test/session-start.test.ts index b7ded46..a54c698 100644 --- a/pi-permission-system/test/session-start.test.ts +++ b/pi-permission-system/test/session-start.test.ts @@ -1,8 +1,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { getGlobalConfigPath } from "#src/config-paths"; import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config"; import piPermissionSystemExtension from "#src/index"; import type { ScopeConfig } from "#src/types"; @@ -18,22 +17,30 @@ type MockHandler = ( describe("session_start handler consolidation", () => { let baseDir: string; let originalAgentDir: string | undefined; + // Policy and hard-guard files are injected through the factory options so + // the bundle `config/` files are never touched. + let policyPath: string; + let hardGuardsPath: string; beforeEach(() => { baseDir = mkdtempSync(join(tmpdir(), "pi-permission-session-start-")); originalAgentDir = process.env.PI_CODING_AGENT_DIR; - const globalConfigPath = getGlobalConfigPath(baseDir); + policyPath = join(baseDir, "pi-permission-policy.jsonc"); + hardGuardsPath = join(baseDir, "pi-permission-hard-guards.jsonc"); mkdirSync(join(baseDir, "agents"), { recursive: true }); - mkdirSync(dirname(globalConfigPath), { recursive: true }); const config: ScopeConfig = { permission: { "*": "ask" }, }; writeFileSync( - globalConfigPath, + policyPath, `${JSON.stringify({ ...DEFAULT_EXTENSION_CONFIG, ...config }, null, 2)}\n`, "utf8", ); + writeFileSync( + hardGuardsPath, + JSON.stringify({ version: 1, permission: { "*": "allow" } }), + ); process.env.PI_CODING_AGENT_DIR = baseDir; }); @@ -63,7 +70,10 @@ describe("session_start handler consolidation", () => { emit: (): void => {}, on: (): (() => void) => () => undefined, }, - } as never); + } as never, { + configPath: policyPath, + hardGuardConfigPath: hardGuardsPath, + }); const sessionStartHandlers = registrations.filter( (r) => r.name === "session_start", @@ -87,7 +97,10 @@ describe("session_start handler consolidation", () => { emit: (): void => {}, on: (): (() => void) => () => undefined, }, - } as never); + } as never, { + configPath: policyPath, + hardGuardConfigPath: hardGuardsPath, + }); const sessionStartHandlers = registrations.filter( (r) => r.name === "session_start", diff --git a/pi-rtk-optimizer/README.md b/pi-rtk-optimizer/README.md index e087a41..d2a7ef1 100644 --- a/pi-rtk-optimizer/README.md +++ b/pi-rtk-optimizer/README.md @@ -106,16 +106,9 @@ Use ←/→ to switch tabs, ↑/↓ to navigate settings in the active tab, type ## Configuration -Configuration is stored at: +Configuration is read from — and `/rtk` saves to — the installed bundle's `config/rtk-optimizer.json` (the bundle repository's `config/` directory is the single source of truth; `pi update` refreshes the installed copy). Nothing is deployed to or migrated from the agent directory. -```text -Default global path: ~/.pi/agent/my-pi/rtk-optimizer/config.json -Actual global path: $PI_CODING_AGENT_DIR/my-pi/rtk-optimizer/config.json when PI_CODING_AGENT_DIR is set -``` - -A starter template is included at `config/config.example.json`. - -For audit or debugging sessions, keep `showRewriteNotifications` enabled and disable lossy `read` compaction/source filtering before gathering evidence. Existing `config.json` files are user-owned runtime state; do not overwrite local choices unless you intentionally want to change live extension behavior. +For audit or debugging sessions, keep `showRewriteNotifications` enabled and disable lossy `read` compaction/source filtering before gathering evidence. Edit `config/rtk-optimizer.json` in the bundle repository for durable changes; `pi update` overwrites the installed copy on upgrade. ### Configuration Options diff --git a/pi-rtk-optimizer/src/config-store.ts b/pi-rtk-optimizer/src/config-store.ts index 325ee33..6bc3243 100644 --- a/pi-rtk-optimizer/src/config-store.ts +++ b/pi-rtk-optimizer/src/config-store.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; -import { CONFIG_PATH } from "./constants.js"; +import { resolveConfigPath } from "./constants.js"; import { toRecord } from "./record-utils.js"; import { DEFAULT_RTK_INTEGRATION_CONFIG, @@ -151,7 +151,7 @@ export function normalizeRtkIntegrationConfig(raw: unknown): RtkIntegrationConfi }; } -export function ensureConfigExists(configPath = CONFIG_PATH): EnsureConfigResult { +export function ensureConfigExists(configPath = resolveConfigPath()): EnsureConfigResult { if (existsSync(configPath)) { return { created: false }; } @@ -169,7 +169,7 @@ export function ensureConfigExists(configPath = CONFIG_PATH): EnsureConfigResult } } -export function loadRtkIntegrationConfig(configPath = CONFIG_PATH): ConfigLoadResult { +export function loadRtkIntegrationConfig(configPath = resolveConfigPath()): ConfigLoadResult { if (!existsSync(configPath)) { return { config: structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG) }; } @@ -189,7 +189,7 @@ export function loadRtkIntegrationConfig(configPath = CONFIG_PATH): ConfigLoadRe export function saveRtkIntegrationConfig( config: RtkIntegrationConfig, - configPath = CONFIG_PATH, + configPath = resolveConfigPath(), ): ConfigSaveResult { const normalized = normalizeRtkIntegrationConfig(config); const tmpPath = `${configPath}.tmp`; @@ -218,6 +218,6 @@ export function saveRtkIntegrationConfig( } } -export function getRtkIntegrationConfigPath(configPath = CONFIG_PATH): string { +export function getRtkIntegrationConfigPath(configPath = resolveConfigPath()): string { return configPath; } diff --git a/pi-rtk-optimizer/src/constants.ts b/pi-rtk-optimizer/src/constants.ts index 471667f..b10d8fe 100644 --- a/pi-rtk-optimizer/src/constants.ts +++ b/pi-rtk-optimizer/src/constants.ts @@ -1,6 +1,19 @@ -import { getAgentDir } from "@earendil-works/pi-coding-agent"; -import { join } from "node:path"; +import { fileURLToPath } from "node:url"; export const EXTENSION_NAME = "pi-rtk-optimizer"; -export const CONFIG_DIR = join(getAgentDir(), "my-pi", "rtk-optimizer"); -export const CONFIG_PATH = join(CONFIG_DIR, "config.json"); + +// Bundle-level `config/` is the single source of truth: the extension reads +// (and /rtk save writes) `config/rtk-optimizer.json` inside the installed +// bundle, resolved relative to this source file. Nothing is deployed to or +// migrated from the agent directory. +export const CONFIG_PATH = fileURLToPath( + new URL("../../config/rtk-optimizer.json", import.meta.url), +); + +/** + * Resolve the effective config path. `PI_RTK_CONFIG_PATH` relocates the file + * (tests point it at a temp file; it is never a persisted config value). + */ +export function resolveConfigPath(): string { + return process.env.PI_RTK_CONFIG_PATH ?? CONFIG_PATH; +} diff --git a/pi-rtk-optimizer/src/index.test.ts b/pi-rtk-optimizer/src/index.test.ts index 5e5485c..e704d89 100644 --- a/pi-rtk-optimizer/src/index.test.ts +++ b/pi-rtk-optimizer/src/index.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { dirname } from "node:path"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { mock, runTest } from "./test-helpers.test.ts"; @@ -37,12 +38,18 @@ const indexModule = await import("./index.ts"); const { createBoundedNoticeTracker, shouldInjectSourceFilterTroubleshootingNote, injectGuidelineIntoPrompt } = indexModule; const rtkIntegrationExtension = indexModule.default; const { DEFAULT_RTK_INTEGRATION_CONFIG } = await import("./types.ts"); -const { CONFIG_PATH } = await import("./constants.ts"); + +// Tests must never write the bundle's config/ file: relocate the config to a +// throwaway temp file before any config-store call resolves its path. +const TEST_CONFIG_PATH = join( + mkdtempSync(join(tmpdir(), "my-pi-rtk-test-")), + "rtk-optimizer.json", +); +process.env.PI_RTK_CONFIG_PATH = TEST_CONFIG_PATH; function writeTestConfig(commandRewritingEnabled: boolean): void { - mkdirSync(dirname(CONFIG_PATH), { recursive: true }); writeFileSync( - CONFIG_PATH, + TEST_CONFIG_PATH, `${JSON.stringify({ ...DEFAULT_RTK_INTEGRATION_CONFIG, commandRewritingEnabled }, null, 2)}\n`, "utf-8", ); diff --git a/pi-ssh/README.md b/pi-ssh/README.md index 67c0dfe..e49fd91 100644 --- a/pi-ssh/README.md +++ b/pi-ssh/README.md @@ -28,8 +28,8 @@ ProxyJump and ProxyCommand are intentionally rejected in the first ssh2 release. From the installed bundle or this repository: ```sh -./ssh_config.sh import -./ssh_config.sh import packaging-server +./configure.sh ssh import +./configure.sh ssh import packaging-server ``` The helper: @@ -46,10 +46,10 @@ The helper: Other commands: ```sh -./ssh_config.sh list -./ssh_config.sh update packaging-server -./ssh_config.sh remove packaging-server -./ssh_config.sh rotate-key +./configure.sh ssh list +./configure.sh ssh update packaging-server +./configure.sh ssh remove packaging-server +./configure.sh ssh rotate-key ``` Secret prompts require an interactive terminal. Passwords and passphrases are never passed as command-line arguments. diff --git a/pi-ssh/extension-spec.md b/pi-ssh/extension-spec.md index ef9375a..1ddc023 100644 --- a/pi-ssh/extension-spec.md +++ b/pi-ssh/extension-spec.md @@ -20,7 +20,7 @@ The extension is for operating servers from local projects, not for replacing Pi Runtime communication uses Node `ssh2` only. OpenSSH is not spawned during Pi sessions. A user must explicitly import a concrete OpenSSH alias before connecting: ```text -ssh_config.sh import +configure.sh ssh import → ssh -G → user chooses private-key or password auth → ssh2 obtains host key, user confirms fingerprint diff --git a/pi-ssh/scripts/ssh-config.mjs b/pi-ssh/scripts/ssh-config.mjs index ab222f1..642a98c 100755 --- a/pi-ssh/scripts/ssh-config.mjs +++ b/pi-ssh/scripts/ssh-config.mjs @@ -184,7 +184,7 @@ async function main() { } if (command === "remove") { const id = arguments_[0]; - if (!id) throw new Error("usage: ssh_config.sh remove "); + if (!id) throw new Error("usage: configure.sh ssh remove "); const config = loadVaultOrEmpty(); if (!config.hosts[id]) throw new Error(`unknown pi-ssh host: ${id}`); delete config.hosts[id]; @@ -194,11 +194,11 @@ async function main() { return; } if (command !== "import" && command !== "update") { - throw new Error("usage: ssh_config.sh [list|import [alias...]|update |remove |rotate-key]"); + throw new Error("usage: configure.sh ssh [list|import [alias...]|update |remove |rotate-key]"); } const config = loadVaultOrEmpty(); const aliases = command === "update" - ? [arguments_[0] || (() => { throw new Error("usage: ssh_config.sh update "); })()] + ? [arguments_[0] || (() => { throw new Error("usage: configure.sh ssh update "); })()] : await chooseAliases(arguments_); for (const alias of aliases) await importHost(config, alias); saveVault(config); diff --git a/pi-ssh/src/agent-connection.ts b/pi-ssh/src/agent-connection.ts index 92506af..3cc7a86 100644 --- a/pi-ssh/src/agent-connection.ts +++ b/pi-ssh/src/agent-connection.ts @@ -40,7 +40,7 @@ export function getConfiguredHost(config: PiSshConfig, hostId: string): SshHostC if (host) return host; const hostIds = Object.keys(config.hosts).sort(); if (hostIds.length === 0) { - throw new Error(`unknown pi-ssh host '${hostId}'; no hosts are imported; run ssh_config.sh import `); + throw new Error(`unknown pi-ssh host '${hostId}'; no hosts are imported; run configure.sh ssh import `); } const shown = hostIds.slice(0, 10); const remaining = hostIds.length - shown.length; diff --git a/pi-ssh/src/vault.ts b/pi-ssh/src/vault.ts index 8bc19aa..0328955 100644 --- a/pi-ssh/src/vault.ts +++ b/pi-ssh/src/vault.ts @@ -52,7 +52,7 @@ function parseKey(text: string): Buffer { } function readKey(paths: VaultPaths): Buffer { - if (!existsSync(paths.key)) throw new Error("pi-ssh vault key is missing; run ssh_config.sh import"); + if (!existsSync(paths.key)) throw new Error("pi-ssh vault key is missing; run configure.sh ssh import"); assertOwnerOnly(paths.key); return parseKey(readFileSync(paths.key, "utf8")); } @@ -123,7 +123,7 @@ export function decryptConfig(encrypted: string, key: Buffer): PiSshConfig { export function loadVault(paths: VaultPaths = resolveVaultPaths()): PiSshConfig { if (!existsSync(paths.encryptedConfig)) { - throw new Error("pi-ssh is not configured; run ssh_config.sh import"); + throw new Error("pi-ssh is not configured; run configure.sh ssh import"); } assertOwnerOnly(paths.encryptedConfig); const key = readKey(paths); diff --git a/pi-ssh/test/config-cli.test.ts b/pi-ssh/test/config-cli.test.ts index 9edda94..ea894b0 100644 --- a/pi-ssh/test/config-cli.test.ts +++ b/pi-ssh/test/config-cli.test.ts @@ -25,7 +25,7 @@ test("the configuration CLI lists hosts without revealing credentials", () => { const home = mkdtempSync(join(tmpdir(), "pi-ssh-cli-")); const configHome = join(home, "config"); saveVault(fixture, resolveVaultPaths({ XDG_CONFIG_HOME: configHome }, process.platform, home)); - const result = spawnSync(resolve("../ssh_config.sh"), ["list"], { + const result = spawnSync(resolve("../configure.sh"), ["ssh", "list"], { encoding: "utf8", env: { ...process.env, HOME: home, XDG_CONFIG_HOME: configHome }, }); diff --git a/pi-tool-search/README.md b/pi-tool-search/README.md index 5b462c5..73c9581 100644 --- a/pi-tool-search/README.md +++ b/pi-tool-search/README.md @@ -71,7 +71,7 @@ The first loads are purely additive and retain Pi's native deferred-loading prop ## Configuration -Pi reads this configuration from `/my-pi/tool-search/config.json` (normally `~/.pi/agent/my-pi/tool-search/config.json`), deployed by the bundle's `install.sh` / `update.sh` from `config/tool-search.json`. A missing, unreadable, or invalid file falls back to the built-in `BUNDLE_TOOL_SEARCH_DEFAULTS`; the extension never writes the file: +Pi reads this configuration directly from the installed bundle's `config/tool-search.json` — the single source of truth, refreshed by `pi update`. A missing, unreadable, or invalid file falls back to the built-in `BUNDLE_TOOL_SEARCH_DEFAULTS`; the extension never writes the file: ```json { @@ -117,7 +117,7 @@ Example override: } ``` -An explicit override larger than `maxDynamicTools` remains visible but cannot be activated until the limit or override is corrected. Every `install.sh` / `update.sh` run rebuilds the file together with the rest of the `my-pi/` tree, so hand edits are overwritten — change `config/tool-search.json` in the bundle repository for durable customization. +An explicit override larger than `maxDynamicTools` remains visible but cannot be activated until the limit or override is corrected. To customize, edit `config/tool-search.json` in the bundle repository and update the bundle — `pi update` refreshes the installed copy. ## Current host integration diff --git a/pi-tool-search/extensions/config.ts b/pi-tool-search/extensions/config.ts index cf47c7c..7c9ff40 100644 --- a/pi-tool-search/extensions/config.ts +++ b/pi-tool-search/extensions/config.ts @@ -1,18 +1,22 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; const TOOL_SEARCH_DIR = join("my-pi", "tool-search"); /** - * Path of the bundle-managed Tool Search config inside the agent directory. - * `scripts/rebuild-bundle-config.mjs` deploys `config/tool-search.json` here; - * this extension never writes or migrates it. + * Bundle-level `config/` is the single source of truth: the extension reads + * `config/tool-search.json` from inside the installed bundle (resolved + * relative to this source file), never deployed to or migrated from the + * agent directory. */ -export function getToolSearchConfigPath(agentDir: string): string { - return join(agentDir, TOOL_SEARCH_DIR, "config.json"); +export function getToolSearchConfigPath(): string { + const override = process.env.PI_TOOL_SEARCH_CONFIG_PATH; + if (override) return override; + return fileURLToPath(new URL("../../config/tool-search.json", import.meta.url)); } -/** Catalog cache path under the same bundle-owned `my-pi/tool-search/` tree. */ +/** Disposable catalog cache under the agent dir's `my-pi/tool-search/` tree. */ export function getToolSearchCatalogPath(agentDir: string): string { return join(agentDir, TOOL_SEARCH_DIR, "catalog-v1.json"); } @@ -83,13 +87,16 @@ export function bundleFallbackConfig(): ToolSearchConfig { } /** - * Read the Tool Search config from `/my-pi/tool-search/config.json`. - * A missing, unreadable, or non-object file falls back to the bundle defaults. + * Read the Tool Search config. Defaults to the bundle-level + * `config/tool-search.json`; tests inject an explicit temp path. A missing, + * unreadable, or non-object file falls back to the bundle defaults. */ -export function readToolSearchConfig(agentDir: string): ToolSearchConfig { +export function readToolSearchConfig( + configPath: string = getToolSearchConfigPath(), +): ToolSearchConfig { const fallback = bundleFallbackConfig(); try { - const parsed: unknown = JSON.parse(readFileSync(getToolSearchConfigPath(agentDir), "utf8")); + const parsed: unknown = JSON.parse(readFileSync(configPath, "utf8")); if (!isObject(parsed)) return fallback; const config = parsed; return { diff --git a/pi-tool-search/extensions/index.ts b/pi-tool-search/extensions/index.ts index 445926e..71e9073 100644 --- a/pi-tool-search/extensions/index.ts +++ b/pi-tool-search/extensions/index.ts @@ -114,7 +114,7 @@ export default function toolSearchExtension(pi: ExtensionAPI): void { const agentDir = getAgentDir(); const cachePath = getToolSearchCatalogPath(agentDir); - let config = readToolSearchConfig(agentDir); + let config = readToolSearchConfig(); let tools: ToolInfo[] = []; let catalog: ToolCatalog = createFallbackCatalog([], "", { maxToolsPerGroup: Math.min(config.maxToolsPerGroup, config.maxDynamicTools), @@ -440,7 +440,7 @@ export default function toolSearchExtension(pi: ExtensionAPI): void { } function refreshState(ctx: Pick, forceReset: boolean): void { - const nextConfig = readToolSearchConfig(agentDir); + const nextConfig = readToolSearchConfig(); const registeredTools = pi.getAllTools().filter((tool) => tool.name !== TOOL_SEARCH_NAME); const platformPolicy = platformToolPolicy(registeredTools, process.platform); const allTools = platformPolicy.tools; diff --git a/pi-tool-search/test/config.test.ts b/pi-tool-search/test/config.test.ts index 22e6f72..4fb7e08 100644 --- a/pi-tool-search/test/config.test.ts +++ b/pi-tool-search/test/config.test.ts @@ -21,10 +21,12 @@ async function withAgentDir(run: (agentDir: string) => Promise): Promise { - const path = getToolSearchConfigPath(agentDir); +/** Write a config file inside the temp dir; never touches the repo config/. */ +async function writeConfig(agentDir: string, content: string): Promise { + const path = join(agentDir, "config.json"); await mkdir(dirname(path), { recursive: true }); await writeFile(path, content, "utf8"); + return path; } test("bundle defaults keep the committed tool list", () => { @@ -52,12 +54,9 @@ test("bundle defaults keep the committed tool list", () => { }); }); -test("config and catalog live under the bundle-owned my-pi tree", async () => { +test("config resolves to the bundle config dir; catalog stays under my-pi", async () => { await withAgentDir(async (agentDir) => { - assert.equal( - getToolSearchConfigPath(agentDir), - join(agentDir, "my-pi", "tool-search", "config.json"), - ); + assert.match(getToolSearchConfigPath(), /config[/\\]tool-search\.json$/); assert.equal( getToolSearchCatalogPath(agentDir), join(agentDir, "my-pi", "tool-search", "catalog-v1.json"), @@ -67,13 +66,16 @@ test("config and catalog live under the bundle-owned my-pi tree", async () => { test("falls back to bundle defaults when the config file is absent or invalid", async () => { await withAgentDir(async (agentDir) => { - assert.deepEqual(readToolSearchConfig(agentDir), bundleFallbackConfig()); + assert.deepEqual( + readToolSearchConfig(join(agentDir, "missing.json")), + bundleFallbackConfig(), + ); - await writeConfig(agentDir, "{not-json"); - assert.deepEqual(readToolSearchConfig(agentDir), bundleFallbackConfig()); + const badJson = await writeConfig(agentDir, "{not-json"); + assert.deepEqual(readToolSearchConfig(badJson), bundleFallbackConfig()); - await writeConfig(agentDir, '"a string, not an object"'); - assert.deepEqual(readToolSearchConfig(agentDir), bundleFallbackConfig()); + const nonObject = await writeConfig(agentDir, '"a string, not an object"'); + assert.deepEqual(readToolSearchConfig(nonObject), bundleFallbackConfig()); }); }); @@ -87,8 +89,8 @@ test("normalizes invalid runtime values without overwriting the file", async () maxDynamicTools: -1, groupOverrides: { web: ["search", 2], empty: [] }, }); - await writeConfig(agentDir, original); - assert.deepEqual(readToolSearchConfig(agentDir), { + const path = await writeConfig(agentDir, original); + assert.deepEqual(readToolSearchConfig(path), { alwaysEnabled: ["one"], showToolSearchFooterStatus: false, maxActiveGroups: 5, @@ -96,7 +98,7 @@ test("normalizes invalid runtime values without overwriting the file", async () maxDynamicTools: 28, groupOverrides: { web: ["search"] }, }); - assert.equal(await readFile(getToolSearchConfigPath(agentDir), "utf8"), original); + assert.equal(await readFile(path, "utf8"), original); }); }); @@ -110,8 +112,8 @@ test("reads an explicit configuration verbatim", async () => { maxDynamicTools: 20, groupOverrides: { custom: ["some_tool"] }, }, null, 2)}\n`; - await writeConfig(agentDir, original); - assert.deepEqual(readToolSearchConfig(agentDir), { + const path = await writeConfig(agentDir, original); + assert.deepEqual(readToolSearchConfig(path), { alwaysEnabled: ["codegraph_explore", "lsp_diagnostics"], showToolSearchFooterStatus: true, maxActiveGroups: 3, @@ -119,6 +121,6 @@ test("reads an explicit configuration verbatim", async () => { maxDynamicTools: 20, groupOverrides: { custom: ["some_tool"] }, }); - assert.equal(await readFile(getToolSearchConfigPath(agentDir), "utf8"), original); + assert.equal(await readFile(path, "utf8"), original); }); }); diff --git a/pi-tool-search/test/extension.test.ts b/pi-tool-search/test/extension.test.ts index 9068fd2..b9aeac0 100644 --- a/pi-tool-search/test/extension.test.ts +++ b/pi-tool-search/test/extension.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -49,13 +49,13 @@ function testContext(overrides: Partial = {}): ExtensionContex test("loads whole groups and evicts the least-recently-used group", async () => { const agentDir = await mkdtemp(join(tmpdir(), "my-pi-tool-search-extension-")); const previousAgentDir = process.env.PI_CODING_AGENT_DIR; + const previousConfigPath = process.env.PI_TOOL_SEARCH_CONFIG_PATH; process.env.PI_CODING_AGENT_DIR = agentDir; + process.env.PI_TOOL_SEARCH_CONFIG_PATH = join(agentDir, "tool-search-config.json"); try { - const configDir = join(agentDir, "my-pi", "tool-search"); - await mkdir(configDir, { recursive: true }); await writeFile( - join(configDir, "config.json"), + process.env.PI_TOOL_SEARCH_CONFIG_PATH, JSON.stringify({ alwaysEnabled: ["codegraph_explore", "lsp_diagnostics"], showToolSearchFooterStatus: false, @@ -159,6 +159,8 @@ test("loads whole groups and evicts the least-recently-used group", async () => } finally { if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = previousAgentDir; + if (previousConfigPath === undefined) delete process.env.PI_TOOL_SEARCH_CONFIG_PATH; + else process.env.PI_TOOL_SEARCH_CONFIG_PATH = previousConfigPath; await rm(agentDir, { recursive: true, force: true }); } }); diff --git a/pi-web-search/README.md b/pi-web-search/README.md index 6cf48a9..c0ca71e 100644 --- a/pi-web-search/README.md +++ b/pi-web-search/README.md @@ -26,7 +26,7 @@ Plain `fetch` calls — no MCP adapter, no upstream extension wrappers: Keys live in Pi's `auth.json` as one `api_key` credential per provider (`tavily`, `exa`, `keenable`), read by `auth.ts` with upstream resolution semantics — literal values, `$VAR`/`${VAR}` interpolation, and `!command` -indirection (e.g. `"!op read op://Vault/Tavily/key"`). `search_config.sh` +indirection (e.g. `"!op read op://Vault/Tavily/key"`). `configure.sh search` writes them (atomically, mode 600, preserving all other entries); process environment variables are not read directly — use `"key": "$TAVILY_API_KEY"` to indirect through env. `KEENABLE_API_URL` stays env-only, or can be stored diff --git a/pi-worktree/README.md b/pi-worktree/README.md index 24f45f4..bd694ad 100644 --- a/pi-worktree/README.md +++ b/pi-worktree/README.md @@ -64,9 +64,9 @@ Pi 不支持修改进程 cwd。`/wt new` / `merge` / `switch` 通过 `SessionMan ## 状态与配置 -- 登记文件:`/pi-worktree/state.json`(`getAgentDir()`,遵循 `PI_CODING_AGENT_DIR`),目录权限 `700`、文件权限 `600`,原子写入;文件损坏或版本未知时命令报错中止,不会被静默覆盖。**不**放在 `/my-pi/` 下,因为该目录每次升级会被整体替换。 -- `settings.json` 的 `piWorktree` 键: - - `root`:worktree 根目录,默认 `~/.pi/worktrees`(只展开开头的 `~`,展开后必须是绝对路径)。worktree 路径为 `/<主 worktree 目录名>/`。 +- 登记文件:`/pi-worktree/state.json`(`getAgentDir()`,遵循 `PI_CODING_AGENT_DIR`),目录权限 `700`、文件权限 `600`,原子写入;文件损坏或版本未知时命令报错中止,不会被静默覆盖。**不**放在 `/my-pi/` 下,因为该目录只承载可随时丢弃的运行时状态。 +- 配置文件:已安装组合包内的 `config/pi-worktree.json`(仓库 `config/` 是唯一来源,`pi update` 随组合包刷新;不读取 `settings.json`)。键: + - `root`:worktree 根目录,默认 `~/.pi/worktrees`(只展开开头的 `~`,展开后必须是绝对路径)。该默认值依赖本机路径,因此组合包附带的 JSON 不写 `root`,由代码运行时计算。worktree 路径为 `/<主 worktree 目录名>/`。 - `branchPrefix`:新分支前缀,默认 `wt/`。 - slug 规则:`A-Za-z0-9._-` 与 `/` 分层,最长 64 字符,不能以 `-` 开头、不能含 `..`、不能以 `.lock` 结尾,保留名 `main` / `HEAD` 不可用。 diff --git a/pi-worktree/src/commands.ts b/pi-worktree/src/commands.ts index e15f40f..6fce880 100644 --- a/pi-worktree/src/commands.ts +++ b/pi-worktree/src/commands.ts @@ -971,7 +971,7 @@ export function registerWorktreeCommands(pi: ExtensionAPI): void { const git = createGit(); let settings: WorktreeSettings; try { - settings = readWorktreeSettings(() => pi.getSettings()); + settings = readWorktreeSettings(); } catch (error) { notifyError(ctx.ui, error); return; diff --git a/pi-worktree/src/settings.ts b/pi-worktree/src/settings.ts index 24f3fe8..825dc48 100644 --- a/pi-worktree/src/settings.ts +++ b/pi-worktree/src/settings.ts @@ -1,5 +1,7 @@ +import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { isAbsolute, join } from "node:path"; +import { fileURLToPath } from "node:url"; export interface WorktreeSettings { /** Absolute directory under which worktrees are created: //. */ @@ -10,20 +12,44 @@ export interface WorktreeSettings { const DEFAULT_ROOT = "~/.pi/worktrees"; const DEFAULT_BRANCH_PREFIX = "wt/"; +// Bundle-level `config/` is the single source of truth: the extension reads +// `config/pi-worktree.json` from inside the installed bundle (resolved +// relative to this source file), never `settings.json`. A `~`-prefixed `root` +// is expanded against the current home directory at runtime. +export const WORKTREE_CONFIG_PATH = fileURLToPath( + new URL("../../config/pi-worktree.json", import.meta.url), +); + function expandRoot(value: string): string { if (value === "~") return homedir(); if (value.startsWith("~/")) return join(homedir(), value.slice(2)); return value; } -export function readWorktreeSettings(getSettings: () => unknown): WorktreeSettings { - const raw = (getSettings() as { piWorktree?: { root?: unknown; branchPrefix?: unknown } } | undefined)?.piWorktree; - const rootRaw = typeof raw?.root === "string" && raw.root.trim() ? raw.root.trim() : DEFAULT_ROOT; +function readConfigFile(configPath: string): Record { + try { + const parsed = JSON.parse(readFileSync(configPath, "utf8")) as unknown; + return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +export function readWorktreeSettings( + configPath: string = WORKTREE_CONFIG_PATH, +): WorktreeSettings { + const raw = readConfigFile(configPath); + const rootRaw = + typeof raw.root === "string" && raw.root.trim() ? raw.root.trim() : DEFAULT_ROOT; const root = expandRoot(rootRaw); if (!isAbsolute(root)) { - throw new Error(`piWorktree.root 必须是绝对路径或以 ~ 开头:${rootRaw}`); + throw new Error(`pi-worktree config root 必须是绝对路径或以 ~ 开头:${rootRaw}`); } const branchPrefix = - typeof raw?.branchPrefix === "string" && raw.branchPrefix ? raw.branchPrefix : DEFAULT_BRANCH_PREFIX; + typeof raw.branchPrefix === "string" && raw.branchPrefix + ? raw.branchPrefix + : DEFAULT_BRANCH_PREFIX; return { root, branchPrefix }; } diff --git a/scripts/managed-config.sh b/scripts/lib.sh similarity index 69% rename from scripts/managed-config.sh rename to scripts/lib.sh index 6f2546b..9d755e8 100644 --- a/scripts/managed-config.sh +++ b/scripts/lib.sh @@ -1,7 +1,63 @@ #!/bin/sh +# Variables assigned here are consumed by the sourcing install.sh/update.sh. +# shellcheck disable=SC2034 -# Shared helpers for files that contain my-pi-owned blocks. -# Callers must provide say() and warn(). +# Shared helpers for the bundle's POSIX sh scripts. +# Source this file; it defines say(), warn(), the Kitty/Zsh resolution +# helpers, and the my-pi managed-block machinery used by install.sh and +# update.sh. + +say() { + printf '%s\n' "$*" +} + +warn() { + printf '警告: %s\n' "$*" >&2 +} + +detect_kitty() { + kitty_bin= + if command -v kitty >/dev/null 2>&1; then + kitty_bin=$(command -v kitty) + return 0 + fi + for candidate in /Applications/kitty.app/Contents/MacOS/kitty "$HOME/.local/kitty.app/bin/kitty"; do + if [ -x "$candidate" ]; then + kitty_bin=$candidate + return 0 + fi + done + return 1 +} + +resolve_kitty_config_dir() { + kitty_config_dir=${KITTY_CONFIG_DIRECTORY:-${XDG_CONFIG_HOME:-$HOME/.config}/kitty} +} + +resolve_zsh_paths() { + oh_my_zsh_dir=${ZSH:-$HOME/.oh-my-zsh} + zsh_custom_dir=${ZSH_CUSTOM:-$oh_my_zsh_dir/custom} + zshrc_file=${ZDOTDIR:-$HOME}/.zshrc + my_pi_config_dir=${XDG_CONFIG_HOME:-$HOME/.config}/my-pi + p10k_managed_config_file=$my_pi_config_dir/p10k.zsh +} + +# Anchor regex shared by every "before the Oh My Zsh loader" managed block. +MY_PI_ZSH_LOADER_ANCHOR='^(# >>> my-pi:oh-my-zsh-loader >>>|[[:space:]]*(source|\.)[[:space:]].*oh-my-zsh\.sh)' + +replace_zsh_managed_block() { + managed_id=$1 + managed_placement=$2 + managed_anchor=$3 + shift 3 + managed_content=$(mktemp "${TMPDIR:-/tmp}/my-pi-zsh-block.XXXXXX") || return 1 + printf '%s\n' "$@" > "$managed_content" + my_pi_replace_managed_block "$zshrc_file" "$managed_id" "$managed_content" \ + "$managed_placement" "$managed_anchor" + managed_status=$? + rm -f "$managed_content" + return "$managed_status" +} MY_PI_MANAGED_BACKUPS=${MY_PI_MANAGED_BACKUPS:-} diff --git a/scripts/rebuild-bundle-config.mjs b/scripts/rebuild-bundle-config.mjs deleted file mode 100644 index 9fa2ff0..0000000 --- a/scripts/rebuild-bundle-config.mjs +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env node - -/** - * Rebuilds the bundle-owned config directory inside the Pi agent directory. - * - * Every piece of bundle-owned, rebuildable state lives under - * `/my-pi/` (policy files, Tool Search config, the system prompt - * cover, permission logs). `config/bundle-manifest.json` is the authoritative - * source→target list: this helper builds the full tree in a sibling temp - * directory, deletes any existing `my-pi/` wholesale — no backup — and renames - * the fresh tree into place, so a rebuild can never half-apply. - * - * Nothing outside `my-pi/` is read or removed; leftovers at the old locations - * are ignored rather than migrated. - */ - -import { realpathSync } from "node:fs"; -import { chmod, copyFile, lstat, mkdir, readFile, rename, rm, unlink } from "node:fs/promises"; -import { dirname, isAbsolute, join, resolve, sep } from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptPath = fileURLToPath(import.meta.url); -const bundleRoot = dirname(dirname(scriptPath)); -export const BUNDLE_ROOT = bundleRoot; -export const MANIFEST_PATH = join(bundleRoot, "config", "bundle-manifest.json"); - -/** Thrown for an unusable agentDir argument; maps to CLI exit code 2. */ -export class RebuildUsageError extends Error {} - -function isObject(value) { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -/** - * Load and validate the manifest. - * - * Every source must be a readable repo-relative file and every target a - * relative path that stays inside the bundle directory — a `..` or absolute - * target would escape `my-pi/` during the rebuild, so it is rejected before - * anything is touched. - * - * @param {string} manifestPath - * @returns {Promise<{ directory: string, files: Array<{ source: string, target: string }> }>} - */ -export async function loadManifest(manifestPath) { - let manifest; - try { - manifest = JSON.parse(await readFile(manifestPath, "utf8")); - } catch (error) { - throw new Error( - `cannot read bundle manifest at ${manifestPath}: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - if (!isObject(manifest) || manifest.directory !== "my-pi" || !Array.isArray(manifest.files)) { - throw new Error(`bundle manifest at ${manifestPath} is malformed`); - } - - const files = manifest.files.map((entry, index) => { - if (!isObject(entry) || typeof entry.source !== "string" || typeof entry.target !== "string") { - throw new Error(`bundle manifest entry ${index} lacks source/target strings`); - } - const directoryBase = resolve(manifest.directory); - const resolved = resolve(directoryBase, entry.target); - if ( - isAbsolute(entry.target) || - resolved === directoryBase || - !resolved.startsWith(directoryBase + sep) - ) { - throw new Error( - `bundle manifest target ${entry.target} escapes the ${manifest.directory}/ directory`, - ); - } - const sourcePath = join(bundleRoot, entry.source); - return { source: entry.source, sourcePath, target: entry.target }; - }); - - // Fail before deleting anything: a missing source must not wipe the live tree. - for (const file of files) { - try { - await readFile(file.sourcePath); - } catch (error) { - throw new Error( - `cannot read manifest source ${file.source}: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - } - return { directory: manifest.directory, files }; -} - -/** - * Rebuild `/my-pi` from the bundle manifest. - * - * The tree is staged in `/.my-pi.rebuild--` (dirs 0700, - * files 0600), then the existing `my-pi` is removed — a symlink is unlinked, - * never followed — and the stage is renamed into place. Any failure removes - * the stage and throws. - * - * @param {string} agentDir - Absolute Pi agent directory. - * @param {{ manifestPath?: string }} [options] - * @returns {Promise<{ targetDir: string }>} - */ -export async function rebuildBundleConfig(agentDir, { manifestPath = MANIFEST_PATH } = {}) { - if (typeof agentDir !== "string" || agentDir.length === 0 || !isAbsolute(agentDir)) { - throw new RebuildUsageError("rebuild-bundle-config requires an absolute agentDir argument"); - } - const manifest = await loadManifest(manifestPath); - - const targetDir = join(agentDir, manifest.directory); - const stageDir = join( - agentDir, - `.${manifest.directory}.rebuild-${process.pid}-${Math.random().toString(16).slice(2)}`, - ); - - try { - await mkdir(agentDir, { recursive: true, mode: 0o700 }); - await mkdir(stageDir, { mode: 0o700 }); - for (const file of manifest.files) { - const destination = join(stageDir, file.target); - await mkdir(dirname(destination), { recursive: true, mode: 0o700 }); - // copyFile keeps the source mode on some platforms; pin owner-only. - await copyFile(file.sourcePath, destination); - await chmod(destination, 0o600); - } - - // Never follow a symlink: unlink the link itself, never its target. - try { - const stats = await lstat(targetDir); - if (stats.isSymbolicLink()) { - await unlink(targetDir); - } else { - await rm(targetDir, { recursive: true, force: true }); - } - } catch (error) { - if (error?.code !== "ENOENT") throw error; - } - - await rename(stageDir, targetDir); - return { targetDir }; - } catch (error) { - await rm(stageDir, { recursive: true, force: true }).catch(() => {}); - throw error; - } -} - -function isMainModule() { - if (!process.argv[1]) return false; - try { - return fileURLToPath(import.meta.url) === realpathSync(process.argv[1]); - } catch { - return false; - } -} - -async function main() { - const agentDir = process.argv[2] ?? ""; - if (agentDir.length === 0 || !isAbsolute(agentDir)) { - console.error("usage: rebuild-bundle-config.mjs "); - process.exitCode = 2; - return; - } - const { targetDir } = await rebuildBundleConfig(agentDir); - process.stdout.write(`rebuilt:${targetDir}\n`); -} - -if (isMainModule()) { - main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = error instanceof RebuildUsageError ? 2 : 1; - }); -} diff --git a/search_config.sh b/search_config.sh deleted file mode 100755 index ef9906d..0000000 --- a/search_config.sh +++ /dev/null @@ -1,119 +0,0 @@ -#!/bin/sh - -set -u - -say() { - printf '%s\n' "$*" -} - -warn() { - printf '错误: %s\n' "$*" >&2 -} - -usage() { - cat <<'EOF' -用法: - ./search_config.sh - ./search_config.sh --tavily KEY --exa KEY --keenable KEY - ./search_config.sh --migrate [search.env 路径] - ./search_config.sh --list - -选项: - --tavily KEY 配置 tavily api_key 凭据 - --exa KEY 配置 exa api_key 凭据 - --keenable KEY 配置 keenable api_key 凭据 - --migrate 从旧 search.env 导入缺失的 key - --list 显示各 provider 配置状态(不输出 key) - --auth FILE 覆盖 auth.json 路径 - -h, --help 显示帮助 - -不传 key 参数时进入隐藏输入的交互模式;传入任意 key 参数时不交互, -未指定的 provider 保留已有值。凭据写入 Pi 的 auth.json -($PI_CODING_AGENT_DIR/auth.json 或 ~/.pi/agent/auth.json,权限 600)。 -key 支持 auth.json 原生语义:字面量、$ENV_VAR / ${ENV_VAR} 插值、 -以及 !command(执行命令取 stdout,如 "!op read op://Vault/Tavily/key")。 -EOF -} - -SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd) -AUTH_MJS="$SCRIPT_DIR/scripts/search-auth.mjs" -[ -f "$AUTH_MJS" ] || { warn "找不到 ${AUTH_MJS}"; exit 1; } - -AUTH_FILE= -mode=interactive -did_work=0 - -run_auth() { - if [ -n "$AUTH_FILE" ]; then - node "$AUTH_MJS" --auth "$AUTH_FILE" "$@" - else - node "$AUTH_MJS" "$@" - fi -} - -has_key() { - run_auth --has "$1" >/dev/null 2>&1 -} - -prompt_key() { - prompt_label=$1 - prompt_configured=$2 - if [ ! -t 0 ]; then - warn "交互模式需要终端;请改用 --tavily/--exa/--keenable 参数。" - return 2 - fi - if [ "$prompt_configured" -eq 1 ]; then - printf '%s API key(回车保留现有值): ' "$prompt_label" >&2 - else - printf '%s API key(回车跳过): ' "$prompt_label" >&2 - fi - stty -echo - IFS= read -r prompt_value || prompt_status=$? - stty echo - printf '\n' >&2 - [ "${prompt_status:-0}" -eq 0 ] || return "$prompt_status" - printf '%s\n' "$prompt_value" -} - -while [ "$#" -gt 0 ]; do - case $1 in - --tavily|--exa|--keenable) - [ "$#" -ge 2 ] || { warn "$1 缺少 KEY"; exit 2; } - mode=noninteractive; did_work=1 - run_auth --set "${1#--}=$2" || exit $? - shift 2 ;; - --migrate) - mode=noninteractive; did_work=1 - if [ "$#" -ge 2 ] && [ "${2#--}" = "$2" ]; then - run_auth --migrate "$2" || exit $? - shift 2 - else - run_auth --migrate || exit $? - shift - fi - say "如确认 auth.json 可用,可手动删除旧 search.env 文件。" ;; - --list) mode=noninteractive; did_work=1; run_auth --list || exit $?; shift ;; - --auth) - [ "$#" -ge 2 ] || { warn "--auth 缺少 FILE"; exit 2; } - AUTH_FILE=$2; shift 2 ;; - -h|--help) usage; exit 0 ;; - *) warn "未知参数:$1"; usage >&2; exit 2 ;; - esac -done - -if [ "$mode" = interactive ]; then - for provider in tavily exa keenable; do - case $provider in - tavily) label=Tavily ;; - exa) label=Exa ;; - keenable) label=Keenable ;; - esac - if has_key "$provider"; then configured=1; else configured=0; fi - value=$(prompt_key "$label" "$configured") || exit $? - if [ -n "$value" ]; then - run_auth --set "$provider=$value" || exit $? - did_work=1 - fi - done - [ "$did_work" -eq 1 ] || say "未提供任何 key,auth.json 保持不变。" -fi diff --git a/ssh_config.sh b/ssh_config.sh deleted file mode 100755 index a3171af..0000000 --- a/ssh_config.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh -set -eu - -SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -exec node "$SCRIPT_DIR/pi-ssh/scripts/ssh-config-launcher.mjs" "$@" diff --git a/tests/bundle-config-rebuild.test.ts b/tests/bundle-config-rebuild.test.ts deleted file mode 100644 index b503301..0000000 --- a/tests/bundle-config-rebuild.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import assert from "node:assert/strict"; -import { lstat, mkdir, mkdtemp, readdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; -import { spawnSync } from "node:child_process"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; - -import { - getGlobalConfigPath, - getGlobalLogsDir, - getHardGuardConfigPath, -} from "../pi-permission-system/src/config-paths.ts"; -import { - BUNDLE_TOOL_SEARCH_DEFAULTS, - getToolSearchCatalogPath, - getToolSearchConfigPath, -} from "../pi-tool-search/extensions/config.ts"; -import { resolveCoverPath } from "../extensions/system-prompt-cover-config.ts"; -import { rebuildBundleConfig } from "../scripts/rebuild-bundle-config.mjs"; - -const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const rebuildScript = join(repositoryRoot, "scripts", "rebuild-bundle-config.mjs"); -const manifestPath = join(repositoryRoot, "config", "bundle-manifest.json"); - -interface ManifestFile { - source: string; - target: string; -} - -const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as { - version: number; - directory: string; - files: ManifestFile[]; -}; - -async function makeTempDir() { - return mkdtemp(join(tmpdir(), "my-pi-rebuild-")); -} - -function modeOf(path: string): Promise { - return stat(path).then((info) => info.mode & 0o777); -} - -async function readSnapshot(path: string): Promise { - return readFile(path, "utf8"); -} - -test("rebuild replaces the my-pi tree with byte-identical manifest targets and leaves outside files alone", async () => { - const dir = await makeTempDir(); - try { - // Pre-existing junk inside the managed directory must disappear entirely. - await mkdir(join(dir, "my-pi", "permission-system"), { recursive: true }); - await writeFile(join(dir, "my-pi", "permission-system", "policy.jsonc"), "STALE POLICY", "utf8"); - await writeFile(join(dir, "my-pi", "leftover.txt"), "JUNK", "utf8"); - await mkdir(join(dir, "my-pi", "stray", "deep"), { recursive: true }); - - // Files outside my-pi/ must survive byte-identical — the script owns only my-pi/. - const outside = [ - join(dir, "settings.json"), - join(dir, "sessions", "abc", "session.jsonl"), - join(dir, "extensions", "pi-permission-system", "policy.jsonc"), - ]; - for (const path of outside) { - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, `outside ${path}\n`, "utf8"); - } - const before = new Map(await Promise.all(outside.map(async (p) => [p, await readSnapshot(p)]))); - - await rebuildBundleConfig(dir); - - const myPi = join(dir, "my-pi"); - const expectedFiles = new Set(manifest.files.map((file) => file.target)); - const actualFiles: string[] = []; - const walk = async (root: string, rel: string) => { - for (const entry of await readdir(root, { withFileTypes: true })) { - const next = rel ? `${rel}/${entry.name}` : entry.name; - if (entry.isDirectory()) await walk(join(root, entry.name), next); - else actualFiles.push(next); - } - }; - await walk(myPi, ""); - assert.deepEqual(actualFiles.sort(), [...expectedFiles].sort(), "my-pi must contain exactly the manifest targets"); - - for (const file of manifest.files) { - const target = join(myPi, file.target); - assert.equal(await readFile(target, "utf8"), await readFile(join(repositoryRoot, file.source), "utf8")); - assert.equal(await modeOf(target), 0o600, `${file.target} must be owner-only`); - assert.equal(await modeOf(dirname(target)), 0o700, `${dirname(file.target)} must be owner-only`); - } - - for (const [path, content] of before) { - assert.equal(await readSnapshot(path), content, `outside file ${path} must be untouched`); - } - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("rejects an empty or relative agentDir without deleting anything", async () => { - const dir = await makeTempDir(); - try { - const sentinel = join(dir, "keep.txt"); - await writeFile(sentinel, "keep", "utf8"); - - await assert.rejects(rebuildBundleConfig(""), /absolute/); - await assert.rejects(rebuildBundleConfig("relative/dir"), /absolute/); - assert.equal(await readFile(sentinel, "utf8"), "keep"); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("CLI rejects a relative agentDir and reports the failure", async () => { - const dir = await makeTempDir(); - try { - await mkdir(join(dir, "my-pi"), { recursive: true }); - await writeFile(join(dir, "my-pi", "sentinel.txt"), "still here", "utf8"); - - const failure = spawnSync(process.execPath, [rebuildScript, "relative/dir"], { - encoding: "utf8", - }); - assert.equal(failure.status, 2); - assert.match(failure.stderr ?? "", /absolute/); - assert.equal(await readFile(join(dir, "my-pi", "sentinel.txt"), "utf8"), "still here"); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("CLI prints rebuilt: on success", async () => { - const dir = await makeTempDir(); - try { - const result = spawnSync(process.execPath, [rebuildScript, dir], { encoding: "utf8" }); - assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); - assert.equal(result.stdout.trim(), `rebuilt:${join(dir, "my-pi")}`); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("a manifest target escaping the directory is rejected without deleting my-pi", async () => { - const dir = await makeTempDir(); - try { - await mkdir(join(dir, "my-pi"), { recursive: true }); - await writeFile(join(dir, "my-pi", "sentinel.txt"), "still here", "utf8"); - - const badManifestPath = join(dir, "bad-manifest.json"); - await writeFile( - badManifestPath, - JSON.stringify({ - version: 1, - directory: "my-pi", - files: [{ source: "config/tool-search.json", target: "../escape.txt" }], - }), - "utf8", - ); - await assert.rejects( - rebuildBundleConfig(dir, { manifestPath: badManifestPath }), - /escapes/, - ); - assert.equal(await readFile(join(dir, "my-pi", "sentinel.txt"), "utf8"), "still here"); - await assert.rejects(readFile(join(dir, "escape.txt"), "utf8"), /ENOENT/); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("a symlinked my-pi is replaced, never followed", async () => { - const dir = await makeTempDir(); - const elsewhere = await makeTempDir(); - try { - const targetContent = join(elsewhere, "precious.txt"); - await writeFile(targetContent, "do not touch", "utf8"); - await symlink(elsewhere, join(dir, "my-pi"), "dir"); - - await rebuildBundleConfig(dir); - - // The link itself must be replaced by a real directory… - const rebuilt = await stat(join(dir, "my-pi")); - assert.ok(rebuilt.isDirectory()); - assert.ok(!(await lstat(join(dir, "my-pi"))).isSymbolicLink(), "my-pi must be a real directory now"); - - // …and the link target's contents must survive untouched. - assert.equal(await readFile(targetContent, "utf8"), "do not touch"); - } finally { - await rm(dir, { recursive: true, force: true }); - await rm(elsewhere, { recursive: true, force: true }); - } -}); - -test("manifest targets match the paths the extensions actually read", () => { - const targets = new Map(manifest.files.map((file) => [file.target, file.source])); - const agentDir = "/agent"; - const base = `${agentDir}/${manifest.directory}`; - - const expectTarget = (source: string, resolved: string, suffix: string) => { - assert.ok(resolved.startsWith(`${base}/`), `${resolved} must live under ${base}`); - assert.equal( - targets.get(resolved.slice(base.length + 1)), - source, - `${resolved} must be a manifest target backed by ${source}`, - ); - assert.ok(resolved.endsWith(suffix), `${resolved} must end with ${suffix}`); - }; - - expectTarget("config/pi-permission-policy.jsonc", getGlobalConfigPath(agentDir), "my-pi/permission-system/policy.jsonc"); - expectTarget("config/pi-permission-hard-guards.jsonc", getHardGuardConfigPath(agentDir), "my-pi/permission-system/hard-guards.jsonc"); - expectTarget("config/tool-search.json", getToolSearchConfigPath(agentDir), "my-pi/tool-search/config.json"); - expectTarget("config/system-prompt-cover.md", resolveCoverPath(agentDir), "my-pi/system-prompt-cover.md"); - - // Runtime state that is created rather than deployed must still live under my-pi/. - assert.ok(getGlobalLogsDir(agentDir).endsWith("my-pi/permission-system/logs")); - assert.ok(getToolSearchCatalogPath(agentDir).endsWith("my-pi/tool-search/catalog-v1.json")); -}); - -test("deployed tool-search config is exactly BUNDLE_TOOL_SEARCH_DEFAULTS", async () => { - const deployed = JSON.parse(await readFile(join(repositoryRoot, "config", "tool-search.json"), "utf8")); - assert.deepEqual(deployed, BUNDLE_TOOL_SEARCH_DEFAULTS); -}); diff --git a/tests/bundle-config.test.ts b/tests/bundle-config.test.ts new file mode 100644 index 0000000..dedcb8b --- /dev/null +++ b/tests/bundle-config.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + getGlobalConfigPath, + getGlobalLogsDir, + getHardGuardConfigPath, +} from "../pi-permission-system/src/config-paths.ts"; +import { + getToolSearchCatalogPath, + getToolSearchConfigPath, +} from "../pi-tool-search/extensions/config.ts"; +import { + CONFIG_PATH as RTK_CONFIG_PATH, + resolveConfigPath as resolveRtkConfigPath, +} from "../pi-rtk-optimizer/src/constants.ts"; +import { CONFIG_PATH as COMPACTION_CONFIG_PATH } from "../pi-compaction/src/config.ts"; +import { WORKTREE_CONFIG_PATH } from "../pi-worktree/src/settings.ts"; +import { resolveCoverPath } from "../extensions/system-prompt-cover-config.ts"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const bundleConfigDir = join(repositoryRoot, "config"); + +// pi-hermes-memory's config module uses runtime "./x.js" specifiers that +// Node's type stripping cannot resolve to sibling ".ts" files, so it is +// loaded through jiti instead of a plain import. +const { createJiti } = await import("jiti"); +const jiti = createJiti(import.meta.url); +const { DEFAULT_CONFIG_PATH: HERMES_CONFIG_PATH } = (await jiti.import( + join(repositoryRoot, "pi-hermes-memory", "src", "config.ts"), +)) as { DEFAULT_CONFIG_PATH: string }; + +/** Bundle config files every extension reads directly at runtime. */ +const BUNDLE_CONFIG_FILES = [ + "pi-permission-policy.jsonc", + "pi-permission-hard-guards.jsonc", + "tool-search.json", + "rtk-optimizer.json", + "pi-compaction.json", + "pi-worktree.json", + "pi-hermes-memory.json", + "system-prompt-cover.md", + "pi-hashline-edit.json", + "lsp.json", + "p10k.zsh", +]; + +/** Strip JSONC comments (line and block) — same approach as the seed test. */ +function stripJsonc(raw: string): string { + return raw.replace(/^\s*\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""); +} + +test("every bundle config file exists and is non-empty", async () => { + for (const name of BUNDLE_CONFIG_FILES) { + const content = await readFile(join(bundleConfigDir, name), "utf8"); + assert.ok(content.trim().length > 0, `config/${name} must not be empty`); + } +}); + +test("every bundle JSON/JSONC config file parses", async () => { + for (const name of BUNDLE_CONFIG_FILES) { + if (!name.endsWith(".json") && !name.endsWith(".jsonc")) continue; + const raw = await readFile(join(bundleConfigDir, name), "utf8"); + assert.doesNotThrow( + () => JSON.parse(stripJsonc(raw)), + `config/${name} must parse as JSONC`, + ); + } +}); + +test("each extension resolves its config path inside the bundle config/ dir", () => { + const savedToolSearch = process.env.PI_TOOL_SEARCH_CONFIG_PATH; + const savedRtk = process.env.PI_RTK_CONFIG_PATH; + delete process.env.PI_TOOL_SEARCH_CONFIG_PATH; + delete process.env.PI_RTK_CONFIG_PATH; + try { + const inBundleConfig = (resolved: string, file: string) => + assert.equal( + resolved, + join(bundleConfigDir, file), + `expected the bundle config path for ${file}`, + ); + + inBundleConfig(getGlobalConfigPath(), "pi-permission-policy.jsonc"); + inBundleConfig(getHardGuardConfigPath(), "pi-permission-hard-guards.jsonc"); + inBundleConfig(getToolSearchConfigPath(), "tool-search.json"); + inBundleConfig(RTK_CONFIG_PATH, "rtk-optimizer.json"); + inBundleConfig(resolveRtkConfigPath(), "rtk-optimizer.json"); + inBundleConfig(COMPACTION_CONFIG_PATH, "pi-compaction.json"); + inBundleConfig(WORKTREE_CONFIG_PATH, "pi-worktree.json"); + inBundleConfig(HERMES_CONFIG_PATH, "pi-hermes-memory.json"); + inBundleConfig(resolveCoverPath(), "system-prompt-cover.md"); + } finally { + if (savedToolSearch === undefined) delete process.env.PI_TOOL_SEARCH_CONFIG_PATH; + else process.env.PI_TOOL_SEARCH_CONFIG_PATH = savedToolSearch; + if (savedRtk === undefined) delete process.env.PI_RTK_CONFIG_PATH; + else process.env.PI_RTK_CONFIG_PATH = savedRtk; + } +}); + +test("disposable runtime state stays under the agent-dir my-pi tree", () => { + const agentDir = "/agent"; + assert.ok( + getGlobalLogsDir(agentDir).endsWith("my-pi/permission-system/logs"), + ); + assert.ok( + getToolSearchCatalogPath(agentDir).endsWith( + "my-pi/tool-search/catalog-v1.json", + ), + ); +}); diff --git a/tests/extension-load.test.ts b/tests/extension-load.test.ts index b86d7f1..3e41a29 100644 --- a/tests/extension-load.test.ts +++ b/tests/extension-load.test.ts @@ -78,7 +78,7 @@ test("all package extensions load together without global registration conflicts assert.equal(packageJson.dependencies.ssh2, "1.17.0", "the packed bundle must install pi-ssh's runtime transport"); assert.equal(packageJson.dependencies.jiti, "2.7.0", "the packed helper must load local TypeScript outside Pi"); assert.ok(packageJson.files.includes("pi-ssh"), "the packed bundle must include the locally maintained SSH source"); - assert.ok(packageJson.files.includes("ssh_config.sh"), "the packed bundle must include the SSH host import helper"); + assert.ok(packageJson.files.includes("configure.sh"), "the packed bundle must include the search/SSH config helper"); assert.ok(packageJson.pi.extensions.includes("./pi-ask-user/index.ts"), "the bundle must load pi-ask-user"); assert.equal(packageJson.dependencies["pi-ask-user"], "file:./pi-ask-user"); assert.ok(packageJson.files.includes("pi-ask-user"), "the packed bundle must include pi-ask-user source"); diff --git a/tests/search-auth.test.ts b/tests/search-auth.test.ts index 0893fc6..fb5409c 100644 --- a/tests/search-auth.test.ts +++ b/tests/search-auth.test.ts @@ -140,7 +140,7 @@ test("template parser handles env names, braces and escapes", () => { }); const AUTH_MJS = new URL("../scripts/search-auth.mjs", import.meta.url).pathname; -const SEARCH_SH = new URL("../search_config.sh", import.meta.url).pathname; +const CONFIGURE_SH = new URL("../configure.sh", import.meta.url).pathname; function runMjs(args: string[], env: Record = {}) { return spawnSync("node", [AUTH_MJS, ...args], { encoding: "utf8", env: { ...process.env, ...env } }); @@ -238,10 +238,10 @@ test("search-auth.mjs --has and --list never print key material", async () => { assert.doesNotMatch(list.stdout, /supersecret/); }); -test("search_config.sh writes keys through search-auth.mjs noninteractively", async () => { +test("configure.sh search writes keys through search-auth.mjs noninteractively", async () => { const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-")); const authPath = join(dir, "auth.json"); - const result = spawnSync("sh", [SEARCH_SH, "--auth", authPath, "--tavily", "tvly-sh", "--exa", "exa-sh"], { + const result = spawnSync("sh", [CONFIGURE_SH, "search", "--auth", authPath, "--tavily", "tvly-sh", "--exa", "exa-sh"], { encoding: "utf8", }); assert.equal(result.status, 0, result.stderr); diff --git a/tests/system-prompt-cover.test.ts b/tests/system-prompt-cover.test.ts index 2deb0f1..b87bce6 100644 --- a/tests/system-prompt-cover.test.ts +++ b/tests/system-prompt-cover.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import test from "node:test"; @@ -139,14 +139,16 @@ test("spliceCover keeps an attribute-bearing nested tag inside its parent sectio ); }); +test("resolveCoverPath points at the bundle config file", () => { + assert.equal(resolveCoverPath(), bundledCoverPath); +}); + test("readCoverFile returns undefined when the file is absent or blank", async () => { const dir = await makeTempDir(); try { - const coverPath = resolveCoverPath(dir); - assert.equal(coverPath, join(dir, "my-pi", "system-prompt-cover.md")); + const coverPath = join(dir, "system-prompt-cover.md"); assert.equal(readCoverFile(coverPath), undefined); - await mkdir(dirname(coverPath), { recursive: true }); await writeFile(coverPath, " \n\n", "utf8"); assert.equal(readCoverFile(coverPath), undefined); diff --git a/update.sh b/update.sh index d7fd1fd..dcf7177 100755 --- a/update.sh +++ b/update.sh @@ -3,13 +3,13 @@ set -u SCRIPT_DIR=$(CDPATH=; cd "$(dirname "$0")" && pwd) || exit 1 -MANAGED_CONFIG_HELPERS=$SCRIPT_DIR/scripts/managed-config.sh -if [ ! -f "$MANAGED_CONFIG_HELPERS" ]; then - printf '错误: 找不到受管配置辅助脚本:%s\n' "$MANAGED_CONFIG_HELPERS" >&2 +MY_PI_LIB=$SCRIPT_DIR/scripts/lib.sh +if [ ! -f "$MY_PI_LIB" ]; then + printf '错误: 找不到共享辅助脚本:%s\n' "$MY_PI_LIB" >&2 exit 1 fi -# shellcheck source=scripts/managed-config.sh -. "$MANAGED_CONFIG_HELPERS" +# shellcheck source=scripts/lib.sh +. "$MY_PI_LIB" PACKAGE_SOURCE=${PI_PACKAGE_SOURCE:-git:git@bitbucket.org:siakitem/my-pi.git} POWERLEVEL10K_PRESET_FILE=$SCRIPT_DIR/config/p10k.zsh @@ -20,14 +20,6 @@ KITTY_VERSION_URL=https://sw.kovidgoyal.net/kitty/current-version.txt update_failed=0 brew_metadata_refreshed=0 -say() { - printf '%s\n' "$*" -} - -warn() { - printf '警告: %s\n' "$*" >&2 -} - record_update_failure() { warn "$1 升级或配置同步失败;其余已安装组件仍会继续处理。" update_failed=1 @@ -35,6 +27,7 @@ record_update_failure() { select_updated_bundle_config() { updated_bundle_dir= + updated_bundle_managed_git=0 case $PACKAGE_SOURCE in /*) updated_bundle_dir=$PACKAGE_SOURCE @@ -44,6 +37,7 @@ select_updated_bundle_config() { ;; git:git@bitbucket.org:siakitem/my-pi.git) updated_bundle_dir=${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}/git/bitbucket.org/siakitem/my-pi + updated_bundle_managed_git=1 ;; esac @@ -57,62 +51,18 @@ select_updated_bundle_config() { fi } -resolve_agent_dir() { - if [ -n "${PI_CODING_AGENT_DIR:-}" ]; then - case $PI_CODING_AGENT_DIR in - ~/*) printf '%s\n' "$HOME/${PI_CODING_AGENT_DIR#~/}" ;; - *) printf '%s\n' "$PI_CODING_AGENT_DIR" ;; - esac - return 0 +# pi update 只在有新提交时覆盖已安装副本;update.sh 每次成功升级后都把受管 +# checkout 还原到已发布版本,使对已安装副本的本地修改(包括扩展设置界面的 +# 保存)不积累。只对默认 git 来源的受管 checkout 执行:本地路径来源就是用户的 +# 工作仓库,绝不能 reset。不做 git clean,以保留 node_modules。 +restore_installed_bundle() { + [ "${updated_bundle_managed_git:-0}" = "1" ] || return 0 + [ -d "$updated_bundle_dir/.git" ] || return 0 + if git -C "$updated_bundle_dir" reset --quiet --hard HEAD; then + say "✓ 已还原已安装组合包中的本地修改" + else + record_update_failure "已安装组合包本地修改还原" fi - printf '%s\n' "$HOME/.pi/agent" -} - -# 整体删除并重建组合包拥有的 /my-pi/ 配置目录;helper 必须来自升级后的 -# 组合包,缺失时报告失败且绝不回退到脚本目录中可能陈旧的副本。 -rebuild_bundle_config() { - if [ -z "${updated_bundle_dir:-}" ] || [ ! -f "$updated_bundle_dir/scripts/rebuild-bundle-config.mjs" ]; then - warn "无法定位升级后组合包中的配置重建辅助脚本,已保留现有配置。" - return 1 - fi - if ! command -v node >/dev/null 2>&1; then - warn "找不到 node,无法重建组合包配置目录。" - return 1 - fi - rebuild_helper=$updated_bundle_dir/scripts/rebuild-bundle-config.mjs - rebuild_agent_dir=$(resolve_agent_dir) || return 1 - rebuild_status=$(node "$rebuild_helper" "$rebuild_agent_dir" 2>&1) || { - warn "组合包配置目录重建失败:$rebuild_status" - return 1 - } - case $rebuild_status in - rebuilt:*) say "✓ 已重建组合包配置目录:${rebuild_status#rebuilt:}" ;; - *) - warn "组合包配置重建返回未知状态:$rebuild_status" - return 1 - ;; - esac -} -resolve_zsh_paths() { - oh_my_zsh_dir=${ZSH:-$HOME/.oh-my-zsh} - zsh_custom_dir=${ZSH_CUSTOM:-$oh_my_zsh_dir/custom} - zshrc_file=${ZDOTDIR:-$HOME}/.zshrc - my_pi_config_dir=${XDG_CONFIG_HOME:-$HOME/.config}/my-pi - p10k_managed_config_file=$my_pi_config_dir/p10k.zsh -} - -replace_zsh_managed_block() { - managed_id=$1 - managed_placement=$2 - managed_anchor=$3 - shift 3 - managed_content=$(mktemp "${TMPDIR:-/tmp}/my-pi-zsh-block.XXXXXX") || return 1 - printf '%s\n' "$@" > "$managed_content" - my_pi_replace_managed_block "$zshrc_file" "$managed_id" "$managed_content" \ - "$managed_placement" "$managed_anchor" - managed_status=$? - rm -f "$managed_content" - return "$managed_status" } update_git_checkout() { @@ -186,29 +136,6 @@ update_brew_formula_if_installed() { fi } -detect_kitty() { - kitty_bin= - if command -v kitty >/dev/null 2>&1; then - kitty_bin=$(command -v kitty) - return 0 - fi - case $(uname -s) in - Darwin) kitty_candidates="/Applications/kitty.app/Contents/MacOS/kitty" ;; - *) kitty_candidates="$HOME/.local/kitty.app/bin/kitty" ;; - esac - for candidate in $kitty_candidates; do - if [ -x "$candidate" ]; then - kitty_bin=$candidate - return 0 - fi - done - return 1 -} - -resolve_kitty_config_dir() { - kitty_config_dir=${KITTY_CONFIG_DIRECTORY:-${XDG_CONFIG_HOME:-$HOME/.config}/kitty} -} - kitty_uses_solarized_dark() { resolve_kitty_config_dir [ -f "$kitty_config_dir/current-theme.conf" ] && @@ -347,13 +274,13 @@ update_zsh_environment() { fi if my_pi_managed_block_exists "$zshrc_file" "powerlevel10k-theme"; then replace_zsh_managed_block "powerlevel10k-theme" "before" \ - '^(# >>> my-pi:oh-my-zsh-loader >>>|[[:space:]]*(source|\.)[[:space:]].*oh-my-zsh\.sh)' \ + "$MY_PI_ZSH_LOADER_ANCHOR" \ 'ZSH_THEME="powerlevel10k/powerlevel10k"' || record_update_failure ".zshrc Powerlevel10k 主题受管块" fi for plugin in git zsh-autosuggestions zsh-syntax-highlighting; do if my_pi_managed_block_exists "$zshrc_file" "zsh-plugin-$plugin"; then replace_zsh_managed_block "zsh-plugin-$plugin" "before" \ - '^(# >>> my-pi:oh-my-zsh-loader >>>|[[:space:]]*(source|\.)[[:space:]].*oh-my-zsh\.sh)' \ + "$MY_PI_ZSH_LOADER_ANCHOR" \ "plugins+=($plugin)" || record_update_failure ".zshrc $plugin 受管块" fi done @@ -381,7 +308,7 @@ if ! pi update "$PACKAGE_SOURCE"; then exit 1 fi select_updated_bundle_config || exit 1 -rebuild_bundle_config || record_update_failure "组合包配置重建" +restore_installed_bundle say "组合包升级完成,开始升级已安装的终端环境和机器级 CLI;未安装项将直接跳过。" update_kitty || record_update_failure "Kitty"