mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
Merge branch 'main' of bitbucket.org:siakitem/my-pi
# Conflicts: # AGENTS.md # README.md # package-lock.json # package.json # pi-tool-search/CHANGELOG.md # pi-tool-search/README.md # pi-tool-search/docs/dynamic-tool-loading.md # pi-tool-search/extensions/bundle-groups.ts # pi-tool-search/test/bundle-groups.test.ts
This commit is contained in:
@@ -8,6 +8,10 @@
|
|||||||
- 上游来源:<https://github.com/MasuRii/pi-rtk-optimizer>
|
- 上游来源:<https://github.com/MasuRii/pi-rtk-optimizer>
|
||||||
- 初始导入快照:`d155d253cb2f1358e34e717d47a82ebccb08cb8e`(2026-07-03,`0.9.0`)。
|
- 初始导入快照:`d155d253cb2f1358e34e717d47a82ebccb08cb8e`(2026-07-03,`0.9.0`)。
|
||||||
- 该目录已纳入本仓库直接维护,不是 submodule,也不保留嵌套 `.git`。
|
- 该目录已纳入本仓库直接维护,不是 submodule,也不保留嵌套 `.git`。
|
||||||
|
- `pi-ssh/`:从上游源码导入并由根组合包加载的纯 `ssh2` 远程操作扩展,通过受权限链复核的 `ssh_connect` 建立 Agent 控制的持久连接,以 `ssh_cd` 显式维护远端工作区,并以 SFTP 与有界自适应搜索提供独立的远端工具;运行时不调用 OpenSSH 或 `sshpass`。
|
||||||
|
- 上游来源:<https://github.com/pansapiens/pi-ssh>
|
||||||
|
- 初始导入快照:`e9a1059a0f37ab14b6a73ee608cb203edf803f31`(2026-06-23,`0.7.0`)。
|
||||||
|
- 该目录已纳入本仓库直接维护,不是 submodule,也不保留嵌套 `.git`、`node_modules` 或构建产物;纯 `ssh2` 设计参考 `99percentpeople/pi-extensions` 的明确 commit,来源记录保留在 `pi-ssh/UPSTREAM.md`。
|
||||||
- `pi-permission-auto-review/`:作为 `pi-permission-system` 的 `auto-review` authorizer,使用 Codex Guardian 风格策略复核权限基线产生的 `ask`。
|
- `pi-permission-auto-review/`:作为 `pi-permission-system` 的 `auto-review` authorizer,使用 Codex Guardian 风格策略复核权限基线产生的 `ask`。
|
||||||
- 上游来源:<https://github.com/mzwing/pi-packages/tree/main/packages/pi-permission-auto-review>
|
- 上游来源:<https://github.com/mzwing/pi-packages/tree/main/packages/pi-permission-auto-review>
|
||||||
- 初始导入快照:`8d196e4ef0884cac8326c366191dad3f585d470a`(`@mzwing/pi-permission-auto-review@0.2.0`)。
|
- 初始导入快照:`8d196e4ef0884cac8326c366191dad3f585d470a`(`@mzwing/pi-permission-auto-review@0.2.0`)。
|
||||||
@@ -25,6 +29,7 @@
|
|||||||
- `@keenable/pi-search`:加载 `keenable_search` / `keenable_fetch` 及其 skill,默认 keyless,`KEENABLE_API_KEY` 仅用于提高速率限制。
|
- `@keenable/pi-search`:加载 `keenable_search` / `keenable_fetch` 及其 skill,默认 keyless,`KEENABLE_API_KEY` 仅用于提高速率限制。
|
||||||
- `search_config.sh` 与 `extensions/search-config.ts`:以交互式或参数方式把三家搜索 key 写入用户级 `search.env`(权限 `600`),并在搜索扩展初始化前加载;显式进程环境变量优先。
|
- `search_config.sh` 与 `extensions/search-config.ts`:以交互式或参数方式把三家搜索 key 写入用户级 `search.env`(权限 `600`),并在搜索扩展初始化前加载;显式进程环境变量优先。
|
||||||
- `extensions/mcp.ts`:通过单个共享 `pi-mcp-adapter` 实例同时连接 Exa 托管 MCP 与机器现有的 `codegraph serve --mcp`,避免重复注册 Pi 的全局 MCP flag 与命令;Exa key 只通过 `x-api-key` 请求头发送,原始工具映射为统一的来源前缀形式,CodeGraph 只暴露 `codegraph_explore`。
|
- `extensions/mcp.ts`:通过单个共享 `pi-mcp-adapter` 实例同时连接 Exa 托管 MCP 与机器现有的 `codegraph serve --mcp`,避免重复注册 Pi 的全局 MCP flag 与命令;Exa key 只通过 `x-api-key` 请求头发送,原始工具映射为统一的来源前缀形式,CodeGraph 只暴露 `codegraph_explore`。
|
||||||
|
- `extensions/plugin-init.ts`:注册用户显式触发的 `/plugin_init`,在确认当前项目后只初始化缺失的 CodeGraph/Hippo 状态,并在全部成功后调用 Pi 官方热重载。
|
||||||
- `context-mode`:加载上游 Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性。
|
- `context-mode`:加载上游 Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性。
|
||||||
- `extensions/hashline.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`,以行哈希锚点覆盖内置 `read` / `edit`;Hashline 自带 `grep` 强制关闭。
|
- `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-system.json` 同步为全局权威配置。
|
- `extensions/permission-system.ts`:直接加载仓库内 `pi-permission-system/src/index.ts`,并在权限扩展注册前将 `config/pi-permission-system.json` 同步为全局权威配置。
|
||||||
@@ -34,6 +39,10 @@
|
|||||||
- 上游来源:<https://github.com/tuansondinh/pi-tool-search>
|
- 上游来源:<https://github.com/tuansondinh/pi-tool-search>
|
||||||
- 初始导入快照:`ddfb23646fd3957b791214de278e23aa393c9b13`(`v0.3.6`)。
|
- 初始导入快照:`ddfb23646fd3957b791214de278e23aa393c9b13`(`v0.3.6`)。
|
||||||
- 该目录不是 submodule,不保留嵌套 `.git`、上游 `.pi` 状态、`node_modules` 或构建产物。
|
- 该目录不是 submodule,不保留嵌套 `.git`、上游 `.pi` 状态、`node_modules` 或构建产物。
|
||||||
|
- `pi-ask-user/`:把 Pi 上游 `question.ts` 与 `questionnaire.ts` 示例整合为模型主动调用的 `ask_user_question` 工具,统一支持单题、多题、选择、自由文本、自定义回答与复核;不加载 `/qna`。
|
||||||
|
- 上游来源:<https://github.com/earendil-works/pi/tree/main/packages/coding-agent/examples/extensions>
|
||||||
|
- 初始参考快照:`dcd461925db2edf69a43c8135db1180d418afd54`(2026-08-24)。
|
||||||
|
- 该目录由本仓库直接维护,保留上游 MIT 许可证与来源说明;根组合包直接加载源码并通过本地 `file:` 依赖打包。
|
||||||
- `pi-lsp@0.1.7`:提供声明式 LSP 接入;组合包内置 `typescript-language-server@5.3.0` + `typescript@6.0.3`,并配置机器级 `kotlin-lsp --stdio` 与 `jdtls`。
|
- `pi-lsp@0.1.7`:提供声明式 LSP 接入;组合包内置 `typescript-language-server@5.3.0` + `typescript@6.0.3`,并配置机器级 `kotlin-lsp --stdio` 与 `jdtls`。
|
||||||
- `hippo-memory-pi/`:从官方 `hippo-memory` 仓库的 `extensions/pi-extension/` 导入并由本仓库直接维护;提供 session start 项目记忆注入、工具错误过滤捕获、session shutdown sleep 和 5 个 `hippo_*` 工具。
|
- `hippo-memory-pi/`:从官方 `hippo-memory` 仓库的 `extensions/pi-extension/` 导入并由本仓库直接维护;提供 session start 项目记忆注入、工具错误过滤捕获、session shutdown sleep 和 5 个 `hippo_*` 工具。
|
||||||
- 上游来源:<https://github.com/kitfunso/hippo-memory>
|
- 上游来源:<https://github.com/kitfunso/hippo-memory>
|
||||||
@@ -44,31 +53,36 @@
|
|||||||
- 上游来源:<https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-minimal-footer>
|
- 上游来源:<https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-minimal-footer>
|
||||||
- 初始导入快照:`77bd1e175003cd08e6d05d9e7fed695f86ae87b7`(`@ogulcancelik/pi-minimal-footer@0.1.10`)。
|
- 初始导入快照:`77bd1e175003cd08e6d05d9e7fed695f86ae87b7`(`@ogulcancelik/pi-minimal-footer@0.1.10`)。
|
||||||
- 该目录从明确 commit 的上游源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
- 该目录从明确 commit 的上游源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
||||||
|
- `pi-notify/`:基于 `@smoose/pi-notify` 的 Kitty 优先完成通知扩展,使用 OSC 99 精确聚焦来源 window/pane,并在 Pi 完全 settled 后通知。
|
||||||
|
- 上游来源:<https://github.com/smoosex/pi-notify>
|
||||||
|
- 初始导入快照:`3a3691ab690b4bc37a4412ab0dcbd35ef14adcbf`(`@smoose/pi-notify@0.1.1`)。
|
||||||
|
- 该目录从 npm 对应明确 commit 的源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
||||||
- `pi-extension-codex-fast-mode/`:为符合条件的 Codex Responses 请求设置 priority service tier,并把新会话默认值跨会话持久化。
|
- `pi-extension-codex-fast-mode/`:为符合条件的 Codex Responses 请求设置 priority service tier,并把新会话默认值跨会话持久化。
|
||||||
- 上游来源:<https://github.com/Firstp1ck/pi-coding-agent-forge/tree/main/pi-extension-codex-fast-mode>
|
- 上游来源:<https://github.com/Firstp1ck/pi-coding-agent-forge/tree/main/pi-extension-codex-fast-mode>
|
||||||
- 初始导入快照:`f1d0efd24a7f4ae99d19e10c5f4c3770a3bdd845`(`@firstpick/pi-extension-codex-fast-mode@0.1.1`)。
|
- 初始导入快照:`f1d0efd24a7f4ae99d19e10c5f4c3770a3bdd845`(`@firstpick/pi-extension-codex-fast-mode@0.1.1`)。
|
||||||
- 该目录从上游源码仓库导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
- 该目录从上游源码仓库导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
||||||
- `pi-condense@2.9.1`:总结已完成的工具调用批次,以短 stub 替换历史原始输出,并通过 `context_tree_query` 按需恢复;组合包在用户尚未配置 `contextPrune.enabled` 时默认开启。
|
- `pi-condense@2.9.1`:总结已完成的工具调用批次,以短 stub 替换历史原始输出,并通过 `context_tree_query` 按需恢复;组合包在用户尚未配置 `contextPrune.enabled` 时默认开启。
|
||||||
- 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0`、`typescript@6.0.3` 和 `pi-context-view`;`@firstpick/pi-extension-codex-fast-mode`、`@gotgenes/pi-permission-system`、`@tintinweb/pi-subagents`、`pi-minimal-footer` 与 `pi-tool-search` 分别指向仓库内对应目录的本地 `file:` 依赖。
|
- 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0`、`typescript@6.0.3` 和 `pi-context-view`;`@firstpick/pi-extension-codex-fast-mode`、`@gotgenes/pi-permission-system`、`@smoose/pi-notify`、`@tintinweb/pi-subagents`、`pi-ask-user`、`pi-minimal-footer`、`pi-ssh` 与 `pi-tool-search` 分别指向仓库内对应目录的本地 `file:` 依赖。
|
||||||
- `install.sh`:先安装根组合包,再交互检查 Hippo Memory CLI、Kitty/Solarized Dark、Oh My Zsh/Powerlevel10k/Zsh 插件、CodeGraph、Kotlin LSP、Java 21+ 和 JDT LS;缺失项目只在用户明确选择 `Y` 后安装或配置。Hippo 项目目录与 `hippo init` 始终由用户自行决定。Powerlevel10k 默认配置来自仓库内置的 `config/p10k.zsh`(当前 Rainbow/ASCII 单行紧凑主题)。
|
- `install.sh`:先安装根组合包,再交互检查 Hippo Memory CLI、Kitty/Solarized Dark、Oh My Zsh/Powerlevel10k/Zsh 插件、CodeGraph、Kotlin LSP、Java 21+ 和 JDT LS;缺失项目只在用户明确选择 `Y` 后安装或配置。Hippo 项目目录与 `hippo init` 始终由用户自行决定。Powerlevel10k 默认配置来自仓库内置的 `config/p10k.zsh`(当前 Rainbow/ASCII 单行紧凑主题)。
|
||||||
- `update.sh`:先通过 `pi update` 升级根组合包,再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。
|
- `update.sh`:先通过 `pi update` 升级根组合包,再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。
|
||||||
- `uninstall.sh`:先移除根组合包;若检测到 Hippo Memory CLI,再明确询问是否卸载确认属于 npm 全局安装的 `hippo-memory`,默认保留且始终不删除 `.hippo/` 或用户记忆数据。其他共享终端环境和机器工具不卸载。
|
- `uninstall.sh`:先移除根组合包;若检测到 Hippo Memory CLI,再明确询问是否卸载确认属于 npm 全局安装的 `hippo-memory`,默认保留且始终不删除 `.hippo/` 或用户记忆数据。其他共享终端环境和机器工具不卸载。
|
||||||
|
|
||||||
## 当前职责与默认行为
|
## 当前职责与默认行为
|
||||||
|
|
||||||
- FFF 独占字面搜索。RTK 不得处理 `grep`、`find`、`multi_grep` 的调用或结果,也不得通过默认命令改写接管 `rg`、`grep`、`find`、`fd`。
|
- FFF 独占本地字面搜索;远端服务器由独立的 `ssh_find` / `ssh_grep` 结构化工具在已授权调用内自适应使用现有命令。RTK 不得处理本地或远端搜索工具的调用/结果,也不得通过默认命令改写接管 `rg`、`grep`、`find`、`fd`。
|
||||||
- RTK 默认只压缩非搜索输出,包括 Bash ANSI 清理、测试聚合、构建过滤、Git 压缩和 Lint 聚合,并记录压缩统计。
|
- RTK 默认只压缩非搜索输出,包括本地 `bash` 与远端 `ssh_bash` 的 ANSI 清理、测试聚合、构建过滤、Git 压缩、Lint 聚合和兜底截断;`ssh_bash` 只复用输出处理,远端命令不得进入 RTK rewrite。
|
||||||
- `commandRewritingEnabled` 默认 `false`。默认安装不依赖系统 `rtk` CLI;只有用户主动开启命令改写时才需要 `rtk rewrite`。
|
- `commandRewritingEnabled` 默认 `false`。默认安装不依赖系统 `rtk` CLI;只有用户主动开启命令改写时才需要 `rtk rewrite`。
|
||||||
- `readCompaction.enabled`、`sourceCodeFilteringEnabled` 和 `smartTruncate.enabled` 当前均默认 `false`,源码读取保持原样。未经用户明确决定,不因节省上下文而改变这些默认值。
|
- `readCompaction.enabled`、`sourceCodeFilteringEnabled` 和 `smartTruncate.enabled` 当前均默认 `false`,源码读取保持原样。未经用户明确决定,不因节省上下文而改变这些默认值。
|
||||||
- 若以后开启 read 压缩,优先考虑 `readCompaction + smartTruncate`,源码过滤仍独立评估;必须保留精确 `offset/limit` 读取、短文件和行锚点的完整性。
|
- 若以后开启 read 压缩,优先考虑 `readCompaction + smartTruncate`,源码过滤仍独立评估;必须保留精确 `offset/limit` 读取、短文件和行锚点的完整性。
|
||||||
- Context Mode 负责避免批量读取、命令研究和网页原始内容直接撑大上下文;FFF 仍负责精确字面搜索,RTK 仍处理未走 Context Mode 的普通输出。
|
- Context Mode 负责避免批量读取、命令研究和网页原始内容直接撑大上下文;FFF 仍负责精确字面搜索,RTK 仍处理未走 Context Mode 的普通输出。
|
||||||
- Hashline 默认以 2 字符行哈希覆盖内置 `read` / `edit`,`grep` 和 `replaceText` 均关闭;它保证锚定编辑而不负责压缩,大文件上下文节省必须来自先定位/隔离分析、再做最小范围 `offset/limit` 读取。
|
- 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`。在线搜索按场景只先选一个服务:广泛发现、新闻和候选来源用 Tavily,官方/技术/论文和高确定性来源用 Exa,中文、站点限定和日期筛选用 Keenable;只有必要时才跨服务复核。遇到大量结果、截断或上限时继续缩小范围,不得靠提高 limit 或倾倒全部结果解决。
|
- 工具路由规则不得替换 Pi 默认系统提示词:代码结构、调用关系和待修改 symbol 优先 CodeGraph,定义/引用/类型/诊断优先 LSP;工作区内大文件探索、分析和总结优先 `ctx_execute_file`,日志、构建与不可预测命令输出优先 Context Mode;字面搜索先用 FFF `find` 收敛文件位置,再在收敛路径内用 `grep`/`multi_grep` 获取行号;真正修改前才用小范围 Hashline `read` 获取新鲜锚点并用锚定 `edit`。在线搜索按场景只先选一个服务:广泛发现、新闻和候选来源用 Tavily,官方/技术/论文和高确定性来源用 Exa,中文、站点限定和日期筛选用 Keenable;只有必要时才跨服务复核。遇到大量结果、截断或上限时继续缩小范围,不得靠提高 limit 或倾倒全部结果解决。
|
||||||
|
- `ask_user_question` 只用于模型继续任务所必需的用户决定、偏好、确认或澄清;不得重复询问直接用户消息中已有的信息。工具仅支持交互式 TUI、顺序执行和最多 8 题;取消或 abort 必须返回 `cancelled: true`,不得伪造答案,且 `/qna` 不属于该扩展。其成功 `details.answers` 必须保留 `question` / `answer` 字段,以匹配 AutoReview 已有的可信结构化用户交互边界。
|
||||||
- Tool Search 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics` 与 `tool_search`;其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组。默认最多 3 个动态组、模型组最多 8 个工具、动态工具总数最多 20,加载或组内调用更新 LRU,超限先移除最久未用组。
|
- Tool Search 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics` 与 `tool_search`;其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组。默认最多 3 个动态组、模型组最多 8 个工具、动态工具总数最多 20,加载或组内调用更新 LRU,超限先移除最久未用组。
|
||||||
- 标准组合包工具全部命中预置目录时 Tool Search 不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。
|
- 标准组合包工具全部命中预置目录时 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 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。
|
||||||
- `.pi-debug/` 是 `/dump-system-prompt` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。
|
- `.pi-debug/` 是 `/dump-system-prompt` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。
|
||||||
- CodeGraph 扩展只配置 Pi 到外部 `codegraph` 命令的 MCP 连接。根包安装本身不安装 CodeGraph;便捷脚本仅在组合包安装完成且用户明确选择 `Y` 后调用官方安装器。仓库不执行 `codegraph init`,不创建或管理 `.codegraph/`,也不改动索引、更新或遥测设置。
|
- CodeGraph MCP 扩展只配置 Pi 到外部 `codegraph` 命令的连接。根包安装本身不安装 CodeGraph;便捷脚本仅在组合包安装完成且用户明确选择 `Y` 后调用官方安装器。安装、升级和普通启动流程不得执行 `codegraph init`;只有用户在目标项目显式调用 `/plugin_init` 并确认后,命令才可以 `.codegraph/codegraph.db` 与 `.hippo/hippo.db` 为权威标志初始化缺失状态。命令必须先预检两个 CLI,任一步失败不得热重载,也不得隐瞒此前已完成的部分初始化;不得改动 CodeGraph 更新或遥测设置。
|
||||||
- CodeGraph MCP 使用 `keep-alive` 并只直接暴露 `codegraph_explore`;命令缺失、项目未初始化或连接失败时不得阻止其他扩展加载。
|
- CodeGraph MCP 使用 `keep-alive` 并只直接暴露 `codegraph_explore`;命令缺失、项目未初始化或连接失败时不得阻止其他扩展加载。
|
||||||
- 所有 MCP 服务必须合并到 `extensions/mcp.ts` 创建的唯一 `pi-mcp-adapter` 实例;不得新增独立 adapter 扩展入口,否则会重复注册 `--mcp-config`、`/mcp` 等全局接口。根测试必须扫描全部本地扩展入口并强制这一不变量。
|
- 所有 MCP 服务必须合并到 `extensions/mcp.ts` 创建的唯一 `pi-mcp-adapter` 实例;不得新增独立 adapter 扩展入口,否则会重复注册 `--mcp-config`、`/mcp` 等全局接口。根测试必须扫描全部本地扩展入口并强制这一不变量。
|
||||||
- Exa MCP 使用托管 Streamable HTTP 端点和 `eager` lifecycle;`EXA_API_KEY` 通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。所有在线搜索工具统一采用“来源名 + 原始语义工具名”的命名形式,例如 `tavily_web_search`、`exa_web_search`、`keenable_search`。Exa 高级搜索必须显式约束结果数量和文本长度;Tavily 输出默认控制 `max_results` 且非必要不请求 raw content;Keenable 优先利用中文、站点和日期筛选能力。
|
- Exa MCP 使用托管 Streamable HTTP 端点和 `eager` lifecycle;`EXA_API_KEY` 通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。所有在线搜索工具统一采用“来源名 + 原始语义工具名”的命名形式,例如 `tavily_web_search`、`exa_web_search`、`keenable_search`。Exa 高级搜索必须显式约束结果数量和文本长度;Tavily 输出默认控制 `max_results` 且非必要不请求 raw content;Keenable 优先利用中文、站点和日期筛选能力。
|
||||||
@@ -76,7 +90,10 @@
|
|||||||
- `pi-context-view` 只观察上下文占用,不参与压缩策略。
|
- `pi-context-view` 只观察上下文占用,不参与压缩策略。
|
||||||
- `pi-condense` 负责压缩已经进入会话的历史工具结果,与 Context Mode 的输入隔离职责互补;`extensions/condense.ts` 只在 `settings.json` 尚无 `contextPrune.enabled` 时写入 `true`,必须保留用户显式设置的 `false`,配置无效时不得覆盖原文件。其他参数沿用上游默认,包括 `agent-message` 触发模式和 skill 路径保护。
|
- `pi-condense` 负责压缩已经进入会话的历史工具结果,与 Context Mode 的输入隔离职责互补;`extensions/condense.ts` 只在 `settings.json` 尚无 `contextPrune.enabled` 时写入 `true`,必须保留用户显式设置的 `false`,配置无效时不得覆盖原文件。其他参数沿用上游默认,包括 `agent-message` 触发模式和 skill 路径保护。
|
||||||
- Codex fast mode 只为符合条件的 `openai-codex-responses` 请求设置 priority service tier。`/fast-mode on|off` 同时更新当前分支记录与 Pi agent 目录中的 owner-only 全局默认值;新会话继承全局值,已有分支记录优先,配置缺失或无效时回退为关闭。
|
- Codex fast mode 只为符合条件的 `openai-codex-responses` 请求设置 priority service tier。`/fast-mode on|off` 同时更新当前分支记录与 Pi agent 目录中的 owner-only 全局默认值;新会话继承全局值,已有分支记录优先,配置缺失或无效时回退为关闭。
|
||||||
|
- Kitty 通知只在 TUI 模式的 `agent_settled` 后发送;自动重试、自动 compaction 和 follow-up 期间不得提前通知或重置总耗时。默认 `o=unfocused`、`a=focus`,每个 Pi Session 使用独立稳定 ID,标题和正文必须 Base64 编码。
|
||||||
|
- `/notify on|off` 只覆盖当前 Session;持久默认来自 `PI_NOTIFY_*` 环境变量。`PI_NOTIFY_MESSAGE_SOURCE=none` 必须继续提供不泄露回复正文的隐私模式,非 TUI 模式不得写入 OSC 序列。
|
||||||
- 权限策略默认允许常规工具,允许 FFF 工具;拒绝 Bash 直搜和敏感凭据路径;Git 非只读操作、包管理、外部目录、文件/系统/网络高风险操作与普通 MCP 调用先由 `pi-permission-system` 判为 `ask`。
|
- 权限策略默认允许常规工具,允许 FFF 工具;拒绝 Bash 直搜和敏感凭据路径;Git 非只读操作、包管理、外部目录、文件/系统/网络高风险操作与普通 MCP 调用先由 `pi-permission-system` 判为 `ask`。
|
||||||
|
- 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 的 `path` / `external_directory` 归一化。
|
||||||
- `pi-permission-auto-review` 不是独立 `tool_call` gate,而是 `pi-permission-system` authorizer chain 中名为 `auto-review` 的链路;只复核权限基线产生的 `ask`,不会重复处理已 `allow` 或已 `deny` 的请求。
|
- `pi-permission-auto-review` 不是独立 `tool_call` gate,而是 `pi-permission-system` authorizer chain 中名为 `auto-review` 的链路;只复核权限基线产生的 `ask`,不会重复处理已 `allow` 或已 `deny` 的请求。
|
||||||
- reviewer 返回 `allow` 时自动批准、返回 `deny` 时直接拒绝,配置、模型、认证、超时或响应异常时必须 `defer` 到正常人工提示。`pi-permission-system` 的 delegation envelope 继续禁止 authorizer 自动批准全部 `path` 请求;`external_directory` 对内置只读路径工具(`read`、`find`、`grep`、`ls`)接受 reviewer 的 `allow`,写入、编辑、Bash、未知工具和其他外部访问仍转人工。
|
- reviewer 返回 `allow` 时自动批准、返回 `deny` 时直接拒绝,配置、模型、认证、超时或响应异常时必须 `defer` 到正常人工提示。`pi-permission-system` 的 delegation envelope 继续禁止 authorizer 自动批准全部 `path` 请求;`external_directory` 对内置只读路径工具(`read`、`find`、`grep`、`ls`)接受 reviewer 的 `allow`,写入、编辑、Bash、未知工具和其他外部访问仍转人工。
|
||||||
- 默认 reviewer 为 `openai-codex/codex-auto-review`、low reasoning、90 秒总重试预算和内置 Codex Guardian 风格策略;只把 active branch 中的直接用户消息与已识别结构化问答作为授权证据,assistant/tool/compaction 内容不能自行授权。
|
- 默认 reviewer 为 `openai-codex/codex-auto-review`、low reasoning、90 秒总重试预算和内置 Codex Guardian 风格策略;只把 active branch 中的直接用户消息与已识别结构化问答作为授权证据,assistant/tool/compaction 内容不能自行授权。
|
||||||
@@ -102,6 +119,7 @@
|
|||||||
- `pi-tool-search/` 的根依赖必须保持 `file:./pi-tool-search`,组合包直接加载 `pi-tool-search/extensions/index.ts`;同步上游时先核对本地增量动态工具改造,禁止用旧 npm 入口或上游旧宿主实现直接覆盖。
|
- `pi-tool-search/` 的根依赖必须保持 `file:./pi-tool-search`,组合包直接加载 `pi-tool-search/extensions/index.ts`;同步上游时先核对本地增量动态工具改造,禁止用旧 npm 入口或上游旧宿主实现直接覆盖。
|
||||||
- 新引入或同步自维护扩展时,必须先联网确认其官方源码仓库地址,再从官方 Git 仓库的明确 tag/commit 拉取源码快照;禁止以 npm tarball、npm 缓存、`node_modules`、`dist/` 或其他预编译发布产物作为导入源。npm registry 只能用于核对包名、版本和发布元数据,不能替代源码仓库。
|
- 新引入或同步自维护扩展时,必须先联网确认其官方源码仓库地址,再从官方 Git 仓库的明确 tag/commit 拉取源码快照;禁止以 npm tarball、npm 缓存、`node_modules`、`dist/` 或其他预编译发布产物作为导入源。npm registry 只能用于核对包名、版本和发布元数据,不能替代源码仓库。
|
||||||
- 源码导入必须保留上游源码、测试、必要文档、许可证和来源记录,移除嵌套 `.git`、上游本地 `.pi` 状态、`node_modules`、覆盖率与构建产物,并在本文件和扩展来源说明中记录官方仓库、tag、commit 与版本。
|
- 源码导入必须保留上游源码、测试、必要文档、许可证和来源记录,移除嵌套 `.git`、上游本地 `.pi` 状态、`node_modules`、覆盖率与构建产物,并在本文件和扩展来源说明中记录官方仓库、tag、commit 与版本。
|
||||||
|
- `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 和本文件。
|
- `install.sh`、`update.sh`、`uninstall.sh` 和 `search_config.sh` 必须保持 POSIX `sh` 兼容和可执行权限,并包含在根 `package.json` 的 `files` 中;修改脚本行为时同步更新 README 和本文件。
|
||||||
- 安装流程中的机器级依赖和用户终端配置必须保持逐项询问且默认拒绝,不得在没有用户明确确认的情况下自动安装或改写。用户主动运行 `update.sh` 只授权升级已安装项和同步已有受管配置;缺失项仍必须跳过。`.zshrc` 修改必须局限于受管块并保留备份,卸载不得顺带删除或还原共享工具和用户终端配置。
|
- 安装流程中的机器级依赖和用户终端配置必须保持逐项询问且默认拒绝,不得在没有用户明确确认的情况下自动安装或改写。用户主动运行 `update.sh` 只授权升级已安装项和同步已有受管配置;缺失项仍必须跳过。`.zshrc` 修改必须局限于受管块并保留备份,卸载不得顺带删除或还原共享工具和用户终端配置。
|
||||||
@@ -172,6 +190,43 @@
|
|||||||
- `ask` 只能由 auto-review 在 delegation envelope 内明确 `allow` 时自动批准;reviewer `defer`、异常或不在 envelope 内必须转发父会话人工 authority,不得因子会话无 UI 静默放宽。父模型生成的子 Agent prompt 不得伪装成人类直接授权证据。
|
- `ask` 只能由 auto-review 在 delegation envelope 内明确 `allow` 时自动批准;reviewer `defer`、异常或不在 envelope 内必须转发父会话人工 authority,不得因子会话无 UI 静默放宽。父模型生成的子 Agent prompt 不得伪装成人类直接授权证据。
|
||||||
- 子 Agent 转发的 access facts 必须先由根 authority 的 `SessionRules` 匹配;父会话 whole-session grant 可覆盖语义一致的后代操作,普通单次批准不得复制为可重复消费的子 Agent grant。auto-review 必须运行在根会话并只使用根 transcript 的可信用户证据。
|
- 子 Agent 转发的 access facts 必须先由根 authority 的 `SessionRules` 匹配;父会话 whole-session grant 可覆盖语义一致的后代操作,普通单次批准不得复制为可重复消费的子 Agent grant。auto-review 必须运行在根会话并只使用根 transcript 的可信用户证据。
|
||||||
- 修改 Agent lifecycle、权限桥接、tool/extension scope、resume、nested agents 或 session persistence 时,必须同步包内 README、CHANGELOG、测试,并运行 `npm run lint`、`npm run typecheck`、`npm run test` 和 `npm run build`。
|
- 修改 Agent lifecycle、权限桥接、tool/extension scope、resume、nested agents 或 session persistence 时,必须同步包内 README、CHANGELOG、测试,并运行 `npm run lint`、`npm run typecheck`、`npm run test` 和 `npm run build`。
|
||||||
|
## `pi-notify` 开发约定
|
||||||
|
|
||||||
|
- 扩展入口是 `pi-notify/src/index.ts`,Kitty/macOS 后端位于 `pi-notify/src/terminal.ts`,测试保留在 `pi-notify/tests/`,来源记录是 `pi-notify/UPSTREAM.md`。
|
||||||
|
- 同步上游必须从明确 tag/commit 移植,保留作者、MIT 许可声明与来源;不得导入嵌套 `.git`、`node_modules` 或构建产物。
|
||||||
|
- Kitty OSC 99 必须保留 source-window focus、Session 间唯一 ID、Base64 payload 和 tmux passthrough;修改 visibility/action、事件生命周期、fallback、模板、隐私或命令时必须同步 README 和测试。
|
||||||
|
- 默认只在 TUI `agent_settled` 后通知,非 TUI 输出完整性、自动 retry/compaction/follow-up 去重、总耗时、短任务阈值和多窗口点击聚焦都属于回归边界。
|
||||||
|
- 根包必须通过 `file:./pi-notify` 提供依赖并直接加载源码,不得同时加载 npm `@smoose/pi-notify` 实现。
|
||||||
|
|
||||||
|
## `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`。
|
||||||
|
- 同步上游必须从明确 tag/commit 移植并保留 `LICENSE`、作者和来源;参考其他实现时记录明确 commit,不得导入嵌套 `.git`、`node_modules` 或构建产物。
|
||||||
|
- 运行时必须保持纯 `ssh2`,不得回退到系统 OpenSSH、`sshpass`、`SSH_ASKPASS` 或 ControlMaster;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 必须可运行。
|
||||||
|
- SSH vault 使用同目录独立随机 key 和 AES-256-GCM 整体加密,目录/文件在 POSIX 上必须保持 `700` / `600`;该设计只防止误看或单独泄漏密文,不防同一用户读取 key。密码、私钥 passphrase、私钥内容、vault key 与解密明文不得进入命令参数、日志、Pi session、权限证据或明文临时文件。
|
||||||
|
- 已导入主机必须固定 SHA256 Host Key;不匹配时 fail closed。私钥只保存路径,不复制内容;文件工具使用 SFTP,写入优先临时文件与原子 rename,远端断线不得自动重放命令。
|
||||||
|
- `ssh_connect` 是唯一运行时连接入口,只接受 vault 中已导入的 host ID 与可选绝对/`~/` remote cwd,并必须默认 `ask` 后进入 AutoReview;它必须声明 `sequential` execution mode 且支持取消,Agent 必须作为独立步骤调用并等待成功后才能发出依赖连接的其他 `ssh_*` 调用,避免连接建立或替换与远端操作重叠。未知 host ID 只可有界列出已导入 ID,不得泄露 endpoint 或凭据。不得注册 `/ssh`、`--ssh`、session resume 自动重连或把用户 `!` 命令切换到远端。用户未在直接请求中明确服务器和具体远端任务时不得推断、替换或连接主机;新连接替换旧连接,会话结束自动断开。
|
||||||
|
- `ssh_find` / `ssh_grep` 必须在已授权工具调用内部按 `fd/fdfind → git ls-files → find` 与 `rg → git grep → find+grep` 顺序检测服务器现有能力,不安装或上传远端二进制;搜索目标必须作为绝对参数与执行 cwd 分离,搜索进程始终从当前已确认 `remoteCwd` 启动,单文件 `ssh_grep` 目标不得被当作目录执行 `cd`。输入必须 shell-safe,结果数量/单行/总捕获必须有界并显式报告 backend 与 truncated;30 秒超时必须报告解析后的 root 并提示先缩小范围。RTK 不得处理这两个搜索工具的结果。
|
||||||
|
- `ssh_cd`、`ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 的远端路径不能按本机路径执行 `path` / `external_directory` gate;必须通过公共 `PermissionsService` 注册显式 extractor 关闭默认 `input.path` 推断,并保持六个工具的默认策略为 `ask`。权限预览必须依据远端 `remoteCwd` / `remoteHome` 解析相对路径与 `~/`,同时显示请求值和解析后的绝对远端路径。
|
||||||
|
- SSH transport 在会话中持久,但不得维护隐藏的交互式 Shell/PTY 状态;`ssh_bash` 每次调用必须从连接当前 `remoteCwd` 启动独立的非交互 Bash,必须忽略 Pi 本地 Bash factory 的 cwd 与 `PI_*` 会话环境。跨调用的工作区切换只能通过受复核且声明 `sequential` execution mode 的 `ssh_cd` 显式验证并更新连接状态;Agent 必须把它作为独立步骤调用并等待成功后,才可发出依赖新目录的远端工具调用。普通命令中的 `cd` 只可作为当次命令的临时目录变化,`cd`、`export`、alias 或函数不得被伪装为持久状态。
|
||||||
|
- 远端 HOME/cwd 探测与 `ssh_cd` 验证必须使用可取消、输出有界、随机标记 framing 的固定命令,只接受唯一绝对 POSIX 路径;banner、畸形/多行输出、超限、超时或取消时不得更新状态。独立 exec channel 最多并发 4 路,排队调用必须可立即取消;共享 SFTP 操作继续串行。
|
||||||
|
- `ssh_bash` 必须在权威权限配置的 `shellTools` 中映射 `commandArgument: "command"` 并设置 `decisionFloor: "ask"`,复用 Bash 命令拆分、硬拒绝、风险 `ask` 和 authorizer chain;全局 floor 必须在项目配置字段级合并时保留,不得新增第二个并行 `tool_call` 审批层。
|
||||||
|
- RTK 只把 `ssh_bash` 当作 Bash 输出别名执行 ANSI 清理、测试/构建/Git/Lint 聚合与兜底截断;不得对远端命令启用 RTK rewrite,也不得处理 `ssh_read`、`ssh_find` 或 `ssh_grep` 返回。
|
||||||
|
- SSH 工具的权限预览必须包含连接请求或当前远端的 host id、target、port、remote cwd 与有界操作摘要;连接预览只解密本地 vault 以提取非秘密目标字段,绝不包含凭据,远端 shell 的完整命令继续由 Bash payload 单独提供。权限服务缺失或注册失败时必须 fail-safe,不得转为无提示自动允许。
|
||||||
|
- `pi-ssh` 只用于从本地项目显式操作服务器,不得自动探测、读取或向系统提示词注入远端 `AGENTS.md`、`CLAUDE.md` 或其他项目说明;远端内容只能由明确且已授权的 `ssh_*` 工具调用获取。
|
||||||
|
- 修改 SSH transport、vault、导入、权限集成或加载入口后至少运行 `pi-ssh` 包内 `npm test`、根脚本语法与 mock 配置测试、`pi-permission-system` 的 `npm run typecheck` / `npm run test` / `npm run build`、根扩展联合加载和实际 packed tarball 隔离安装验证。未经明确要求不得连接真实服务器或把真实密码写入测试。
|
||||||
|
- 根加载顺序必须保持 `pi-permission-auto-review` → `pi-permission-system` → `pi-ssh` → `pi-tool-search`,确保 authorizer 先注册、权限服务先发布、SSH 桥接随后安装且 Tool Search 最后收集完整工具目录。
|
||||||
|
|
||||||
|
|
||||||
|
## `pi-ask-user` 开发约定
|
||||||
|
|
||||||
|
- 扩展入口是 `pi-ask-user/index.ts`,schema、归一化、TUI 与工具注册位于 `pi-ask-user/src/`,纯 helper 测试保留在 `pi-ask-user/test/`。
|
||||||
|
- 工具名必须保持 `ask_user_question`,并由 Tool Search 的独立 `user-interaction` 组按需加载;根加载顺序必须位于 `pi-tool-search` 之前。
|
||||||
|
- 参数必须有界并在运行时复核 question id、题型、选项和值的有效性;TUI 必须顺序执行、监听 abort、按 width 失效缓存,并把外层 `Focusable` 状态传播给内嵌 Editor。
|
||||||
|
- 成功结果的 `details` 必须保持 `version`、`cancelled: false` 与非空 `answers`,每个 answer 必须提供原始 `question` 和用户 `answer`;取消结果不得成为权限授权证据。修改结果结构时同步验证 `pi-permission-auto-review` transcript 解析。
|
||||||
|
- 同步上游示例时从明确 commit 移植并保留 `LICENSE`、`UPSTREAM.md` 与本地整合差异;不得恢复 `/qna`、重复的单题工具或多个扩展入口。
|
||||||
|
- 修改后至少运行包内 `npm test` / `npm run check`、Tool Search 的 typecheck/test/build、AutoReview transcript 测试、根扩展联合加载和 packed tarball 隔离安装验证。
|
||||||
|
|
||||||
|
|
||||||
## `pi-tool-search` 开发约定
|
## `pi-tool-search` 开发约定
|
||||||
@@ -187,12 +242,14 @@
|
|||||||
|
|
||||||
组合包依赖或加载入口变化时,至少验证根 `npm install` 幂等、锁文件有效、MCP adapter 唯一所有者不变量测试,以及全部扩展可在隔离的临时 Pi agent 目录加载。权限配置变化时使用仓库内固定快照的 `pi-permission-system` schema 校验,并验证包装入口部署后的文件与仓库源配置一致。
|
组合包依赖或加载入口变化时,至少验证根 `npm install` 幂等、锁文件有效、MCP adapter 唯一所有者不变量测试,以及全部扩展可在隔离的临时 Pi agent 目录加载。权限配置变化时使用仓库内固定快照的 `pi-permission-system` schema 校验,并验证包装入口部署后的文件与仓库源配置一致。
|
||||||
|
|
||||||
安装、升级、卸载或搜索配置脚本变化时,至少运行 `sh -n install.sh`、`sh -n update.sh`、`sh -n uninstall.sh`、`sh -n search_config.sh` 和 ShellCheck,并核对脚本仍具有可执行权限、仍包含在根 `package.json` 的 `files` 中、README 描述与实际流程一致。搜索配置测试只能使用虚拟 key 和隔离 HOME,不得把真实 key 写入测试输出。涉及真实 `pi install`、`pi update`、`pi remove`、Homebrew、远程安装器、Git 克隆或真实用户终端配置的端到端验证属于外部写操作,未经明确要求不得执行;可以使用隔离的临时 HOME 和 mock 命令验证分支行为。
|
安装、升级、卸载、搜索或 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 描述与实际流程一致。搜索配置测试只能使用虚拟 key 和隔离 HOME;SSH 配置测试必须使用隔离 HOME、虚拟凭据与 mock transport,不得连接真实服务器或把真实密码写入测试输出。涉及真实 `pi install`、`pi update`、`pi remove`、Homebrew、远程安装器、Git 克隆、真实 SSH 连接或真实用户终端配置的端到端验证属于外部写操作,未经明确要求不得执行;可以使用隔离的临时 HOME 和 mock 命令验证分支行为。
|
||||||
|
|
||||||
Hippo 脚本变化必须额外用隔离 HOME/PATH 和 mock `pi`、`npm`、`hippo` 验证:安装缺失 CLI 但不调用 `hippo init`、相同版本不升级、不同版本精确升级、未知来源 CLI 不替换、卸载默认保留、明确确认后只卸载 npm 全局包且保留数据。不得在测试中执行真实全局 npm 写入或修改真实 `.hippo/`。
|
Hippo 脚本变化必须额外用隔离 HOME/PATH 和 mock `pi`、`npm`、`hippo` 验证:安装缺失 CLI 但不调用 `hippo init`、相同版本不升级、不同版本精确升级、未知来源 CLI 不替换、卸载默认保留、明确确认后只卸载 npm 全局包且保留数据。不得在测试中执行真实全局 npm 写入或修改真实 `.hippo/`。
|
||||||
|
|
||||||
在 `pi-permission-system/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm run build`。在 `pi-permission-auto-review/` 内至少运行同样三项;涉及权限集成时还要使用仓库内 `pi-permission-system` 验证 authorizer 注册、`allow` / `deny` / `defer` 与 delegation envelope。
|
在 `pi-permission-system/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm run build`。在 `pi-permission-auto-review/` 内至少运行同样三项;涉及权限集成时还要使用仓库内 `pi-permission-system` 验证 authorizer 注册、`allow` / `deny` / `defer` 与 delegation envelope。
|
||||||
|
|
||||||
|
在 `pi-notify/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm pack --dry-run --json`;涉及加载入口或 lifecycle 时还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。
|
||||||
|
|
||||||
在 `pi-tool-search/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm run build`;涉及加载入口或 active-tool 行为时还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。
|
在 `pi-tool-search/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm run build`;涉及加载入口或 active-tool 行为时还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,14 @@
|
|||||||
- `hippo-memory-pi/`:从官方 `hippo-memory v1.33.0` 源码导入的 Pi Extension,提供项目记忆注入、错误捕获和 sleep consolidation。
|
- `hippo-memory-pi/`:从官方 `hippo-memory v1.33.0` 源码导入的 Pi Extension,提供项目记忆注入、错误捕获和 sleep consolidation。
|
||||||
- `@ogulcancelik/pi-codex-compaction@0.1.3`:为 `openai-codex` 提供原生远程压缩。
|
- `@ogulcancelik/pi-codex-compaction@0.1.3`:为 `openai-codex` 提供原生远程压缩。
|
||||||
- 本仓库维护的 `pi-minimal-footer`:基于 `@ogulcancelik/pi-minimal-footer@0.1.10`,用紧凑的上下文仪表和订阅用量条替换默认 footer,并在 Codex Fast 模式开启时显示 `Fast on`。
|
- 本仓库维护的 `pi-minimal-footer`:基于 `@ogulcancelik/pi-minimal-footer@0.1.10`,用紧凑的上下文仪表和订阅用量条替换默认 footer,并在 Codex Fast 模式开启时显示 `Fast on`。
|
||||||
|
- 本仓库维护的 `pi-notify`:基于 `@smoose/pi-notify@0.1.1`,使用 Kitty OSC 99 在 Pi 完全 settled 后通知,并支持点击精确聚焦来源 Kitty 窗口。
|
||||||
- `pi-condense@2.9.1`:把已完成的工具调用批次总结为可恢复的短摘要,并通过 `context_tree_query` 按需取回原始输出;组合包首次加载时默认开启。
|
- `pi-condense@2.9.1`:把已完成的工具调用批次总结为可恢复的短摘要,并通过 `context_tree_query` 按需取回原始输出;组合包首次加载时默认开启。
|
||||||
|
- 本仓库维护的 `pi-ask-user`:提供模型主动调用的 `ask_user_question`,用一个顺序 TUI 同时支持单题、多题、选择题、文本题、自定义回答与提交前复核。
|
||||||
- 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,以最多 3 个动态组的 LRU 策略按组加载原始完整 schema。
|
- 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,以最多 3 个动态组的 LRU 策略按组加载原始完整 schema。
|
||||||
- 本仓库维护的 `pi-permission-auto-review`:作为 `pi-permission-system` authorizer,使用 Codex Guardian 风格策略自动复核 `ask` 请求。
|
- 本仓库维护的 `pi-permission-auto-review`:作为 `pi-permission-system` authorizer,使用 Codex Guardian 风格策略自动复核 `ask` 请求。
|
||||||
- 本仓库维护的 `pi-permission-system`:从 `@gotgenes/pi-permission-system@26.2.1` 源码导入,负责工具、路径、MCP、硬拒绝和兜底权限基线。
|
- 本仓库维护的 `pi-permission-system`:从 `@gotgenes/pi-permission-system@26.2.1` 源码导入,负责工具、路径、MCP、硬拒绝和兜底权限基线。
|
||||||
- 本仓库维护的 `pi-subagents`:从 `@tintinweb/pi-subagents@0.18.0` 源码导入并默认加载,提供隔离子 Agent、后台执行、steering、resume、嵌套代理与自定义 Agent;通过 mandatory permission wrapper 接入权限 child lifecycle、根 authority `ask` 转发和根 transcript auto-review 身份桥接,其 3 个编排工具由 Tool Search 的 `subagents` 组按需加载。
|
- 本仓库维护的 `pi-subagents`:从 `@tintinweb/pi-subagents@0.18.0` 源码导入并默认加载,提供隔离子 Agent、后台执行、steering、resume、嵌套代理与自定义 Agent;通过 mandatory permission wrapper 接入权限 child lifecycle、根 authority `ask` 转发和根 transcript auto-review 身份桥接,其 3 个编排工具由 Tool Search 的 `subagents` 组按需加载。
|
||||||
|
- 本仓库维护的 `pi-ssh`:通过纯 Node `ssh2` 持久连接、显式 `ssh_cd` 远端工作区切换、SFTP 与自适应有界搜索提供独立的远端工具;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接。
|
||||||
- `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,并按当前激活工具追加简短的工具与搜索路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。
|
- `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,并按当前激活工具追加简短的工具与搜索路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。
|
||||||
|
|
||||||
## 安装、升级与卸载
|
## 安装、升级与卸载
|
||||||
@@ -109,18 +112,23 @@ Hippo 官方 Pi Extension 存在于上游 Git 仓库的 `extensions/pi-extension
|
|||||||
`hippo-memory@1.33.0` npm tarball 中。本仓库从官方 `v1.33.0` /
|
`hippo-memory@1.33.0` npm tarball 中。本仓库从官方 `v1.33.0` /
|
||||||
`e928179a3b35e8fe5837878aed071d6025ced45c` 导入源码到 `hippo-memory-pi/` 并直接加载;
|
`e928179a3b35e8fe5837878aed071d6025ced45c` 导入源码到 `hippo-memory-pi/` 并直接加载;
|
||||||
匹配的 CLI 版本由 `config/hippo-memory-version` 声明。`install.sh` 会在 CLI 缺失时询问是否执行
|
匹配的 CLI 版本由 `config/hippo-memory-version` 声明。`install.sh` 会在 CLI 缺失时询问是否执行
|
||||||
`npm install -g hippo-memory@<固定版本>`,但不会选择项目或执行 `hippo init`。用户应进入需要记忆的
|
`npm install -g hippo-memory@<固定版本>`,但安装脚本不会选择或初始化项目。进入目标项目后可在 Pi 中执行
|
||||||
项目目录自行运行 `hippo init`。
|
`/plugin_init`:命令会显示当前目录并要求确认,以 `.codegraph/codegraph.db` 和 `.hippo/hippo.db` 为
|
||||||
|
初始化标志,只为缺失状态运行 `codegraph init` 与 `hippo init`;全部成功后通过 Pi 官方热重载重新连接 CodeGraph MCP 并重新触发 Hippo
|
||||||
|
session start。也可以继续在终端手动执行两个 CLI 的 `init` 命令。
|
||||||
|
|
||||||
默认配置不需要系统 `rtk` CLI。只有以后在 `/rtk` 中主动开启
|
默认配置不需要系统 `rtk` CLI。只有以后在 `/rtk` 中主动开启 RTK command rewriting 时,才需要另外安装 `rtk` 可执行文件。
|
||||||
`RTK command rewriting` 时,才需要另外安装 `rtk` 可执行文件。
|
|
||||||
|
|
||||||
CodeGraph 本体不由组合包安装。需要使用 CodeGraph 的机器应自行确保 `codegraph` 在
|
CodeGraph 本体不由组合包安装。需要使用 CodeGraph 的机器应自行确保 `codegraph` 在
|
||||||
启动 Pi 的 `PATH` 中,并在目标项目执行过 `codegraph init`。组合包不会创建或维护
|
启动 Pi 的 `PATH` 中。组合包只有在用户显式调用 `/plugin_init` 并确认当前项目后才会创建
|
||||||
`.codegraph/`,也不会修改 CodeGraph 的索引、更新或遥测设置。
|
`.codegraph/`;安装、升级和普通启动流程不会创建或维护索引,也不会修改 CodeGraph 的更新或遥测设置。
|
||||||
|
|
||||||
## 组合行为
|
## 组合行为
|
||||||
|
|
||||||
|
### 模型主动提问
|
||||||
|
|
||||||
|
`pi-ask-user/` 把 Pi 上游的单题与多题示例合并为唯一的 `ask_user_question` 工具;不提供 `/qna` 或其他用户命令。模型只有在继续任务确实缺少用户决定、偏好、确认或澄清时才应加载 `user-interaction` 组并调用它,不得重复询问直接消息中已有的信息。工具仅在交互式 TUI 中运行,最多一次提交 8 个问题,支持选择、自由文本、显式跳过、返回修改和最终复核;取消结果不会作为 AutoReview 的授权证据。
|
||||||
|
|
||||||
### 工具与搜索路由
|
### 工具与搜索路由
|
||||||
|
|
||||||
`extensions/tool-routing.ts` 不替换 Pi 默认系统提示词,而是在每轮开始前根据当前激活工具追加简短规则。代码结构、调用关系和待修改 symbol 优先使用 CodeGraph,定义、引用、类型和修改后诊断优先使用 LSP;仓库字面搜索先用 FFF `find` 缩小文件或目录范围,再在已收敛的路径中用 `grep`/`multi_grep` 获取行号。宽泛搜索命中大量结果、发生截断或达到上限时,应继续缩小路径、glob 或 pattern,而不是提高 limit 或输出全部结果。
|
`extensions/tool-routing.ts` 不替换 Pi 默认系统提示词,而是在每轮开始前根据当前激活工具追加简短规则。代码结构、调用关系和待修改 symbol 优先使用 CodeGraph,定义、引用、类型和修改后诊断优先使用 LSP;仓库字面搜索先用 FFF `find` 缩小文件或目录范围,再在已收敛的路径中用 `grep`/`multi_grep` 获取行号。宽泛搜索命中大量结果、发生截断或达到上限时,应继续缩小路径、glob 或 pattern,而不是提高 limit 或输出全部结果。
|
||||||
@@ -137,7 +145,7 @@ CodeGraph 本体不由组合包安装。需要使用 CodeGraph 的机器应自
|
|||||||
|
|
||||||
本地源码直接使用 `@earendil-works/pi-coding-agent`、当前 `typebox`、`ModelRegistry.complete()` 与 `setActiveTools()`,不安装、别名映射或加载旧 `@mariozechner` runtime,也不使用 provider payload 改写、代理执行或隐藏 `sendMessage` 循环。
|
本地源码直接使用 `@earendil-works/pi-coding-agent`、当前 `typebox`、`ModelRegistry.complete()` 与 `setActiveTools()`,不安装、别名映射或加载旧 `@mariozechner` runtime,也不使用 provider payload 改写、代理执行或隐藏 `sendMessage` 循环。
|
||||||
|
|
||||||
新 session 默认常驻 Pi 核心 `read`、`write`、`edit`、`bash`、`grep`、`find`,以及 `codegraph_explore`、`lsp_diagnostics` 和 `tool_search`。组合包在 `pi-tool-search/extensions/bundle-groups.ts` 中为自身暴露的工具预置权威分组,包括文件导航、CodeGraph/LSP、Tavily、Exa、Keenable、Context Mode 执行/知识库/观测/管理、Memory 查询/维护、Skill 和 MCP 管理。不可用的可选工具会自动从组中滤除,固定工具也不占动态组额度。
|
新 session 默认常驻 Pi 核心 `read`、`write`、`edit`、`bash`、`grep`、`find`,以及 `codegraph_explore`、`lsp_diagnostics` 和 `tool_search`。组合包在 `pi-tool-search/extensions/bundle-groups.ts` 中为自身暴露的工具预置权威分组,包括本地文件导航、SSH 远端文件/命令、用户交互、CodeGraph/LSP、Tavily、Exa、Keenable、Context Mode 执行/知识库/观测/管理、Memory 查询/维护、Skill 和 MCP 管理。不可用的可选工具会自动从组中滤除,固定工具也不占动态组额度。
|
||||||
|
|
||||||
标准组合包的全部隐藏工具都能命中预置目录,因此首次使用不调用模型、不生成用户缓存,也不把完整隐藏 schema 发送给 provider。只有用户另外安装了未识别工具时,才先为新增工具提供确定性分组,并可在第一次 `tool_search` 时使用当前已认证模型补充目录;模型结果必须保留组合包预置分组,否则直接拒绝。有效增强缓存以 `0600` 写到 agent 目录的 `tool-search/catalog-v1.json`,调用用量计入工具结果。用户 `groupOverrides` 的优先级高于预置目录;`/tool-search-rebuild` 会立即恢复预置目录,只有仍存在额外工具时才可能在下次搜索惰性增强。
|
标准组合包的全部隐藏工具都能命中预置目录,因此首次使用不调用模型、不生成用户缓存,也不把完整隐藏 schema 发送给 provider。只有用户另外安装了未识别工具时,才先为新增工具提供确定性分组,并可在第一次 `tool_search` 时使用当前已认证模型补充目录;模型结果必须保留组合包预置分组,否则直接拒绝。有效增强缓存以 `0600` 写到 agent 目录的 `tool-search/catalog-v1.json`,调用用量计入工具结果。用户 `groupOverrides` 的优先级高于预置目录;`/tool-search-rebuild` 会立即恢复预置目录,只有仍存在额外工具时才可能在下次搜索惰性增强。
|
||||||
|
|
||||||
@@ -188,6 +196,20 @@ Context Mode 直接加载 npm 包内置的 Pi adapter 和 skills,不需要额
|
|||||||
FFF 仍负责精确字面搜索;RTK 继续压缩未走 Context Mode 的 Bash、build、test、lint
|
FFF 仍负责精确字面搜索;RTK 继续压缩未走 Context Mode 的 Bash、build、test、lint
|
||||||
和 Git 输出。Context Mode 不替换这两个扩展的现有配置。
|
和 Git 输出。Context Mode 不替换这两个扩展的现有配置。
|
||||||
|
|
||||||
|
### SSH 服务器配置
|
||||||
|
|
||||||
|
`pi-ssh` 运行时使用纯 `ssh2`,不依赖系统 `ssh` 或 `sshpass`。首次使用前显式导入需要的 OpenSSH alias:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./ssh_config.sh import packaging-server
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本只在导入阶段调用 `ssh -G` 解析用户选中的 alias,随后交互选择 Key 或密码认证、显示并确认服务器 Host Key、测试连接,再把完整配置写入 `${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/pi-ssh/hosts.enc`。邻接的随机 `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`。
|
||||||
|
|
||||||
|
远端文件搜索使用独立的 `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;也可一次无交互配置:
|
运行 `./search_config.sh` 可用隐藏输入依次配置 Tavily、Exa 和 Keenable;也可一次无交互配置:
|
||||||
@@ -224,6 +246,14 @@ codegraph --version
|
|||||||
codegraph status
|
codegraph status
|
||||||
```
|
```
|
||||||
|
|
||||||
|
在当前项目需要同时初始化 CodeGraph 和 Hippo 时,可直接执行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/plugin_init
|
||||||
|
```
|
||||||
|
|
||||||
|
该命令拒绝文件系统根目录和用户主目录,先确认两个 CLI 均可用,再依次初始化缺失数据库状态。任一步失败都不会热重载,也不会回滚此前已成功完成的初始化;两者已初始化时仍可用该命令快速热重载扩展。
|
||||||
|
|
||||||
### 权限基线自动部署
|
### 权限基线自动部署
|
||||||
|
|
||||||
`pi-permission-system/` 从上游 `pi-permission-system-v26.2.1` tag 导入,初始源码快照为 `ec4fdb11343dc94f7185b113e559a4cf9f8dc035`。根包通过本地 `file:` 依赖提供其运行时依赖,并由包装入口直接加载仓库源码,不再加载 npm 包中的实现。
|
`pi-permission-system/` 从上游 `pi-permission-system-v26.2.1` tag 导入,初始源码快照为 `ec4fdb11343dc94f7185b113e559a4cf9f8dc035`。根包通过本地 `file:` 依赖提供其运行时依赖,并由包装入口直接加载仓库源码,不再加载 npm 包中的实现。
|
||||||
@@ -297,6 +327,19 @@ Hippo CLI:从升级后的组合包读取固定版本,先通过 npm 确认版
|
|||||||
`uninstall.sh` 移除组合包后询问是否一并执行 `npm uninstall -g hippo-memory`,默认保留;无论选择什么,
|
`uninstall.sh` 移除组合包后询问是否一并执行 `npm uninstall -g hippo-memory`,默认保留;无论选择什么,
|
||||||
`.hippo/` 和用户记忆数据都不会被脚本删除。
|
`.hippo/` 和用户记忆数据都不会被脚本删除。
|
||||||
|
|
||||||
|
### Kitty 完成通知
|
||||||
|
|
||||||
|
`pi-notify/` 直接加载本地维护源码。它只在交互式 TUI 中监听 `agent_settled`,因此自动重试、自动
|
||||||
|
compaction 和排队 follow-up 完成前不会提前通知。Kitty 后端使用 Base64 OSC 99、每个 Pi Session
|
||||||
|
独立的稳定通知 ID、显式 `a=focus`,并默认设置 `o=unfocused`:来源 Kitty window/pane 有键盘
|
||||||
|
焦点时不打扰,切到其他 Kitty 窗口、tab 或 pane 后才通知,点击通知由 Kitty 返回原始来源。
|
||||||
|
|
||||||
|
默认忽略不足 3 秒的短任务。`/notify on|off|test|status` 可控制当前 Session;持久默认通过
|
||||||
|
`PI_NOTIFY_ENABLED`、`PI_NOTIFY_MIN_SECONDS`、`PI_NOTIFY_VISIBILITY`、`PI_NOTIFY_ACTION`、
|
||||||
|
`PI_NOTIFY_MESSAGE_SOURCE` 等环境变量配置。`PI_NOTIFY_MESSAGE_SOURCE=none` 可避免把回复摘要发送到
|
||||||
|
macOS 通知中心。Kitty 位于 tmux 内时需要 `set -g allow-passthrough all` 并重启 tmux server。
|
||||||
|
完整配置和上游差异见 [`pi-notify/README.md`](pi-notify/README.md)。
|
||||||
|
|
||||||
### Codex 原生远程压缩
|
### Codex 原生远程压缩
|
||||||
|
|
||||||
远程压缩只对 `openai-codex/openai-codex-responses` 生效,使用上游默认配置:在 turn boundary
|
远程压缩只对 `openai-codex/openai-codex-responses` 生效,使用上游默认配置:在 turn boundary
|
||||||
|
|||||||
@@ -7,11 +7,24 @@
|
|||||||
"authorizerChain": [
|
"authorizerChain": [
|
||||||
"auto-review"
|
"auto-review"
|
||||||
],
|
],
|
||||||
|
"shellTools": {
|
||||||
|
"ssh_bash": {
|
||||||
|
"commandArgument": "command",
|
||||||
|
"decisionFloor": "ask"
|
||||||
|
}
|
||||||
|
},
|
||||||
"permission": {
|
"permission": {
|
||||||
"*": "allow",
|
"*": "allow",
|
||||||
"grep": "allow",
|
"grep": "allow",
|
||||||
"find": "allow",
|
"find": "allow",
|
||||||
"multi_grep": "allow",
|
"multi_grep": "allow",
|
||||||
|
"ssh_connect": "ask",
|
||||||
|
"ssh_cd": "ask",
|
||||||
|
"ssh_read": "ask",
|
||||||
|
"ssh_write": "ask",
|
||||||
|
"ssh_edit": "ask",
|
||||||
|
"ssh_find": "ask",
|
||||||
|
"ssh_grep": "ask",
|
||||||
"external_directory": {
|
"external_directory": {
|
||||||
"*": "ask"
|
"*": "ask"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { stat } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join, parse, resolve } from "node:path";
|
||||||
|
|
||||||
|
interface ExecResult {
|
||||||
|
code: number;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginInitExecutor = (
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options: { cwd: string },
|
||||||
|
) => Promise<ExecResult>;
|
||||||
|
|
||||||
|
interface InitTarget {
|
||||||
|
name: "CodeGraph" | "Hippo";
|
||||||
|
command: "codegraph" | "hippo";
|
||||||
|
database: string;
|
||||||
|
initArgs(cwd: string): string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const INIT_TARGETS: InitTarget[] = [
|
||||||
|
{
|
||||||
|
name: "CodeGraph",
|
||||||
|
command: "codegraph",
|
||||||
|
database: join(".codegraph", "codegraph.db"),
|
||||||
|
initArgs: (cwd) => ["init", cwd],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Hippo",
|
||||||
|
command: "hippo",
|
||||||
|
database: join(".hippo", "hippo.db"),
|
||||||
|
initArgs: () => ["init"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function isFile(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return (await stat(path)).isFile();
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePluginInitCwd(cwd: string): string {
|
||||||
|
const projectRoot = resolve(cwd);
|
||||||
|
if (projectRoot === parse(projectRoot).root) {
|
||||||
|
throw new Error("拒绝在文件系统根目录初始化项目插件。");
|
||||||
|
}
|
||||||
|
if (projectRoot === resolve(homedir())) {
|
||||||
|
throw new Error("拒绝直接在用户主目录初始化项目插件;请先进入具体项目目录。");
|
||||||
|
}
|
||||||
|
return projectRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findMissingPluginInitializers(cwd: string): Promise<InitTarget[]> {
|
||||||
|
const projectRoot = validatePluginInitCwd(cwd);
|
||||||
|
const states = await Promise.all(
|
||||||
|
INIT_TARGETS.map(async (target) => ({
|
||||||
|
target,
|
||||||
|
initialized: await isFile(join(projectRoot, target.database)),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return states.filter(({ initialized }) => !initialized).map(({ target }) => target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactFailure(result: ExecResult): string {
|
||||||
|
const output = (result.stderr.trim() || result.stdout.trim()).replace(/\s+/gu, " ");
|
||||||
|
return output ? output.slice(0, 400) : `退出码 ${result.code}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeChecked(
|
||||||
|
exec: PluginInitExecutor,
|
||||||
|
target: InitTarget,
|
||||||
|
args: string[],
|
||||||
|
cwd: string,
|
||||||
|
phase: "检测" | "初始化",
|
||||||
|
): Promise<void> {
|
||||||
|
let result: ExecResult;
|
||||||
|
try {
|
||||||
|
result = await exec(target.command, args, { cwd });
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`${target.name} ${phase}失败:${detail}`, { cause: error });
|
||||||
|
}
|
||||||
|
if (result.code !== 0) {
|
||||||
|
throw new Error(`${target.name} ${phase}失败:${compactFailure(result)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initializeMissingPlugins(
|
||||||
|
cwd: string,
|
||||||
|
targets: InitTarget[],
|
||||||
|
exec: PluginInitExecutor,
|
||||||
|
onStart?: (target: InitTarget) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const projectRoot = validatePluginInitCwd(cwd);
|
||||||
|
|
||||||
|
// Preflight both CLIs before creating either project's state.
|
||||||
|
for (const target of INIT_TARGETS) {
|
||||||
|
await executeChecked(exec, target, ["--version"], projectRoot, "检测");
|
||||||
|
}
|
||||||
|
const completed: InitTarget[] = [];
|
||||||
|
for (const target of targets) {
|
||||||
|
onStart?.(target);
|
||||||
|
try {
|
||||||
|
await executeChecked(exec, target, target.initArgs(projectRoot), projectRoot, "初始化");
|
||||||
|
completed.push(target);
|
||||||
|
} catch (error) {
|
||||||
|
if (completed.length === 0) throw error;
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(
|
||||||
|
`${detail};已完成的 ${completed.map(({ name }) => name).join("、")} 初始化会保留,不会自动回滚。`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function pluginInitExtension(pi: ExtensionAPI): void {
|
||||||
|
pi.registerCommand("plugin_init", {
|
||||||
|
description: "Initialize CodeGraph and Hippo for the current project, then hot-reload Pi",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
if (!ctx.hasUI) return;
|
||||||
|
|
||||||
|
let projectRoot: string;
|
||||||
|
let missing: InitTarget[];
|
||||||
|
try {
|
||||||
|
projectRoot = validatePluginInitCwd(ctx.cwd);
|
||||||
|
missing = await findMissingPluginInitializers(projectRoot);
|
||||||
|
} catch (error) {
|
||||||
|
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = missing.length > 0 ? missing.map(({ name }) => name).join("、") : "无(均已初始化)";
|
||||||
|
const confirmed = await ctx.ui.confirm(
|
||||||
|
"初始化项目插件",
|
||||||
|
`项目:${projectRoot}\n待初始化:${pending}\n\n完成后将热重载 Pi 扩展。是否继续?`,
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await initializeMissingPlugins(
|
||||||
|
projectRoot,
|
||||||
|
missing,
|
||||||
|
(command, args, options) => pi.exec(command, args, options),
|
||||||
|
(target) => ctx.ui.notify(`正在初始化 ${target.name}…`, "info"),
|
||||||
|
);
|
||||||
|
ctx.ui.notify(
|
||||||
|
missing.length > 0 ? "项目插件初始化完成,正在热重载 Pi…" : "项目插件均已初始化,正在热重载 Pi…",
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
await ctx.reload();
|
||||||
|
} catch (error) {
|
||||||
|
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -44,6 +44,24 @@ export function buildToolRoutingSection(selectedTools: SelectedTools): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasTool(selectedTools, "ssh_connect")) {
|
||||||
|
rules.push(
|
||||||
|
"- Use ssh_connect only when the user explicitly names an imported host as part of a concrete remote task. Call it as a separate step and wait for success before calling other ssh_* tools; never infer or substitute a different host.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasTool(selectedTools, "ssh_cd")) {
|
||||||
|
rules.push(
|
||||||
|
"- Treat ssh_cd as a reviewed persistent workspace transition: call it as a separate step and wait for success before issuing ssh_bash or relative ssh_read/ssh_write/ssh_edit/ssh_find/ssh_grep calls that depend on the new remote cwd. Use cd inside ssh_bash only for an intentionally temporary, command-local directory change.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasTool(selectedTools, "ssh_find") || hasTool(selectedTools, "ssh_grep")) {
|
||||||
|
rules.push(
|
||||||
|
"- For remote SSH searches, use ssh_find to narrow remote file paths before ssh_grep searches file contents. Keep path and limit bounded; when a result says truncated, narrow the path or pattern instead of increasing the limit. Do not run find, fd, grep, or rg through ssh_bash.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const tavilyTools = ["tavily_web_search", "tavily_web_fetch"];
|
const tavilyTools = ["tavily_web_search", "tavily_web_fetch"];
|
||||||
const exaTools = ["exa_web_search", "exa_web_fetch", "exa_web_search_advanced"];
|
const exaTools = ["exa_web_search", "exa_web_fetch", "exa_web_search_advanced"];
|
||||||
const keenableTools = ["keenable_search", "keenable_fetch"];
|
const keenableTools = ["keenable_search", "keenable_fetch"];
|
||||||
|
|||||||
Generated
+148
@@ -14,15 +14,20 @@
|
|||||||
"@keenable/pi-search": "0.1.2",
|
"@keenable/pi-search": "0.1.2",
|
||||||
"@ogulcancelik/pi-codex-compaction": "0.1.3",
|
"@ogulcancelik/pi-codex-compaction": "0.1.3",
|
||||||
"@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer",
|
"@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer",
|
||||||
|
"@smoose/pi-notify": "file:./pi-notify",
|
||||||
"@tavily/pi-extension": "0.1.2",
|
"@tavily/pi-extension": "0.1.2",
|
||||||
"@tintinweb/pi-subagents": "file:./pi-subagents",
|
"@tintinweb/pi-subagents": "file:./pi-subagents",
|
||||||
"context-mode": "1.0.169",
|
"context-mode": "1.0.169",
|
||||||
|
"jiti": "2.7.0",
|
||||||
|
"pi-ask-user": "file:./pi-ask-user",
|
||||||
"pi-condense": "2.9.1",
|
"pi-condense": "2.9.1",
|
||||||
"pi-context-view": "0.4.2",
|
"pi-context-view": "0.4.2",
|
||||||
"pi-hashline-edit": "0.8.3",
|
"pi-hashline-edit": "0.8.3",
|
||||||
"pi-lsp": "0.1.7",
|
"pi-lsp": "0.1.7",
|
||||||
"pi-mcp-adapter": "2.26.0",
|
"pi-mcp-adapter": "2.26.0",
|
||||||
|
"pi-ssh": "file:./pi-ssh",
|
||||||
"pi-tool-search": "file:./pi-tool-search",
|
"pi-tool-search": "file:./pi-tool-search",
|
||||||
|
"ssh2": "1.17.0",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-language-server": "5.3.0",
|
"typescript-language-server": "5.3.0",
|
||||||
"zod": "4.4.3"
|
"zod": "4.4.3"
|
||||||
@@ -4553,6 +4558,10 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@smoose/pi-notify": {
|
||||||
|
"resolved": "pi-notify",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -5021,6 +5030,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1": {
|
||||||
|
"version": "0.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||||
|
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": "~2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/assertion-error": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@@ -5107,6 +5125,15 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcrypt-pbkdf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tweetnacl": "^0.14.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/better-sqlite3": {
|
"node_modules/better-sqlite3": {
|
||||||
"version": "12.11.1",
|
"version": "12.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
|
||||||
@@ -5260,6 +5287,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/buildcheck": {
|
||||||
|
"version": "0.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
|
||||||
|
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bundle-name": {
|
"node_modules/bundle-name": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
|
||||||
@@ -5458,6 +5494,20 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cpu-features": {
|
||||||
|
"version": "0.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
|
||||||
|
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"buildcheck": "~0.0.6",
|
||||||
|
"nan": "^2.19.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/croner": {
|
"node_modules/croner": {
|
||||||
"version": "10.0.1",
|
"version": "10.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz",
|
||||||
@@ -6558,6 +6608,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jiti": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jose": {
|
"node_modules/jose": {
|
||||||
"version": "6.2.9",
|
"version": "6.2.9",
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz",
|
||||||
@@ -7101,6 +7160,13 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nan": {
|
||||||
|
"version": "2.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
|
||||||
|
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.18",
|
"version": "3.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||||
@@ -7366,6 +7432,10 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pi-ask-user": {
|
||||||
|
"resolved": "pi-ask-user",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/pi-condense": {
|
"node_modules/pi-condense": {
|
||||||
"version": "2.9.1",
|
"version": "2.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/pi-condense/-/pi-condense-2.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/pi-condense/-/pi-condense-2.9.1.tgz",
|
||||||
@@ -7476,6 +7546,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pi-ssh": {
|
||||||
|
"resolved": "pi-ssh",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/pi-tool-search": {
|
"node_modules/pi-tool-search": {
|
||||||
"resolved": "pi-tool-search",
|
"resolved": "pi-tool-search",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -8193,6 +8267,23 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ssh2": {
|
||||||
|
"version": "1.17.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
|
||||||
|
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"dependencies": {
|
||||||
|
"asn1": "^0.2.6",
|
||||||
|
"bcrypt-pbkdf": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.16.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"cpu-features": "~0.0.10",
|
||||||
|
"nan": "^2.23.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stackback": {
|
"node_modules/stackback": {
|
||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
@@ -8444,6 +8535,12 @@
|
|||||||
"integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==",
|
"integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tweetnacl": {
|
||||||
|
"version": "0.14.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||||
|
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||||
@@ -8908,6 +9005,29 @@
|
|||||||
"zod": "^3.25.28 || ^4"
|
"zod": "^3.25.28 || ^4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"pi-ask-user": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@earendil-works/pi-coding-agent": "*",
|
||||||
|
"@earendil-works/pi-tui": "*",
|
||||||
|
"typebox": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@earendil-works/pi-coding-agent": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@earendil-works/pi-tui": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typebox": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"pi-extension-codex-fast-mode": {
|
"pi-extension-codex-fast-mode": {
|
||||||
"name": "@firstpick/pi-extension-codex-fast-mode",
|
"name": "@firstpick/pi-extension-codex-fast-mode",
|
||||||
"version": "0.1.1",
|
"version": "0.1.1",
|
||||||
@@ -8927,6 +9047,22 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"pi-notify": {
|
||||||
|
"name": "@smoose/pi-notify",
|
||||||
|
"version": "0.1.1-my-pi.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@earendil-works/pi-coding-agent": "0.84.2",
|
||||||
|
"@types/node": "24.10.13",
|
||||||
|
"typescript": "6.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@earendil-works/pi-coding-agent": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"pi-permission-system": {
|
"pi-permission-system": {
|
||||||
"name": "@gotgenes/pi-permission-system",
|
"name": "@gotgenes/pi-permission-system",
|
||||||
"version": "26.2.1",
|
"version": "26.2.1",
|
||||||
@@ -8953,6 +9089,18 @@
|
|||||||
"@earendil-works/pi-tui": "*"
|
"@earendil-works/pi-tui": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"pi-ssh": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jiti": "2.7.0",
|
||||||
|
"ssh2": "1.17.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@earendil-works/pi-coding-agent": "*",
|
||||||
|
"@gotgenes/pi-permission-system": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"pi-subagents": {
|
"pi-subagents": {
|
||||||
"name": "@tintinweb/pi-subagents",
|
"name": "@tintinweb/pi-subagents",
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
|
|||||||
@@ -11,10 +11,13 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"extensions",
|
"extensions",
|
||||||
"hippo-memory-pi",
|
"hippo-memory-pi",
|
||||||
|
"pi-ask-user",
|
||||||
"pi-extension-codex-fast-mode",
|
"pi-extension-codex-fast-mode",
|
||||||
"pi-rtk-optimizer",
|
"pi-rtk-optimizer",
|
||||||
"pi-minimal-footer",
|
"pi-minimal-footer",
|
||||||
"pi-subagents",
|
"pi-subagents",
|
||||||
|
"pi-notify",
|
||||||
|
"pi-ssh",
|
||||||
"pi-tool-search/extensions",
|
"pi-tool-search/extensions",
|
||||||
"pi-tool-search/docs",
|
"pi-tool-search/docs",
|
||||||
"pi-tool-search/package.json",
|
"pi-tool-search/package.json",
|
||||||
@@ -36,6 +39,7 @@
|
|||||||
"update.sh",
|
"update.sh",
|
||||||
"uninstall.sh",
|
"uninstall.sh",
|
||||||
"search_config.sh",
|
"search_config.sh",
|
||||||
|
"ssh_config.sh",
|
||||||
"README.md",
|
"README.md",
|
||||||
"AGENTS.md"
|
"AGENTS.md"
|
||||||
],
|
],
|
||||||
@@ -46,6 +50,7 @@
|
|||||||
"./extensions/tavily-override.ts",
|
"./extensions/tavily-override.ts",
|
||||||
"./node_modules/@keenable/pi-search/src/index.ts",
|
"./node_modules/@keenable/pi-search/src/index.ts",
|
||||||
"./extensions/mcp.ts",
|
"./extensions/mcp.ts",
|
||||||
|
"./extensions/plugin-init.ts",
|
||||||
"./pi-rtk-optimizer/index.ts",
|
"./pi-rtk-optimizer/index.ts",
|
||||||
"./extensions/hashline.ts",
|
"./extensions/hashline.ts",
|
||||||
"./node_modules/context-mode/build/adapters/pi/extension.js",
|
"./node_modules/context-mode/build/adapters/pi/extension.js",
|
||||||
@@ -55,10 +60,13 @@
|
|||||||
"./hippo-memory-pi/index.ts",
|
"./hippo-memory-pi/index.ts",
|
||||||
"./node_modules/@ogulcancelik/pi-codex-compaction/index.ts",
|
"./node_modules/@ogulcancelik/pi-codex-compaction/index.ts",
|
||||||
"./pi-minimal-footer/index.ts",
|
"./pi-minimal-footer/index.ts",
|
||||||
|
"./pi-notify/src/index.ts",
|
||||||
"./extensions/condense.ts",
|
"./extensions/condense.ts",
|
||||||
"./pi-permission-auto-review/index.ts",
|
"./pi-permission-auto-review/index.ts",
|
||||||
"./extensions/permission-system.ts",
|
"./extensions/permission-system.ts",
|
||||||
"./extensions/subagents.ts",
|
"./extensions/subagents.ts",
|
||||||
|
"./pi-ssh/index.ts",
|
||||||
|
"./pi-ask-user/index.ts",
|
||||||
"./extensions/tool-routing.ts",
|
"./extensions/tool-routing.ts",
|
||||||
"./pi-tool-search/extensions/index.ts"
|
"./pi-tool-search/extensions/index.ts"
|
||||||
],
|
],
|
||||||
@@ -74,15 +82,20 @@
|
|||||||
"@keenable/pi-search": "0.1.2",
|
"@keenable/pi-search": "0.1.2",
|
||||||
"@ogulcancelik/pi-codex-compaction": "0.1.3",
|
"@ogulcancelik/pi-codex-compaction": "0.1.3",
|
||||||
"@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer",
|
"@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer",
|
||||||
|
"@smoose/pi-notify": "file:./pi-notify",
|
||||||
"@tavily/pi-extension": "0.1.2",
|
"@tavily/pi-extension": "0.1.2",
|
||||||
"@tintinweb/pi-subagents": "file:./pi-subagents",
|
"@tintinweb/pi-subagents": "file:./pi-subagents",
|
||||||
"context-mode": "1.0.169",
|
"context-mode": "1.0.169",
|
||||||
|
"jiti": "2.7.0",
|
||||||
"pi-condense": "2.9.1",
|
"pi-condense": "2.9.1",
|
||||||
"pi-context-view": "0.4.2",
|
"pi-context-view": "0.4.2",
|
||||||
"pi-hashline-edit": "0.8.3",
|
"pi-hashline-edit": "0.8.3",
|
||||||
"pi-lsp": "0.1.7",
|
"pi-lsp": "0.1.7",
|
||||||
"pi-mcp-adapter": "2.26.0",
|
"pi-mcp-adapter": "2.26.0",
|
||||||
|
"pi-ask-user": "file:./pi-ask-user",
|
||||||
|
"pi-ssh": "file:./pi-ssh",
|
||||||
"pi-tool-search": "file:./pi-tool-search",
|
"pi-tool-search": "file:./pi-tool-search",
|
||||||
|
"ssh2": "1.17.0",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-language-server": "5.3.0",
|
"typescript-language-server": "5.3.0",
|
||||||
"zod": "4.4.3"
|
"zod": "4.4.3"
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Mario Zechner
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# pi-ask-user
|
||||||
|
|
||||||
|
A locally maintained Pi extension that consolidates the upstream `question.ts` and `questionnaire.ts` examples into one model-initiated tool. It intentionally does not include the `/qna` command.
|
||||||
|
|
||||||
|
## Tool
|
||||||
|
|
||||||
|
The extension registers `ask_user_question`. The model can present one to eight questions in one sequential TUI interaction:
|
||||||
|
|
||||||
|
- `select`: one to eight stable `{ value, label, description? }` choices, with an optional free-form choice;
|
||||||
|
- `text`: a free-form Editor answer;
|
||||||
|
- `required: false`: lets the user explicitly skip the question;
|
||||||
|
- multiple questions: progress tabs plus a final review page.
|
||||||
|
|
||||||
|
The model should call the tool only when a missing decision, preference, or clarification is needed to continue. It should not repeat questions already answered in the user's direct messages.
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
- `↑` / `↓`: move through choices;
|
||||||
|
- `Enter`: select, edit, or submit;
|
||||||
|
- `Tab`, `Shift+Tab`, `←`, `→`: move between questions and review;
|
||||||
|
- `Esc`: leave text entry, or cancel from a question/review page.
|
||||||
|
|
||||||
|
The outer component propagates focus to the embedded Editor for IME cursor positioning.
|
||||||
|
|
||||||
|
## Result contract
|
||||||
|
|
||||||
|
Successful results include readable text and structured `details`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"cancelled": false,
|
||||||
|
"questions": [],
|
||||||
|
"answers": [
|
||||||
|
{
|
||||||
|
"id": "language",
|
||||||
|
"question": "Which language should be used?",
|
||||||
|
"type": "select",
|
||||||
|
"answer": "TypeScript",
|
||||||
|
"value": "typescript",
|
||||||
|
"label": "TypeScript",
|
||||||
|
"custom": false,
|
||||||
|
"skipped": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The tool name and the `question` / `answer` fields intentionally match `pi-permission-auto-review`'s trusted structured user-interaction envelope. Cancelled results are never treated as authorization evidence.
|
||||||
|
|
||||||
|
The tool requires Pi's interactive TUI mode. RPC, JSON, and print modes receive a tool error rather than an invented answer.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm test
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
See `UPSTREAM.md` for the imported reference snapshot and local differences.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Upstream sources
|
||||||
|
|
||||||
|
`pi-ask-user` is maintained directly in this repository. Its initial UI and tool design was adapted from the following MIT-licensed Pi examples:
|
||||||
|
|
||||||
|
- <https://github.com/earendil-works/pi/blob/dcd461925db2edf69a43c8135db1180d418afd54/packages/coding-agent/examples/extensions/question.ts>
|
||||||
|
- <https://github.com/earendil-works/pi/blob/dcd461925db2edf69a43c8135db1180d418afd54/packages/coding-agent/examples/extensions/questionnaire.ts>
|
||||||
|
|
||||||
|
Initial reference snapshot: `dcd461925db2edf69a43c8135db1180d418afd54` (`main`, inspected 2026-08-24).
|
||||||
|
|
||||||
|
The upstream `qna.ts` command is intentionally not included: this package only supports model-initiated questions. Local changes consolidate single and multi-question flows into one tool, add text questions, validation, bounded schemas, cancellation/abort handling, width-aware rendering, and IME focus propagation.
|
||||||
|
|
||||||
|
The copied MIT license and original copyright notice are retained in `LICENSE`.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
|
import { registerAskUserTool } from "./src/tool.ts";
|
||||||
|
|
||||||
|
export default function askUserExtension(pi: ExtensionAPI): void {
|
||||||
|
registerAskUserTool(pi);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { formatAnswers, normalizeQuestions, orderedAnswers } from "./src/normalize.ts";
|
||||||
|
export type {
|
||||||
|
AskUserResult,
|
||||||
|
NormalizedQuestion,
|
||||||
|
QuestionInput,
|
||||||
|
QuestionOptionInput,
|
||||||
|
QuestionType,
|
||||||
|
UserAnswer,
|
||||||
|
} from "./src/types.ts";
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "pi-ask-user",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "A unified interactive question tool for Pi agents.",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "./index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./index.ts"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"index.ts",
|
||||||
|
"src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE",
|
||||||
|
"UPSTREAM.md"
|
||||||
|
],
|
||||||
|
"pi": {
|
||||||
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test test/*.test.ts",
|
||||||
|
"check": "node --experimental-strip-types --check index.ts && node --test test/*.test.ts"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@earendil-works/pi-coding-agent": "*",
|
||||||
|
"@earendil-works/pi-tui": "*",
|
||||||
|
"typebox": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@earendil-works/pi-coding-agent": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@earendil-works/pi-tui": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typebox": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.19.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { NormalizedQuestion, QuestionInput, QuestionOptionInput, UserAnswer } from "./types.ts";
|
||||||
|
|
||||||
|
export const MAX_QUESTIONS = 8;
|
||||||
|
export const MAX_OPTIONS = 8;
|
||||||
|
|
||||||
|
function requiredText(value: unknown, field: string, maxLength: number): string {
|
||||||
|
if (typeof value !== "string") throw new Error(`${field} must be a string`);
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (!normalized) throw new Error(`${field} must not be empty`);
|
||||||
|
if (normalized.length > maxLength) throw new Error(`${field} must be at most ${maxLength} characters`);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOption(option: QuestionOptionInput, questionId: string, index: number): QuestionOptionInput {
|
||||||
|
return {
|
||||||
|
value: requiredText(option?.value, `questions[${questionId}].options[${index}].value`, 128),
|
||||||
|
label: requiredText(option?.label, `questions[${questionId}].options[${index}].label`, 160),
|
||||||
|
...(option?.description === undefined
|
||||||
|
? {}
|
||||||
|
: { description: requiredText(option.description, `questions[${questionId}].options[${index}].description`, 500) }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeQuestions(input: readonly QuestionInput[]): NormalizedQuestion[] {
|
||||||
|
if (!Array.isArray(input) || input.length === 0) throw new Error("questions must contain at least one question");
|
||||||
|
if (input.length > MAX_QUESTIONS) throw new Error(`questions must contain at most ${MAX_QUESTIONS} questions`);
|
||||||
|
|
||||||
|
const ids = new Set<string>();
|
||||||
|
return input.map((question, index) => {
|
||||||
|
if (question === null || typeof question !== "object" || Array.isArray(question)) {
|
||||||
|
throw new Error(`questions[${index}] must be an object`);
|
||||||
|
}
|
||||||
|
const id = requiredText(question?.id, `questions[${index}].id`, 64);
|
||||||
|
if (!/^[A-Za-z][A-Za-z0-9_-]*$/u.test(id)) {
|
||||||
|
throw new Error(`questions[${index}].id must start with a letter and contain only letters, numbers, _ or -`);
|
||||||
|
}
|
||||||
|
if (ids.has(id)) throw new Error(`duplicate question id: ${id}`);
|
||||||
|
ids.add(id);
|
||||||
|
|
||||||
|
const prompt = requiredText(question?.prompt, `questions[${index}].prompt`, 1_000);
|
||||||
|
const label = question.label === undefined
|
||||||
|
? `Q${index + 1}`
|
||||||
|
: requiredText(question.label, `questions[${index}].label`, 48);
|
||||||
|
if (question.type !== "select" && question.type !== "text") {
|
||||||
|
throw new Error(`questions[${index}].type must be select or text`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (question.allowCustom !== undefined && typeof question.allowCustom !== "boolean") {
|
||||||
|
throw new Error(`questions[${index}].allowCustom must be a boolean`);
|
||||||
|
}
|
||||||
|
if (question.required !== undefined && typeof question.required !== "boolean") {
|
||||||
|
throw new Error(`questions[${index}].required must be a boolean`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawOptions = question.options ?? [];
|
||||||
|
if (!Array.isArray(rawOptions)) throw new Error(`questions[${index}].options must be an array`);
|
||||||
|
if (rawOptions.length > MAX_OPTIONS) {
|
||||||
|
throw new Error(`questions[${index}].options must contain at most ${MAX_OPTIONS} options`);
|
||||||
|
}
|
||||||
|
if (question.type === "text" && rawOptions.length > 0) {
|
||||||
|
throw new Error(`text question ${id} must not define options`);
|
||||||
|
}
|
||||||
|
if (question.type === "select" && rawOptions.length === 0) {
|
||||||
|
throw new Error(`select question ${id} must define at least one option`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = rawOptions.map((option, optionIndex) => normalizeOption(option, id, optionIndex));
|
||||||
|
const values = new Set<string>();
|
||||||
|
for (const option of options) {
|
||||||
|
if (values.has(option.value)) throw new Error(`duplicate option value ${option.value} in question ${id}`);
|
||||||
|
values.add(option.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
prompt,
|
||||||
|
type: question.type,
|
||||||
|
options,
|
||||||
|
allowCustom: question.type === "select" && question.allowCustom !== false,
|
||||||
|
required: question.required !== false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderedAnswers(questions: readonly NormalizedQuestion[], answers: ReadonlyMap<string, UserAnswer>): UserAnswer[] {
|
||||||
|
return questions.flatMap((question) => {
|
||||||
|
const answer = answers.get(question.id);
|
||||||
|
return answer ? [answer] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAnswers(answers: readonly UserAnswer[]): string {
|
||||||
|
return answers
|
||||||
|
.map((answer) => {
|
||||||
|
if (answer.skipped) return `${answer.id}: skipped`;
|
||||||
|
if (answer.type === "text" || answer.custom) return `${answer.id}: user wrote: ${answer.answer}`;
|
||||||
|
return `${answer.id}: user selected: ${answer.label} (value: ${answer.value})`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Type } from "typebox";
|
||||||
|
|
||||||
|
import { MAX_OPTIONS, MAX_QUESTIONS } from "./normalize.ts";
|
||||||
|
|
||||||
|
const OptionSchema = Type.Object({
|
||||||
|
value: Type.String({ minLength: 1, maxLength: 128, description: "Stable machine-readable value returned when selected" }),
|
||||||
|
label: Type.String({ minLength: 1, maxLength: 160, description: "User-visible option label" }),
|
||||||
|
description: Type.Optional(Type.String({ minLength: 1, maxLength: 500, description: "Optional explanation shown below the label" })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const QuestionSchema = Type.Object({
|
||||||
|
id: Type.String({
|
||||||
|
minLength: 1,
|
||||||
|
maxLength: 64,
|
||||||
|
pattern: "^[A-Za-z][A-Za-z0-9_-]*$",
|
||||||
|
description: "Unique machine-readable question identifier",
|
||||||
|
}),
|
||||||
|
label: Type.Optional(Type.String({ minLength: 1, maxLength: 48, description: "Short progress label; defaults to Q1, Q2, ..." })),
|
||||||
|
prompt: Type.String({ minLength: 1, maxLength: 1_000, description: "Full question shown to the user" }),
|
||||||
|
type: Type.String({ enum: ["select", "text"], description: "select for choices; text for a free-form answer" }),
|
||||||
|
options: Type.Optional(Type.Array(OptionSchema, {
|
||||||
|
maxItems: MAX_OPTIONS,
|
||||||
|
description: "Required and non-empty for select questions; omit for text questions",
|
||||||
|
})),
|
||||||
|
allowCustom: Type.Optional(Type.Boolean({ description: "For select questions, append a free-form answer choice (default: true)" })),
|
||||||
|
required: Type.Optional(Type.Boolean({ description: "Whether the question must be answered (default: true); optional questions can be skipped" })),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AskUserParamsSchema = Type.Object({
|
||||||
|
questions: Type.Array(QuestionSchema, {
|
||||||
|
minItems: 1,
|
||||||
|
maxItems: MAX_QUESTIONS,
|
||||||
|
description: "One or more questions to present in a single sequential interaction",
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { Text } from "@earendil-works/pi-tui";
|
||||||
|
|
||||||
|
import { formatAnswers, normalizeQuestions } from "./normalize.ts";
|
||||||
|
import { AskUserParamsSchema } from "./schema.ts";
|
||||||
|
import type { AskUserResult, QuestionInput } from "./types.ts";
|
||||||
|
import { AskUserView } from "./view.ts";
|
||||||
|
|
||||||
|
function fallbackText(result: { content: Array<{ type: string; text?: string }> }): string {
|
||||||
|
return result.content.find((item) => item.type === "text")?.text ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAskUserTool(pi: ExtensionAPI): void {
|
||||||
|
pi.registerTool({
|
||||||
|
name: "ask_user_question",
|
||||||
|
label: "Ask User",
|
||||||
|
description:
|
||||||
|
"Ask the user one or more structured questions in an interactive TUI when their input is genuinely needed to continue. Supports choices, free-form text, optional custom answers, review, and cancellation. Do not ask for information already stated by the user.",
|
||||||
|
promptSnippet: "Use ask_user_question to pause and collect missing user decisions instead of guessing",
|
||||||
|
parameters: AskUserParamsSchema,
|
||||||
|
executionMode: "sequential",
|
||||||
|
|
||||||
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||||||
|
const questions = normalizeQuestions(params.questions as QuestionInput[]);
|
||||||
|
if (ctx.mode !== "tui") throw new Error("ask_user_question requires interactive TUI mode");
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
const result: AskUserResult = { version: 1, cancelled: true, questions, answers: [] };
|
||||||
|
return { content: [{ type: "text", text: "User interaction was cancelled" }], details: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ctx.ui.custom<AskUserResult>((tui, theme, _keybindings, done) =>
|
||||||
|
new AskUserView(tui, theme, questions, done, signal),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.cancelled) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "User cancelled the questions" }],
|
||||||
|
details: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: formatAnswers(result.answers) }],
|
||||||
|
details: result,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
renderCall(args, theme) {
|
||||||
|
const questions = Array.isArray(args.questions) ? args.questions as Array<{ id?: unknown; label?: unknown }> : [];
|
||||||
|
const labels = questions
|
||||||
|
.map((question) => typeof question.label === "string" ? question.label : typeof question.id === "string" ? question.id : "question")
|
||||||
|
.join(", ");
|
||||||
|
let text = theme.fg("toolTitle", theme.bold("ask user "));
|
||||||
|
text += theme.fg("muted", `${questions.length} question${questions.length === 1 ? "" : "s"}`);
|
||||||
|
if (labels) text += theme.fg("dim", ` (${labels})`);
|
||||||
|
return new Text(text, 0, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
renderResult(result, _options, theme) {
|
||||||
|
const details = result.details as AskUserResult | undefined;
|
||||||
|
if (!details) return new Text(fallbackText(result), 0, 0);
|
||||||
|
if (details.cancelled) return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
||||||
|
const lines = details.answers.map((answer) => {
|
||||||
|
const value = answer.skipped
|
||||||
|
? theme.fg("muted", "Skipped")
|
||||||
|
: `${answer.custom || answer.type === "text" ? theme.fg("muted", "(wrote) ") : ""}${theme.fg("accent", answer.label)}`;
|
||||||
|
return `${theme.fg("success", "✓ ")}${theme.fg("accent", answer.id)}: ${value}`;
|
||||||
|
});
|
||||||
|
return new Text(lines.join("\n"), 0, 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export type QuestionType = "select" | "text";
|
||||||
|
|
||||||
|
export interface QuestionOptionInput {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestionInput {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
prompt: string;
|
||||||
|
type: QuestionType;
|
||||||
|
options?: QuestionOptionInput[];
|
||||||
|
allowCustom?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedQuestion {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
prompt: string;
|
||||||
|
type: QuestionType;
|
||||||
|
options: QuestionOptionInput[];
|
||||||
|
allowCustom: boolean;
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `question` and `answer` intentionally match pi-permission-auto-review's
|
||||||
|
* trusted user-interaction envelope for ask_user_question.
|
||||||
|
*/
|
||||||
|
export interface UserAnswer {
|
||||||
|
id: string;
|
||||||
|
question: string;
|
||||||
|
type: QuestionType;
|
||||||
|
answer: string;
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
custom: boolean;
|
||||||
|
skipped: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AskUserResult {
|
||||||
|
version: 1;
|
||||||
|
cancelled: boolean;
|
||||||
|
questions: NormalizedQuestion[];
|
||||||
|
answers: UserAnswer[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
||||||
|
import {
|
||||||
|
Editor,
|
||||||
|
type Component,
|
||||||
|
type EditorTheme,
|
||||||
|
type Focusable,
|
||||||
|
Key,
|
||||||
|
matchesKey,
|
||||||
|
type TUI,
|
||||||
|
visibleWidth,
|
||||||
|
wrapTextWithAnsi,
|
||||||
|
} from "@earendil-works/pi-tui";
|
||||||
|
|
||||||
|
import { orderedAnswers } from "./normalize.ts";
|
||||||
|
import type { AskUserResult, NormalizedQuestion, QuestionOptionInput, UserAnswer } from "./types.ts";
|
||||||
|
|
||||||
|
type InputKind = "text" | "custom";
|
||||||
|
type Done = (result: AskUserResult) => void;
|
||||||
|
type RenderOption = QuestionOptionInput & { kind: "option" | "custom" | "skip" };
|
||||||
|
|
||||||
|
export class AskUserView implements Component, Focusable {
|
||||||
|
private readonly editor: Editor;
|
||||||
|
private readonly answers = new Map<string, UserAnswer>();
|
||||||
|
private readonly abortHandler: () => void;
|
||||||
|
private currentPage = 0;
|
||||||
|
private optionIndex = 0;
|
||||||
|
private inputKind: InputKind | null = null;
|
||||||
|
private validationMessage: string | undefined;
|
||||||
|
private cachedWidth: number | undefined;
|
||||||
|
private cachedLines: string[] | undefined;
|
||||||
|
private completed = false;
|
||||||
|
private _focused = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly tui: TUI,
|
||||||
|
private readonly theme: Theme,
|
||||||
|
private readonly questions: NormalizedQuestion[],
|
||||||
|
private readonly done: Done,
|
||||||
|
private readonly signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
const editorTheme: EditorTheme = {
|
||||||
|
borderColor: (text) => theme.fg("accent", text),
|
||||||
|
selectList: {
|
||||||
|
selectedPrefix: (text) => theme.fg("accent", text),
|
||||||
|
selectedText: (text) => theme.fg("accent", text),
|
||||||
|
description: (text) => theme.fg("muted", text),
|
||||||
|
scrollInfo: (text) => theme.fg("dim", text),
|
||||||
|
noMatch: (text) => theme.fg("warning", text),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.editor = new Editor(tui, editorTheme);
|
||||||
|
this.editor.onSubmit = (value) => this.submitEditor(value);
|
||||||
|
this.abortHandler = () => this.finish(true);
|
||||||
|
signal?.addEventListener("abort", this.abortHandler, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
get focused(): boolean {
|
||||||
|
return this._focused;
|
||||||
|
}
|
||||||
|
|
||||||
|
set focused(value: boolean) {
|
||||||
|
this._focused = value;
|
||||||
|
this.syncEditorFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncEditorFocus(): void {
|
||||||
|
this.editor.focused = this._focused && this.inputKind !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private refresh(): void {
|
||||||
|
this.cachedWidth = undefined;
|
||||||
|
this.cachedLines = undefined;
|
||||||
|
this.syncEditorFocus();
|
||||||
|
this.tui.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
private currentQuestion(): NormalizedQuestion | undefined {
|
||||||
|
return this.questions[this.currentPage];
|
||||||
|
}
|
||||||
|
|
||||||
|
private currentOptions(): RenderOption[] {
|
||||||
|
const question = this.currentQuestion();
|
||||||
|
if (!question || question.type !== "select") return [];
|
||||||
|
const options: RenderOption[] = question.options.map((option) => ({ ...option, kind: "option" }));
|
||||||
|
if (question.allowCustom) options.push({ value: "", label: "Type something.", kind: "custom" });
|
||||||
|
if (!question.required) options.push({ value: "", label: "Skip this question.", kind: "skip" });
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
private allAnswered(): boolean {
|
||||||
|
return this.questions.every((question) => this.answers.has(question.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCurrentPage(page: number): void {
|
||||||
|
this.currentPage = page;
|
||||||
|
this.inputKind = null;
|
||||||
|
this.validationMessage = undefined;
|
||||||
|
this.editor.setText("");
|
||||||
|
this.optionIndex = 0;
|
||||||
|
|
||||||
|
const question = this.currentQuestion();
|
||||||
|
const answer = question ? this.answers.get(question.id) : undefined;
|
||||||
|
if (question?.type === "select" && answer) {
|
||||||
|
const options = this.currentOptions();
|
||||||
|
const selected = options.findIndex((option) =>
|
||||||
|
answer.skipped ? option.kind === "skip" : answer.custom ? option.kind === "custom" : option.kind === "option" && option.value === answer.value,
|
||||||
|
);
|
||||||
|
this.optionIndex = Math.max(0, selected);
|
||||||
|
}
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private movePage(delta: number): void {
|
||||||
|
const pageCount = this.questions.length + 1;
|
||||||
|
this.setCurrentPage((this.currentPage + delta + pageCount) % pageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private enterEditor(kind: InputKind): void {
|
||||||
|
const question = this.currentQuestion();
|
||||||
|
if (!question) return;
|
||||||
|
const existing = this.answers.get(question.id);
|
||||||
|
this.inputKind = kind;
|
||||||
|
this.validationMessage = undefined;
|
||||||
|
this.editor.setText(existing && (question.type === "text" || existing.custom) ? existing.answer : "");
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveAnswer(question: NormalizedQuestion, answer: Omit<UserAnswer, "id" | "question" | "type">): void {
|
||||||
|
this.answers.set(question.id, {
|
||||||
|
id: question.id,
|
||||||
|
question: question.prompt,
|
||||||
|
type: question.type,
|
||||||
|
...answer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private submitEditor(value: string): void {
|
||||||
|
const question = this.currentQuestion();
|
||||||
|
if (!question || !this.inputKind) return;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed && (question.required || this.inputKind === "custom")) {
|
||||||
|
this.validationMessage = "An answer is required.";
|
||||||
|
this.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveAnswer(question, {
|
||||||
|
answer: trimmed,
|
||||||
|
value: trimmed,
|
||||||
|
label: trimmed,
|
||||||
|
custom: this.inputKind === "custom",
|
||||||
|
skipped: !trimmed,
|
||||||
|
});
|
||||||
|
this.inputKind = null;
|
||||||
|
this.editor.setText("");
|
||||||
|
this.advanceAfterAnswer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectCurrentOption(): void {
|
||||||
|
const question = this.currentQuestion();
|
||||||
|
const option = this.currentOptions()[this.optionIndex];
|
||||||
|
if (!question || !option) return;
|
||||||
|
if (option.kind === "custom") {
|
||||||
|
this.enterEditor("custom");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (option.kind === "skip") {
|
||||||
|
this.saveAnswer(question, { answer: "", value: "", label: "Skipped", custom: false, skipped: true });
|
||||||
|
} else {
|
||||||
|
this.saveAnswer(question, {
|
||||||
|
answer: option.label,
|
||||||
|
value: option.value,
|
||||||
|
label: option.label,
|
||||||
|
custom: false,
|
||||||
|
skipped: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.advanceAfterAnswer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private advanceAfterAnswer(): void {
|
||||||
|
if (this.questions.length === 1) {
|
||||||
|
this.finish(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setCurrentPage(this.currentPage < this.questions.length - 1 ? this.currentPage + 1 : this.questions.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private finish(cancelled: boolean): void {
|
||||||
|
if (this.completed) return;
|
||||||
|
this.completed = true;
|
||||||
|
this.signal?.removeEventListener("abort", this.abortHandler);
|
||||||
|
this.done({
|
||||||
|
version: 1,
|
||||||
|
cancelled,
|
||||||
|
questions: this.questions,
|
||||||
|
answers: orderedAnswers(this.questions, this.answers),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInput(data: string): void {
|
||||||
|
if (this.inputKind) {
|
||||||
|
if (matchesKey(data, Key.escape)) {
|
||||||
|
this.inputKind = null;
|
||||||
|
this.validationMessage = undefined;
|
||||||
|
this.editor.setText("");
|
||||||
|
this.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.editor.handleInput(data);
|
||||||
|
this.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesKey(data, Key.escape)) {
|
||||||
|
this.finish(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.questions.length > 1) {
|
||||||
|
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
||||||
|
this.movePage(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
||||||
|
this.movePage(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentPage === this.questions.length) {
|
||||||
|
if (matchesKey(data, Key.enter) && this.allAnswered()) this.finish(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const question = this.currentQuestion();
|
||||||
|
if (!question) return;
|
||||||
|
if (question.type === "text") {
|
||||||
|
if (!question.required && matchesKey(data, "s")) {
|
||||||
|
this.saveAnswer(question, { answer: "", value: "", label: "Skipped", custom: false, skipped: true });
|
||||||
|
this.advanceAfterAnswer();
|
||||||
|
} else if (matchesKey(data, Key.enter)) {
|
||||||
|
this.enterEditor("text");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = this.currentOptions();
|
||||||
|
if (matchesKey(data, Key.up)) {
|
||||||
|
this.optionIndex = Math.max(0, this.optionIndex - 1);
|
||||||
|
this.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matchesKey(data, Key.down)) {
|
||||||
|
this.optionIndex = Math.min(options.length - 1, this.optionIndex + 1);
|
||||||
|
this.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matchesKey(data, Key.enter)) this.selectCurrentOption();
|
||||||
|
}
|
||||||
|
|
||||||
|
render(width: number): string[] {
|
||||||
|
const renderWidth = Math.max(1, width);
|
||||||
|
if (this.cachedLines && this.cachedWidth === renderWidth) return this.cachedLines;
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
const question = this.currentQuestion();
|
||||||
|
const addWrapped = (text: string): void => {
|
||||||
|
lines.push(...wrapTextWithAnsi(text, renderWidth));
|
||||||
|
};
|
||||||
|
const addWrappedWithPrefix = (prefix: string, text: string): void => {
|
||||||
|
const prefixWidth = visibleWidth(prefix);
|
||||||
|
if (prefixWidth >= renderWidth) {
|
||||||
|
addWrapped(prefix + text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);
|
||||||
|
const continuation = " ".repeat(prefixWidth);
|
||||||
|
wrapped.forEach((line, index) => lines.push(`${index === 0 ? prefix : continuation}${line}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.push(this.theme.fg("accent", "─".repeat(renderWidth)));
|
||||||
|
|
||||||
|
if (this.questions.length > 1) {
|
||||||
|
const tabs = this.questions.map((item, index) => {
|
||||||
|
const answered = this.answers.has(item.id);
|
||||||
|
const text = ` ${answered ? "■" : "□"} ${item.label} `;
|
||||||
|
return index === this.currentPage
|
||||||
|
? this.theme.bg("selectedBg", this.theme.fg("text", text))
|
||||||
|
: this.theme.fg(answered ? "success" : "muted", text);
|
||||||
|
});
|
||||||
|
const reviewText = " ✓ Review ";
|
||||||
|
tabs.push(this.currentPage === this.questions.length
|
||||||
|
? this.theme.bg("selectedBg", this.theme.fg("text", reviewText))
|
||||||
|
: this.theme.fg(this.allAnswered() ? "success" : "dim", reviewText));
|
||||||
|
addWrappedWithPrefix(" ", tabs.join(" "));
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentPage === this.questions.length) {
|
||||||
|
addWrappedWithPrefix(" ", this.theme.fg("accent", this.theme.bold("Review answers")));
|
||||||
|
lines.push("");
|
||||||
|
for (const item of this.questions) {
|
||||||
|
const answer = this.answers.get(item.id);
|
||||||
|
const value = !answer ? this.theme.fg("warning", "Unanswered") : answer.skipped ? this.theme.fg("muted", "Skipped") : this.theme.fg("text", answer.label);
|
||||||
|
addWrappedWithPrefix(" ", `${this.theme.fg("muted", `${item.label}: `)}${value}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
addWrappedWithPrefix(" ", this.allAnswered()
|
||||||
|
? this.theme.fg("success", "Press Enter to submit")
|
||||||
|
: this.theme.fg("warning", "Answer or explicitly skip every question before submitting"));
|
||||||
|
} else if (question) {
|
||||||
|
addWrappedWithPrefix(" ", this.theme.fg("text", question.prompt));
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
if (question.type === "select") {
|
||||||
|
for (const [index, option] of this.currentOptions().entries()) {
|
||||||
|
const selected = index === this.optionIndex;
|
||||||
|
const prefix = selected ? this.theme.fg("accent", "> ") : " ";
|
||||||
|
addWrappedWithPrefix(prefix, this.theme.fg(selected ? "accent" : option.kind === "skip" ? "muted" : "text", `${index + 1}. ${option.label}`));
|
||||||
|
if (option.description) addWrappedWithPrefix(" ", this.theme.fg("muted", option.description));
|
||||||
|
}
|
||||||
|
} else if (!this.inputKind) {
|
||||||
|
const existing = this.answers.get(question.id);
|
||||||
|
if (existing) addWrappedWithPrefix(" ", `${this.theme.fg("muted", "Current answer: ")}${existing.skipped ? "Skipped" : existing.label}`);
|
||||||
|
const action = `${existing ? "Press Enter to edit" : "Press Enter to answer"}${question.required ? "" : " • s to skip"}`;
|
||||||
|
addWrappedWithPrefix(" ", this.theme.fg("accent", action));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.inputKind) {
|
||||||
|
if (this.inputKind === "custom") lines.push("");
|
||||||
|
addWrappedWithPrefix(" ", this.theme.fg("muted", "Your answer:"));
|
||||||
|
for (const line of this.editor.render(Math.max(1, renderWidth - 2))) lines.push(` ${line}`);
|
||||||
|
if (this.validationMessage) addWrappedWithPrefix(" ", this.theme.fg("warning", this.validationMessage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
const help = this.inputKind
|
||||||
|
? "Enter submit • Esc go back"
|
||||||
|
: this.currentPage === this.questions.length
|
||||||
|
? "Tab/←→ navigate • Enter submit • Esc cancel"
|
||||||
|
: question?.type === "text"
|
||||||
|
? `${question.required ? "Enter write" : "Enter write • s skip"} • Tab/←→ navigate • Esc cancel`
|
||||||
|
: "↑↓ select • Enter confirm • Tab/←→ navigate • Esc cancel";
|
||||||
|
addWrappedWithPrefix(" ", this.theme.fg("dim", this.questions.length === 1 ? help.replace(" • Tab/←→ navigate", "") : help));
|
||||||
|
lines.push(this.theme.fg("accent", "─".repeat(renderWidth)));
|
||||||
|
|
||||||
|
this.cachedWidth = renderWidth;
|
||||||
|
this.cachedLines = lines;
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate(): void {
|
||||||
|
this.cachedWidth = undefined;
|
||||||
|
this.cachedLines = undefined;
|
||||||
|
this.editor.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.signal?.removeEventListener("abort", this.abortHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { formatAnswers, normalizeQuestions, orderedAnswers } from "../src/normalize.ts";
|
||||||
|
import type { UserAnswer } from "../src/types.ts";
|
||||||
|
|
||||||
|
test("normalizes select and text questions with bounded defaults", () => {
|
||||||
|
const questions = normalizeQuestions([
|
||||||
|
{
|
||||||
|
id: "language",
|
||||||
|
label: " Language ",
|
||||||
|
prompt: " Choose a language ",
|
||||||
|
type: "select",
|
||||||
|
options: [{ value: "ts", label: " TypeScript " }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notes",
|
||||||
|
prompt: "Anything else?",
|
||||||
|
type: "text",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(questions[0], {
|
||||||
|
id: "language",
|
||||||
|
label: "Language",
|
||||||
|
prompt: "Choose a language",
|
||||||
|
type: "select",
|
||||||
|
options: [{ value: "ts", label: "TypeScript" }],
|
||||||
|
allowCustom: true,
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
assert.equal(questions[1]?.label, "Q2");
|
||||||
|
assert.equal(questions[1]?.allowCustom, false);
|
||||||
|
assert.equal(questions[1]?.required, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid ids, duplicate ids, invalid option combinations, and duplicate values", () => {
|
||||||
|
assert.throws(() => normalizeQuestions([{ id: "bad id", prompt: "Bad", type: "text" }]), /must start with a letter/);
|
||||||
|
assert.throws(() => normalizeQuestions([null as never]), /must be an object/);
|
||||||
|
assert.throws(() => normalizeQuestions([{ id: "bad_flag", prompt: "Bad", type: "text", required: "yes" as never }]), /must be a boolean/);
|
||||||
|
assert.throws(() => normalizeQuestions([
|
||||||
|
{ id: "same", prompt: "One", type: "text" },
|
||||||
|
{ id: "same", prompt: "Two", type: "text" },
|
||||||
|
]), /duplicate question id/);
|
||||||
|
assert.throws(() => normalizeQuestions([{ id: "pick", prompt: "Pick", type: "select" }]), /at least one option/);
|
||||||
|
assert.throws(() => normalizeQuestions([{ id: "text", prompt: "Write", type: "text", options: [{ value: "x", label: "X" }] }]), /must not define options/);
|
||||||
|
assert.throws(() => normalizeQuestions([{
|
||||||
|
id: "pick",
|
||||||
|
prompt: "Pick",
|
||||||
|
type: "select",
|
||||||
|
options: [{ value: "x", label: "X" }, { value: "x", label: "Again" }],
|
||||||
|
}]), /duplicate option value/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("orders and formats answers by question order", () => {
|
||||||
|
const questions = normalizeQuestions([
|
||||||
|
{ id: "first", prompt: "First?", type: "text" },
|
||||||
|
{ id: "second", prompt: "Second?", type: "select", options: [{ value: "yes", label: "Yes" }] },
|
||||||
|
{ id: "third", prompt: "Third?", type: "text", required: false },
|
||||||
|
]);
|
||||||
|
const answer = (id: string, overrides: Partial<UserAnswer>): UserAnswer => ({
|
||||||
|
id,
|
||||||
|
question: `${id}?`,
|
||||||
|
type: "text",
|
||||||
|
answer: id,
|
||||||
|
value: id,
|
||||||
|
label: id,
|
||||||
|
custom: false,
|
||||||
|
skipped: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
const answers = new Map<string, UserAnswer>([
|
||||||
|
["third", answer("third", { answer: "", value: "", label: "Skipped", skipped: true })],
|
||||||
|
["second", answer("second", { type: "select", answer: "Yes", value: "yes", label: "Yes" })],
|
||||||
|
["first", answer("first", { answer: "hello", value: "hello", label: "hello", custom: true })],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ordered = orderedAnswers(questions, answers);
|
||||||
|
assert.deepEqual(ordered.map((item) => item.id), ["first", "second", "third"]);
|
||||||
|
assert.equal(formatAnswers(ordered), [
|
||||||
|
"first: user wrote: hello",
|
||||||
|
"second: user selected: Yes (value: yes)",
|
||||||
|
"third: skipped",
|
||||||
|
].join("\n"));
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 smoose
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# pi-notify
|
||||||
|
|
||||||
|
Kitty-first completion notifications for Pi. This repository maintains a source import of
|
||||||
|
`@smoose/pi-notify@0.1.1` with local reliability and multi-window improvements.
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
- Waits for Pi's `agent_settled` event, so retries, automatic compaction, and queued follow-ups do not notify early.
|
||||||
|
- Uses Kitty OSC 99 with Base64 payloads, an ID unique to each Pi session, and explicit `a=focus`.
|
||||||
|
- Defaults to `o=unfocused`: no notification while the originating Kitty window/pane has keyboard focus; clicking a notification returns to that exact source window.
|
||||||
|
- Measures the entire run across automatic retries and ignores runs shorter than three seconds by default.
|
||||||
|
- Emits terminal sequences only in interactive TUI mode, never into JSON, print, or RPC output.
|
||||||
|
- Uses macOS AppleScript outside Kitty and a sanitized OSC 777 fallback outside macOS.
|
||||||
|
|
||||||
|
The root `my-pi` bundle loads this local source directly. For the unmodified upstream package, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi install npm:@smoose/pi-notify
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not separately install upstream when using the bundle, or notifications will be duplicated.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- `/notify on`: enable notifications for the current session.
|
||||||
|
- `/notify off`: disable notifications for the current session.
|
||||||
|
- `/notify test`: send a notification immediately; click it to verify exact Kitty-window focus.
|
||||||
|
- `/notify status`: show the active backend, visibility policy, and duration threshold.
|
||||||
|
|
||||||
|
Command overrides are session-local. Use environment variables for persistent defaults.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
- `PI_NOTIFY_ENABLED`: `1`/`true` or `0`/`false`; default enabled.
|
||||||
|
- `PI_NOTIFY_TITLE`: title template; default `π - {project}`.
|
||||||
|
- `PI_NOTIFY_BODY`: body template; default `{message}`.
|
||||||
|
- `PI_NOTIFY_MESSAGE_SOURCE`: `assistant`, `user`, or privacy mode `none`; default `assistant`.
|
||||||
|
- `PI_NOTIFY_MESSAGE_MAX`: maximum message characters before ellipsis; default `80`, maximum `500`.
|
||||||
|
- `PI_NOTIFY_MIN_SECONDS`: minimum complete run duration; default `3`, range `0`–`3600`.
|
||||||
|
- `PI_NOTIFY_VISIBILITY`: Kitty policy `unfocused`, `invisible`, or `always`; default `unfocused`.
|
||||||
|
- `PI_NOTIFY_ACTION`: Kitty click action `focus` or `none`; default `focus`.
|
||||||
|
- `PI_NOTIFY_SOUND_CMD`: optional shell command run after the notification. This is trusted local configuration and executes through a shell.
|
||||||
|
|
||||||
|
Visibility policies:
|
||||||
|
|
||||||
|
- `unfocused`: notify whenever the originating Kitty window/pane does not have keyboard focus.
|
||||||
|
- `invisible`: notify only when the originating window/pane is not visible.
|
||||||
|
- `always`: notify even while typing in the originating window.
|
||||||
|
|
||||||
|
Inside tmux, enable passthrough and restart the tmux server after changing the setting:
|
||||||
|
|
||||||
|
```tmux
|
||||||
|
set -g allow-passthrough all
|
||||||
|
```
|
||||||
|
|
||||||
|
The active tmux client type is checked so a stale `KITTY_WINDOW_ID` cannot force the Kitty backend for a non-Kitty client.
|
||||||
|
|
||||||
|
## Template variables
|
||||||
|
|
||||||
|
- `{project}`: current directory name.
|
||||||
|
- `{cwd}`: current working directory.
|
||||||
|
- `{model}`: active model as `provider/id`.
|
||||||
|
- `{model_short}`: active model ID.
|
||||||
|
- `{message}`: selected message, or `Ready for input` when source is `none` or text is unavailable.
|
||||||
|
- `{user_message}`: latest user text, truncated.
|
||||||
|
- `{assistant_message}`: latest settled assistant text, truncated.
|
||||||
|
- `{duration}` / `{duration_ms}`: complete duration including retries.
|
||||||
|
- `{status}`: `ready`, or `ready · N tool errors` when tools reported errors.
|
||||||
|
- `{status_icon}`: `✓` or `⚠`. This reports observed tool errors, not a definitive task-success verdict.
|
||||||
|
- `{tool_error_count}`: observed tool error count.
|
||||||
|
|
||||||
|
Example for multiple projects without exposing assistant text on the lock screen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PI_NOTIFY_TITLE='π {status_icon} · {project}'
|
||||||
|
export PI_NOTIFY_MESSAGE_SOURCE=none
|
||||||
|
export PI_NOTIFY_VISIBILITY=unfocused
|
||||||
|
```
|
||||||
|
|
||||||
|
Kitty must be allowed under **System Settings → Notifications**. Self-built or unsigned Kitty binaries may not be able to publish macOS notifications.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Upstream source
|
||||||
|
|
||||||
|
- Project: `@smoose/pi-notify`
|
||||||
|
- Repository: <https://github.com/smoosex/pi-notify>
|
||||||
|
- Imported npm version: `0.1.1`
|
||||||
|
- Imported commit: `3a3691ab690b4bc37a4412ab0dcbd35ef14adcbf`
|
||||||
|
- Commit date: 2026-06-02
|
||||||
|
- npm tarball and repository source were compared before import.
|
||||||
|
|
||||||
|
The source is maintained directly in this repository. It is not a submodule and does not retain an embedded `.git` directory or upstream build artifacts.
|
||||||
|
|
||||||
|
## Local changes
|
||||||
|
|
||||||
|
- Notify on `agent_settled` rather than `agent_end`, preserving elapsed time and the latest messages across retries.
|
||||||
|
- Restrict terminal output to TUI mode so OSC sequences cannot corrupt RPC, JSON, or print output.
|
||||||
|
- Give each Pi session a stable unique OSC 99 notification ID.
|
||||||
|
- Make Kitty click-to-focus explicit and default visibility to the source-window-aware `unfocused` policy.
|
||||||
|
- Add duration filtering, enable/privacy/action settings, status template fields, and `/notify` controls.
|
||||||
|
- Keep Base64 Kitty payloads, harden OSC metadata/fallback sanitization, and ignore detached-process spawn errors.
|
||||||
|
- Add lifecycle, multi-window metadata, fallback, parser, and configuration tests.
|
||||||
|
|
||||||
|
Upstream declares the project under the MIT license but did not include a `LICENSE` file at the imported commit or in the npm tarball. This directory adds the standard MIT text using the upstream author's GitHub identity and commit year so the declared license travels with the maintained source.
|
||||||
Generated
+2007
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "@smoose/pi-notify",
|
||||||
|
"version": "0.1.1-my-pi.1",
|
||||||
|
"description": "Kitty-aware completion notifications for Pi with exact-window focus and settled-agent delivery.",
|
||||||
|
"type": "module",
|
||||||
|
"keywords": [
|
||||||
|
"pi-package",
|
||||||
|
"pi-extension",
|
||||||
|
"notification"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"pi": {
|
||||||
|
"extensions": [
|
||||||
|
"./src/index.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src/",
|
||||||
|
"README.md",
|
||||||
|
"UPSTREAM.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/smoosex/pi-notify.git"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/smoosex/pi-notify",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/smoosex/pi-notify/issues"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@earendil-works/pi-coding-agent": "*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@earendil-works/pi-coding-agent": "0.84.2",
|
||||||
|
"@types/node": "24.10.13",
|
||||||
|
"typescript": "6.0.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"check": "npm run typecheck && npm run test"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.19.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { extractMessageText, truncateText } from "./messages.ts";
|
||||||
|
import {
|
||||||
|
parseEnabled,
|
||||||
|
parseKittyVisibility,
|
||||||
|
parseMessageMax,
|
||||||
|
parseMessageSource,
|
||||||
|
parseMinDurationMs,
|
||||||
|
parseNotificationAction,
|
||||||
|
} from "./parsers.ts";
|
||||||
|
import { renderTemplate } from "./template.ts";
|
||||||
|
import type { NotificationRuntimeValues, NotifyConfig } from "./types.ts";
|
||||||
|
|
||||||
|
const DEFAULT_TITLE = "π - {project}";
|
||||||
|
const DEFAULT_BODY = "{message}";
|
||||||
|
|
||||||
|
export function getConfig(
|
||||||
|
ctx: ExtensionContext | undefined,
|
||||||
|
startedAt: number | undefined,
|
||||||
|
messages: unknown[] | undefined,
|
||||||
|
runtime: NotificationRuntimeValues,
|
||||||
|
now = Date.now(),
|
||||||
|
): NotifyConfig {
|
||||||
|
const durationMs = startedAt === undefined ? 0 : Math.max(0, now - startedAt);
|
||||||
|
const messageMax = parseMessageMax(process.env.PI_NOTIFY_MESSAGE_MAX);
|
||||||
|
const userMessage = truncateText(extractMessageText(messages, "user"), messageMax);
|
||||||
|
const assistantMessage = truncateText(extractMessageText(messages, "assistant"), messageMax);
|
||||||
|
const messageSource = parseMessageSource(process.env.PI_NOTIFY_MESSAGE_SOURCE);
|
||||||
|
const selectedMessage = messageSource === "user" ? userMessage : messageSource === "assistant" ? assistantMessage : "";
|
||||||
|
const message = selectedMessage || "Ready for input";
|
||||||
|
const toolErrorCount = Math.max(0, runtime.toolErrorCount ?? 0);
|
||||||
|
const values = {
|
||||||
|
user_message: userMessage,
|
||||||
|
assistant_message: assistantMessage,
|
||||||
|
message,
|
||||||
|
status: toolErrorCount > 0 ? `ready · ${toolErrorCount} tool error${toolErrorCount === 1 ? "" : "s"}` : "ready",
|
||||||
|
status_icon: toolErrorCount > 0 ? "⚠" : "✓",
|
||||||
|
tool_error_count: String(toolErrorCount),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: parseEnabled(process.env.PI_NOTIFY_ENABLED),
|
||||||
|
title: renderTemplate(process.env.PI_NOTIFY_TITLE || DEFAULT_TITLE, ctx, durationMs, values),
|
||||||
|
body: renderTemplate(process.env.PI_NOTIFY_BODY || DEFAULT_BODY, ctx, durationMs, values),
|
||||||
|
durationMs,
|
||||||
|
minDurationMs: parseMinDurationMs(process.env.PI_NOTIFY_MIN_SECONDS),
|
||||||
|
notificationId: runtime.notificationId,
|
||||||
|
visibility: parseKittyVisibility(process.env.PI_NOTIFY_VISIBILITY),
|
||||||
|
action: parseNotificationAction(process.env.PI_NOTIFY_ACTION),
|
||||||
|
soundCommand: process.env.PI_NOTIFY_SOUND_CMD?.trim() || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldNotify(config: NotifyConfig, enabledOverride?: boolean): boolean {
|
||||||
|
return (enabledOverride ?? config.enabled) && config.durationMs >= config.minDurationMs;
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { getConfig, shouldNotify } from "./config.ts";
|
||||||
|
import { getNotificationBackend, sendNotification } from "./terminal.ts";
|
||||||
|
import type { NotifyConfig } from "./types.ts";
|
||||||
|
|
||||||
|
type ExtensionDependencies = {
|
||||||
|
now: () => number;
|
||||||
|
createNotificationId: () => string;
|
||||||
|
notify: (config: NotifyConfig) => void;
|
||||||
|
getBackend: typeof getNotificationBackend;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createPiNotifyExtension(dependencies: Partial<ExtensionDependencies> = {}) {
|
||||||
|
const now = dependencies.now ?? Date.now;
|
||||||
|
const createNotificationId = dependencies.createNotificationId ?? (() => `pi-${process.pid}-${randomUUID()}`);
|
||||||
|
const notify = dependencies.notify ?? sendNotification;
|
||||||
|
const getBackend = dependencies.getBackend ?? getNotificationBackend;
|
||||||
|
|
||||||
|
return function piNotify(pi: ExtensionAPI): void {
|
||||||
|
const notificationId = createNotificationId();
|
||||||
|
let startedAt: number | undefined;
|
||||||
|
let latestMessages: unknown[] | undefined;
|
||||||
|
let toolErrorCount = 0;
|
||||||
|
let enabledOverride: boolean | undefined;
|
||||||
|
|
||||||
|
const resetRun = (): void => {
|
||||||
|
startedAt = undefined;
|
||||||
|
latestMessages = undefined;
|
||||||
|
toolErrorCount = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
pi.on("agent_start", () => {
|
||||||
|
startedAt ??= now();
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("tool_execution_end", (event) => {
|
||||||
|
if (event.isError) toolErrorCount++;
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("agent_end", (event) => {
|
||||||
|
latestMessages = event.messages;
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("agent_settled", (_event, ctx) => {
|
||||||
|
try {
|
||||||
|
const config = getConfig(ctx, startedAt, latestMessages, { notificationId, toolErrorCount }, now());
|
||||||
|
if (ctx.mode === "tui" && shouldNotify(config, enabledOverride)) notify(config);
|
||||||
|
} finally {
|
||||||
|
resetRun();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("notify", {
|
||||||
|
description: "Control Kitty/macOS completion notifications: on, off, test, status",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const action = args.trim().toLowerCase() || "status";
|
||||||
|
|
||||||
|
if (action === "on" || action === "off") {
|
||||||
|
enabledOverride = action === "on";
|
||||||
|
ctx.ui.notify(`Completion notifications ${action}`, "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getConfig(ctx, now() - 1000, undefined, { notificationId, toolErrorCount: 0 }, now());
|
||||||
|
if (action === "test") {
|
||||||
|
if (ctx.mode !== "tui") {
|
||||||
|
ctx.ui.notify("System notifications are only emitted in interactive TUI mode", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notify({ ...config, enabled: true, minDurationMs: 0, body: "Kitty notification test — click to focus this window" });
|
||||||
|
ctx.ui.notify(`Test sent through ${getBackend()}`, "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "status") {
|
||||||
|
const enabled = enabledOverride ?? config.enabled;
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Notifications ${enabled ? "on" : "off"} · ${getBackend()} · ${config.visibility} · min ${config.minDurationMs / 1000}s`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.notify("Usage: /notify on|off|test|status", "warning");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createPiNotifyExtension();
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export function extractMessageText(messages: unknown[] | undefined, role: "user" | "assistant"): string {
|
||||||
|
if (!messages) return "";
|
||||||
|
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const message = messages[i];
|
||||||
|
if (!isRecord(message) || message.role !== role) continue;
|
||||||
|
|
||||||
|
const text = normalizeMessageText(extractContentText(message.content));
|
||||||
|
if (text) return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateText(text: string, maxLength: number): string {
|
||||||
|
const chars = Array.from(text);
|
||||||
|
if (chars.length <= maxLength) return text;
|
||||||
|
if (maxLength <= 0) return "";
|
||||||
|
if (maxLength === 1) return "…";
|
||||||
|
return `${chars.slice(0, maxLength - 1).join("")}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractContentText(content: unknown): string {
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
if (!Array.isArray(content)) return "";
|
||||||
|
|
||||||
|
return content
|
||||||
|
.map((block) => {
|
||||||
|
if (!isRecord(block)) return "";
|
||||||
|
return block.type === "text" && typeof block.text === "string" ? block.text : "";
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMessageText(text: string): string {
|
||||||
|
return text.replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { KittyVisibility, MessageSource, NotificationAction } from "./types.ts";
|
||||||
|
|
||||||
|
export function parseMessageSource(value: string | undefined): MessageSource {
|
||||||
|
const normalized = value?.trim().toLowerCase();
|
||||||
|
if (normalized === "user" || normalized === "none") return normalized;
|
||||||
|
return "assistant";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMessageMax(value: string | undefined): number {
|
||||||
|
return parseBoundedInteger(value, 80, 0, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMinDurationMs(value: string | undefined): number {
|
||||||
|
const seconds = parseBoundedNumber(value, 3, 0, 3600);
|
||||||
|
return Math.round(seconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseEnabled(value: string | undefined): boolean {
|
||||||
|
if (value === undefined) return true;
|
||||||
|
return !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseKittyVisibility(value: string | undefined): KittyVisibility {
|
||||||
|
const normalized = value?.trim().toLowerCase();
|
||||||
|
if (normalized === "always" || normalized === "invisible") return normalized;
|
||||||
|
return "unfocused";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseNotificationAction(value: string | undefined): NotificationAction {
|
||||||
|
return value?.trim().toLowerCase() === "none" ? "none" : "focus";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBoundedInteger(value: string | undefined, fallback: number, min: number, max: number): number {
|
||||||
|
const parsed = Number.parseInt(value || String(fallback), 10);
|
||||||
|
if (!Number.isFinite(parsed)) return fallback;
|
||||||
|
return Math.max(min, Math.min(parsed, max));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBoundedNumber(value: string | undefined, fallback: number, min: number, max: number): number {
|
||||||
|
const normalized = value?.trim();
|
||||||
|
const parsed = Number(normalized ? normalized : fallback);
|
||||||
|
if (!Number.isFinite(parsed)) return fallback;
|
||||||
|
return Math.max(min, Math.min(parsed, max));
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { basename } from "node:path";
|
||||||
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
|
import type { TemplateValues } from "./types.ts";
|
||||||
|
|
||||||
|
export function renderTemplate(
|
||||||
|
template: string,
|
||||||
|
ctx: ExtensionContext | undefined,
|
||||||
|
durationMs: number,
|
||||||
|
extraValues: TemplateValues = {},
|
||||||
|
): string {
|
||||||
|
const cwd = ctx?.cwd || process.cwd();
|
||||||
|
const values: TemplateValues = {
|
||||||
|
cwd,
|
||||||
|
project: basename(cwd),
|
||||||
|
model: formatModel(ctx),
|
||||||
|
model_short: formatModelShort(ctx),
|
||||||
|
duration: formatDuration(durationMs),
|
||||||
|
duration_ms: String(durationMs),
|
||||||
|
...extraValues,
|
||||||
|
};
|
||||||
|
|
||||||
|
return template.replace(/\{([a-z_]+)\}/g, (match, key) => values[key] ?? match);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatModel(ctx: ExtensionContext | undefined): string {
|
||||||
|
if (!ctx?.model) return "unknown";
|
||||||
|
return `${ctx.model.provider}/${ctx.model.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatModelShort(ctx: ExtensionContext | undefined): string {
|
||||||
|
return ctx?.model?.id || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
|
||||||
|
const minutes = Math.floor(ms / 60_000);
|
||||||
|
const seconds = Math.round((ms % 60_000) / 1000);
|
||||||
|
return `${minutes}m ${seconds}s`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { execFileSync, spawn } from "node:child_process";
|
||||||
|
import type { NotificationBackend, NotifyConfig } from "./types.ts";
|
||||||
|
|
||||||
|
const ESC = "\x1b";
|
||||||
|
const BEL = "\x07";
|
||||||
|
const ST = `${ESC}\\`;
|
||||||
|
|
||||||
|
type NotifierDependencies = {
|
||||||
|
spawn: typeof spawn;
|
||||||
|
readTmuxClientInfo: () => string | undefined;
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createNotifier(dependencies: Partial<NotifierDependencies> = {}) {
|
||||||
|
const spawnCommand = dependencies.spawn ?? spawn;
|
||||||
|
const tmuxClientInfo = dependencies.readTmuxClientInfo ?? readTmuxClientInfo;
|
||||||
|
const platform = dependencies.platform ?? process.platform;
|
||||||
|
|
||||||
|
return {
|
||||||
|
getBackend(): NotificationBackend {
|
||||||
|
return detectNotificationBackend(tmuxClientInfo, platform);
|
||||||
|
},
|
||||||
|
sendNotification(config: NotifyConfig): void {
|
||||||
|
try {
|
||||||
|
const backend = detectNotificationBackend(tmuxClientInfo, platform);
|
||||||
|
if (backend === "kitty") notifyKitty(config);
|
||||||
|
else if (backend === "apple-script") notifyAppleScript(config.title, config.body, spawnCommand);
|
||||||
|
else notifyOsc777(config.title, config.body);
|
||||||
|
|
||||||
|
runSoundHook(config.soundCommand, spawnCommand);
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultNotifier = createNotifier();
|
||||||
|
|
||||||
|
export function sendNotification(config: NotifyConfig): void {
|
||||||
|
defaultNotifier.sendNotification(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotificationBackend(): NotificationBackend {
|
||||||
|
return defaultNotifier.getBackend();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectNotificationBackend(
|
||||||
|
readClientInfo: () => string | undefined = readTmuxClientInfo,
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
): NotificationBackend {
|
||||||
|
if (isKitty(readClientInfo)) return "kitty";
|
||||||
|
return platform === "darwin" ? "apple-script" : "osc-777";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKitty(readClientInfo: () => string | undefined): boolean {
|
||||||
|
if (!process.env.TMUX) return Boolean(process.env.KITTY_WINDOW_ID);
|
||||||
|
|
||||||
|
const info = readClientInfo()?.toLowerCase();
|
||||||
|
if (info) return info.includes("kitty");
|
||||||
|
return Boolean(process.env.KITTY_WINDOW_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTmuxClientInfo(): string | undefined {
|
||||||
|
try {
|
||||||
|
return execFileSync("tmux", ["display-message", "-p", "#{client_termname} #{client_termtype}"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyKitty(config: NotifyConfig): void {
|
||||||
|
const safeTitle = Buffer.from(config.title, "utf8").toString("base64");
|
||||||
|
const safeBody = Buffer.from(config.body, "utf8").toString("base64");
|
||||||
|
const id = sanitizeMetadataValue(config.notificationId);
|
||||||
|
writeSequence(
|
||||||
|
`${ESC}]99;i=${id}:d=0:e=1:o=${config.visibility}:a=${config.action};${safeTitle}${ST}`,
|
||||||
|
);
|
||||||
|
writeSequence(`${ESC}]99;i=${id}:p=body:e=1;${safeBody}${ST}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyAppleScript(title: string, body: string, spawnCommand: typeof spawn): void {
|
||||||
|
const script = `display notification ${appleScriptString(body)} with title ${appleScriptString(title)}`;
|
||||||
|
spawnDetached(spawnCommand, "osascript", ["-e", script]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyOsc777(title: string, body: string): void {
|
||||||
|
process.stdout.write(`${ESC}]777;notify;${sanitizeOsc777(title)};${sanitizeOsc777(body)}${BEL}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSequence(sequence: string): void {
|
||||||
|
process.stdout.write(wrapForTmux(sequence));
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapForTmux(sequence: string): string {
|
||||||
|
if (!process.env.TMUX) return sequence;
|
||||||
|
return `${ESC}Ptmux;${sequence.replaceAll(ESC, ESC + ESC)}${ST}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeMetadataValue(value: string): string {
|
||||||
|
const sanitized = value.replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 128);
|
||||||
|
return sanitized || `pi-${process.pid}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeOsc777(value: string): string {
|
||||||
|
return value.replace(/[\x00-\x1f\x7f;]/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function appleScriptString(value: string): string {
|
||||||
|
return `"${value.replace(/[\x00-\x1f\x7f]/g, " ").replace(/\\/g, "\\\\").replace(/"/g, '\\"').trim()}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSoundHook(command: string | undefined, spawnCommand: typeof spawn): void {
|
||||||
|
if (!command) return;
|
||||||
|
spawnDetached(spawnCommand, command, [], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnDetached(spawnCommand: typeof spawn, command: string, args: string[], shell = false): void {
|
||||||
|
const child = spawnCommand(command, args, {
|
||||||
|
shell,
|
||||||
|
detached: true,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
child.on?.("error", () => {});
|
||||||
|
child.unref();
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export type MessageSource = "user" | "assistant" | "none";
|
||||||
|
|
||||||
|
export type KittyVisibility = "always" | "unfocused" | "invisible";
|
||||||
|
export type NotificationAction = "focus" | "none";
|
||||||
|
export type NotificationBackend = "kitty" | "apple-script" | "osc-777";
|
||||||
|
|
||||||
|
export type NotifyConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
durationMs: number;
|
||||||
|
minDurationMs: number;
|
||||||
|
notificationId: string;
|
||||||
|
visibility: KittyVisibility;
|
||||||
|
action: NotificationAction;
|
||||||
|
soundCommand?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NotificationRuntimeValues = {
|
||||||
|
notificationId: string;
|
||||||
|
toolErrorCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TemplateValues = Record<string, string>;
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { afterEach, describe, it } from "node:test";
|
||||||
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { getConfig, shouldNotify } from "../src/config.ts";
|
||||||
|
|
||||||
|
const envKeys = [
|
||||||
|
"PI_NOTIFY_ENABLED",
|
||||||
|
"PI_NOTIFY_TITLE",
|
||||||
|
"PI_NOTIFY_BODY",
|
||||||
|
"PI_NOTIFY_MESSAGE_SOURCE",
|
||||||
|
"PI_NOTIFY_MESSAGE_MAX",
|
||||||
|
"PI_NOTIFY_MIN_SECONDS",
|
||||||
|
"PI_NOTIFY_VISIBILITY",
|
||||||
|
"PI_NOTIFY_ACTION",
|
||||||
|
"PI_NOTIFY_SOUND_CMD",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const savedEnv = new Map(envKeys.map((key) => [key, process.env[key]]));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const key of envKeys) {
|
||||||
|
const value = savedEnv.get(key);
|
||||||
|
if (value === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function context(): ExtensionContext {
|
||||||
|
return {
|
||||||
|
cwd: "/tmp/pi-notify",
|
||||||
|
model: { provider: "openai", id: "gpt-4.1" },
|
||||||
|
} as ExtensionContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtime = { notificationId: "pi-session", toolErrorCount: 0 };
|
||||||
|
|
||||||
|
describe("config", () => {
|
||||||
|
it("builds safe Kitty defaults from the settled assistant output", () => {
|
||||||
|
const config = getConfig(
|
||||||
|
context(),
|
||||||
|
800,
|
||||||
|
[
|
||||||
|
{ role: "user", content: "please fix this" },
|
||||||
|
{ role: "assistant", content: "fixed it" },
|
||||||
|
],
|
||||||
|
runtime,
|
||||||
|
5_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(config, {
|
||||||
|
enabled: true,
|
||||||
|
title: "π - pi-notify",
|
||||||
|
body: "fixed it",
|
||||||
|
durationMs: 4_200,
|
||||||
|
minDurationMs: 3_000,
|
||||||
|
notificationId: "pi-session",
|
||||||
|
visibility: "unfocused",
|
||||||
|
action: "focus",
|
||||||
|
soundCommand: undefined,
|
||||||
|
});
|
||||||
|
assert.equal(shouldNotify(config), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports privacy, threshold, focus, and status template settings", () => {
|
||||||
|
process.env.PI_NOTIFY_ENABLED = "off";
|
||||||
|
process.env.PI_NOTIFY_TITLE = "{status_icon} {status} {tool_error_count}";
|
||||||
|
process.env.PI_NOTIFY_BODY = "{message}";
|
||||||
|
process.env.PI_NOTIFY_MESSAGE_SOURCE = "none";
|
||||||
|
process.env.PI_NOTIFY_MIN_SECONDS = "10.5";
|
||||||
|
process.env.PI_NOTIFY_VISIBILITY = "invisible";
|
||||||
|
process.env.PI_NOTIFY_ACTION = "none";
|
||||||
|
process.env.PI_NOTIFY_SOUND_CMD = " say done ";
|
||||||
|
|
||||||
|
const config = getConfig(context(), 1_000, [{ role: "assistant", content: "secret" }], {
|
||||||
|
notificationId: "pi-session",
|
||||||
|
toolErrorCount: 2,
|
||||||
|
}, 2_000);
|
||||||
|
|
||||||
|
assert.equal(config.title, "⚠ ready · 2 tool errors 2");
|
||||||
|
assert.equal(config.body, "Ready for input");
|
||||||
|
assert.equal(config.enabled, false);
|
||||||
|
assert.equal(config.minDurationMs, 10_500);
|
||||||
|
assert.equal(config.visibility, "invisible");
|
||||||
|
assert.equal(config.action, "none");
|
||||||
|
assert.equal(config.soundCommand, "say done");
|
||||||
|
assert.equal(shouldNotify(config), false);
|
||||||
|
assert.equal(shouldNotify(config, true), false, "the minimum duration still applies to command overrides");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies selected-message truncation and a session override", () => {
|
||||||
|
process.env.PI_NOTIFY_MESSAGE_SOURCE = "user";
|
||||||
|
process.env.PI_NOTIFY_MESSAGE_MAX = "5";
|
||||||
|
process.env.PI_NOTIFY_MIN_SECONDS = "0";
|
||||||
|
const config = getConfig(
|
||||||
|
context(),
|
||||||
|
1_000,
|
||||||
|
[{ role: "user", content: "hello world" }],
|
||||||
|
runtime,
|
||||||
|
2_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(config.body, "hell…");
|
||||||
|
assert.equal(shouldNotify(config, false), false);
|
||||||
|
assert.equal(shouldNotify(config, true), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { afterEach, describe, it } from "node:test";
|
||||||
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { createPiNotifyExtension } from "../src/index.ts";
|
||||||
|
import type { NotifyConfig } from "../src/types.ts";
|
||||||
|
|
||||||
|
type Handler = (...args: any[]) => any;
|
||||||
|
|
||||||
|
const savedMinSeconds = process.env.PI_NOTIFY_MIN_SECONDS;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (savedMinSeconds === undefined) delete process.env.PI_NOTIFY_MIN_SECONDS;
|
||||||
|
else process.env.PI_NOTIFY_MIN_SECONDS = savedMinSeconds;
|
||||||
|
});
|
||||||
|
|
||||||
|
function harness(mode: ExtensionContext["mode"] = "tui") {
|
||||||
|
const handlers = new Map<string, Handler>();
|
||||||
|
const commands = new Map<string, { handler: Handler }>();
|
||||||
|
const notifications: NotifyConfig[] = [];
|
||||||
|
const uiMessages: string[] = [];
|
||||||
|
let now = 1_000;
|
||||||
|
|
||||||
|
const pi = {
|
||||||
|
on(name: string, handler: Handler) {
|
||||||
|
handlers.set(name, handler);
|
||||||
|
},
|
||||||
|
registerCommand(name: string, command: { handler: Handler }) {
|
||||||
|
commands.set(name, command);
|
||||||
|
},
|
||||||
|
} as unknown as ExtensionAPI;
|
||||||
|
|
||||||
|
createPiNotifyExtension({
|
||||||
|
now: () => now,
|
||||||
|
createNotificationId: () => "pi-session-test",
|
||||||
|
notify: (config) => notifications.push(config),
|
||||||
|
getBackend: () => "kitty",
|
||||||
|
})(pi);
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
mode,
|
||||||
|
hasUI: mode === "tui" || mode === "rpc",
|
||||||
|
cwd: "/tmp/project-a",
|
||||||
|
model: { provider: "openai", id: "gpt-test" },
|
||||||
|
ui: {
|
||||||
|
notify(message: string) {
|
||||||
|
uiMessages.push(message);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as ExtensionContext;
|
||||||
|
|
||||||
|
return {
|
||||||
|
handlers,
|
||||||
|
commands,
|
||||||
|
notifications,
|
||||||
|
uiMessages,
|
||||||
|
ctx,
|
||||||
|
setNow(value: number) {
|
||||||
|
now = value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("pi-notify extension", () => {
|
||||||
|
it("notifies once only after retries and follow-ups have settled", async () => {
|
||||||
|
const h = harness();
|
||||||
|
await h.handlers.get("agent_start")?.({}, h.ctx);
|
||||||
|
h.setNow(2_000);
|
||||||
|
await h.handlers.get("agent_end")?.({ messages: [{ role: "assistant", content: "retrying" }] }, h.ctx);
|
||||||
|
await h.handlers.get("agent_start")?.({}, h.ctx);
|
||||||
|
await h.handlers.get("tool_execution_end")?.({ isError: true }, h.ctx);
|
||||||
|
h.setNow(5_000);
|
||||||
|
await h.handlers.get("agent_end")?.({ messages: [{ role: "assistant", content: "finally done" }] }, h.ctx);
|
||||||
|
|
||||||
|
assert.equal(h.notifications.length, 0, "agent_end must not notify before Pi is settled");
|
||||||
|
await h.handlers.get("agent_settled")?.({}, h.ctx);
|
||||||
|
|
||||||
|
assert.equal(h.notifications.length, 1);
|
||||||
|
assert.equal(h.notifications[0]?.body, "finally done");
|
||||||
|
assert.equal(h.notifications[0]?.durationMs, 4_000, "duration includes retries");
|
||||||
|
assert.equal(h.notifications[0]?.notificationId, "pi-session-test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not write terminal notifications in RPC or non-interactive modes", async () => {
|
||||||
|
process.env.PI_NOTIFY_MIN_SECONDS = "0";
|
||||||
|
for (const mode of ["rpc", "json", "print"] as const) {
|
||||||
|
const h = harness(mode);
|
||||||
|
await h.handlers.get("agent_start")?.({}, h.ctx);
|
||||||
|
await h.handlers.get("agent_settled")?.({}, h.ctx);
|
||||||
|
assert.equal(h.notifications.length, 0, mode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers session controls and a test notification", async () => {
|
||||||
|
const h = harness();
|
||||||
|
const command = h.commands.get("notify");
|
||||||
|
assert.ok(command);
|
||||||
|
|
||||||
|
await command.handler("off", h.ctx);
|
||||||
|
assert.match(h.uiMessages.at(-1) ?? "", /off/);
|
||||||
|
await command.handler("test", h.ctx);
|
||||||
|
assert.equal(h.notifications.length, 1);
|
||||||
|
assert.match(h.notifications[0]?.body ?? "", /click to focus this window/);
|
||||||
|
await command.handler("status", h.ctx);
|
||||||
|
assert.match(h.uiMessages.at(-1) ?? "", /kitty/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { extractMessageText, truncateText } from "../src/messages.ts";
|
||||||
|
|
||||||
|
describe("messages", () => {
|
||||||
|
it("extracts the latest text message for a role", () => {
|
||||||
|
const messages = [
|
||||||
|
{ role: "user", content: "first user" },
|
||||||
|
{ role: "assistant", content: [{ type: "text", text: "first assistant" }] },
|
||||||
|
{ role: "user", content: [{ type: "text", text: "latest\nuser" }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.equal(extractMessageText(messages, "user"), "latest user");
|
||||||
|
assert.equal(extractMessageText(messages, "assistant"), "first assistant");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-text content blocks", () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "image", text: "ignore me" },
|
||||||
|
{ type: "text", text: "keep me" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.equal(extractMessageText(messages, "assistant"), "keep me");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates by unicode code points", () => {
|
||||||
|
assert.equal(truncateText("hello", 10), "hello");
|
||||||
|
assert.equal(truncateText("hello", 4), "hel…");
|
||||||
|
assert.equal(truncateText("🤖abc", 3), "🤖a…");
|
||||||
|
assert.equal(truncateText("hello", 1), "…");
|
||||||
|
assert.equal(truncateText("hello", 0), "");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
parseEnabled,
|
||||||
|
parseKittyVisibility,
|
||||||
|
parseMessageMax,
|
||||||
|
parseMessageSource,
|
||||||
|
parseMinDurationMs,
|
||||||
|
parseNotificationAction,
|
||||||
|
} from "../src/parsers.ts";
|
||||||
|
|
||||||
|
describe("parsers", () => {
|
||||||
|
it("parses message source including privacy mode", () => {
|
||||||
|
assert.equal(parseMessageSource("assistant"), "assistant");
|
||||||
|
assert.equal(parseMessageSource(" user "), "user");
|
||||||
|
assert.equal(parseMessageSource("none"), "none");
|
||||||
|
assert.equal(parseMessageSource("invalid"), "assistant");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses and clamps numeric settings", () => {
|
||||||
|
assert.equal(parseMessageMax(undefined), 80);
|
||||||
|
assert.equal(parseMessageMax("12"), 12);
|
||||||
|
assert.equal(parseMessageMax("-1"), 0);
|
||||||
|
assert.equal(parseMessageMax("999"), 500);
|
||||||
|
assert.equal(parseMessageMax("nope"), 80);
|
||||||
|
assert.equal(parseMinDurationMs(undefined), 3_000);
|
||||||
|
assert.equal(parseMinDurationMs(" "), 3_000);
|
||||||
|
assert.equal(parseMinDurationMs("1.25"), 1_250);
|
||||||
|
assert.equal(parseMinDurationMs("9999"), 3_600_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses fail-safe defaults for boolean and Kitty behavior settings", () => {
|
||||||
|
assert.equal(parseEnabled(undefined), true);
|
||||||
|
assert.equal(parseEnabled("off"), false);
|
||||||
|
assert.equal(parseEnabled("yes"), true);
|
||||||
|
assert.equal(parseKittyVisibility(undefined), "unfocused");
|
||||||
|
assert.equal(parseKittyVisibility("always"), "always");
|
||||||
|
assert.equal(parseKittyVisibility("invalid"), "unfocused");
|
||||||
|
assert.equal(parseNotificationAction("none"), "none");
|
||||||
|
assert.equal(parseNotificationAction("invalid"), "focus");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { renderTemplate } from "../src/template.ts";
|
||||||
|
|
||||||
|
function context(): ExtensionContext {
|
||||||
|
return {
|
||||||
|
cwd: "/tmp/pi-notify",
|
||||||
|
model: { provider: "ollama", id: "qwen3" },
|
||||||
|
} as ExtensionContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("template", () => {
|
||||||
|
it("renders built-in values and keeps unknown placeholders", () => {
|
||||||
|
const output = renderTemplate(
|
||||||
|
"{project} {cwd} {model} {model_short} {duration} {duration_ms} {missing}",
|
||||||
|
context(),
|
||||||
|
4200,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(output, "/tmp/pi-notify".split("/").at(-1) + " /tmp/pi-notify ollama/qwen3 qwen3 4.2s 4200 {missing}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows extra values to override built-ins", () => {
|
||||||
|
assert.equal(renderTemplate("{project} {message}", context(), 0, { project: "custom", message: "done" }), "custom done");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { afterEach, beforeEach, describe, it } from "node:test";
|
||||||
|
import { createNotifier, detectNotificationBackend } from "../src/terminal.ts";
|
||||||
|
import type { NotifyConfig } from "../src/types.ts";
|
||||||
|
|
||||||
|
const ESC = "\x1b";
|
||||||
|
const ST = `${ESC}\\`;
|
||||||
|
const savedEnv = {
|
||||||
|
KITTY_WINDOW_ID: process.env.KITTY_WINDOW_ID,
|
||||||
|
TMUX: process.env.TMUX,
|
||||||
|
};
|
||||||
|
|
||||||
|
let originalWrite: typeof process.stdout.write;
|
||||||
|
let output = "";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.KITTY_WINDOW_ID;
|
||||||
|
delete process.env.TMUX;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.stdout.write = originalWrite;
|
||||||
|
output = "";
|
||||||
|
for (const [key, value] of Object.entries(savedEnv)) {
|
||||||
|
if (value === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function captureStdout(): void {
|
||||||
|
originalWrite = process.stdout.write;
|
||||||
|
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||||
|
output += chunk.toString();
|
||||||
|
return true;
|
||||||
|
}) as typeof process.stdout.write;
|
||||||
|
}
|
||||||
|
|
||||||
|
function config(title = "Title", body = "Body"): NotifyConfig {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
durationMs: 5_000,
|
||||||
|
minDurationMs: 3_000,
|
||||||
|
notificationId: "pi-session:unsafe",
|
||||||
|
visibility: "unfocused",
|
||||||
|
action: "focus",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureSpawn(): { calls: unknown[][]; spawn: typeof import("node:child_process").spawn } {
|
||||||
|
const calls: unknown[][] = [];
|
||||||
|
const spawn = ((...args: unknown[]) => {
|
||||||
|
calls.push(args);
|
||||||
|
return { on() {}, unref() {} };
|
||||||
|
}) as unknown as typeof import("node:child_process").spawn;
|
||||||
|
return { calls, spawn };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("terminal notifications", () => {
|
||||||
|
it("writes encoded Kitty OSC 99 payloads with exact-window focus metadata", () => {
|
||||||
|
process.env.KITTY_WINDOW_ID = "1";
|
||||||
|
const notifier = createNotifier();
|
||||||
|
captureStdout();
|
||||||
|
notifier.sendNotification(config("π - pi-notify", "done\nnow"));
|
||||||
|
|
||||||
|
const title = Buffer.from("π - pi-notify", "utf8").toString("base64");
|
||||||
|
const body = Buffer.from("done\nnow", "utf8").toString("base64");
|
||||||
|
assert.equal(
|
||||||
|
output,
|
||||||
|
`${ESC}]99;i=pi-session-unsafe:d=0:e=1:o=unfocused:a=focus;${title}${ST}` +
|
||||||
|
`${ESC}]99;i=pi-session-unsafe:p=body:e=1;${body}${ST}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps Kitty OSC 99 sequences for tmux passthrough", () => {
|
||||||
|
process.env.TMUX = "/tmp/tmux";
|
||||||
|
const notifier = createNotifier({ readTmuxClientInfo: () => "xterm-kitty xterm-kitty" });
|
||||||
|
captureStdout();
|
||||||
|
notifier.sendNotification(config("Pi", "done"));
|
||||||
|
|
||||||
|
const title = Buffer.from("Pi", "utf8").toString("base64");
|
||||||
|
const body = Buffer.from("done", "utf8").toString("base64");
|
||||||
|
const titleSequence = `${ESC}]99;i=pi-session-unsafe:d=0:e=1:o=unfocused:a=focus;${title}${ST}`.replaceAll(ESC, ESC + ESC);
|
||||||
|
const bodySequence = `${ESC}]99;i=pi-session-unsafe:p=body:e=1;${body}${ST}`.replaceAll(ESC, ESC + ESC);
|
||||||
|
assert.equal(output, `${ESC}Ptmux;${titleSequence}${ST}${ESC}Ptmux;${bodySequence}${ST}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses AppleScript safely on macOS when Kitty is not detected", () => {
|
||||||
|
const { calls, spawn } = captureSpawn();
|
||||||
|
const notifier = createNotifier({ spawn, platform: "darwin" });
|
||||||
|
captureStdout();
|
||||||
|
notifier.sendNotification(config('Pi "notify"', "done\\now"));
|
||||||
|
|
||||||
|
assert.equal(output, "");
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
[
|
||||||
|
"osascript",
|
||||||
|
["-e", 'display notification "done\\\\now" with title "Pi \\"notify\\""'],
|
||||||
|
{ shell: false, detached: true, stdio: "ignore" },
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a stale Kitty variable override the active tmux client", () => {
|
||||||
|
process.env.TMUX = "/tmp/tmux";
|
||||||
|
process.env.KITTY_WINDOW_ID = "stale";
|
||||||
|
assert.equal(
|
||||||
|
detectNotificationBackend(() => "xterm-ghostty ghostty", "darwin"),
|
||||||
|
"apple-script",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a sanitized OSC 777 fallback outside macOS", () => {
|
||||||
|
const notifier = createNotifier({ platform: "linux" });
|
||||||
|
captureStdout();
|
||||||
|
notifier.sendNotification(config("Pi;bad", "done\u0007now"));
|
||||||
|
assert.equal(output, `${ESC}]777;notify;Pi bad;done now\x07`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node"],
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Preserve a registered shell-alias input preview as authorizer evidence and permission review context, so wrappers such as `ssh_bash` can disclose their remote execution target while retaining full Bash policy enforcement.
|
||||||
|
- Add `shellTools.<name>.decisionFloor: "ask"`, which raises Bash allows to reviewable asks without weakening existing asks or hard denies, and preserve a global floor through field-level project config merging.
|
||||||
|
|
||||||
## [26.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.2.0...pi-permission-system-v26.2.1) (2026-08-17)
|
## [26.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.2.0...pi-permission-system-v26.2.1) (2026-08-17)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ Project config (policy and runtime knobs) is loaded only once the project is tru
|
|||||||
Within a surface map like `bash` or `mcp`, **last matching rule wins** — put broad catch-alls first and specific overrides after.
|
Within a surface map like `bash` or `mcp`, **last matching rule wins** — put broad catch-alls first and specific overrides after.
|
||||||
|
|
||||||
The optional `shellTools` field records which non-`bash` tools carry shell semantics (e.g. an `exec_command` tool that replaces native `bash`), so they are gated at full parity with native `bash` — see [docs/configuration.md](docs/configuration.md#shelltools--gating-aliased-shell-tools).
|
The optional `shellTools` field records which non-`bash` tools carry shell semantics (e.g. an `exec_command` tool that replaces native `bash`), so they are gated at full parity with native `bash` — see [docs/configuration.md](docs/configuration.md#shelltools--gating-aliased-shell-tools).
|
||||||
|
Registered custom input previews for those aliases are preserved as authorization evidence and review-log context, allowing an extension to disclose execution context such as an SSH target without weakening bash enforcement.
|
||||||
|
A shell alias may also set `decisionFloor: "ask"`: Bash `allow` results are raised into the configured authorizer chain, while existing `ask` and hard `deny` decisions remain unchanged.
|
||||||
|
|
||||||
The optional `authorizerChain` field names registered case-by-case decision links (e.g. a light model judge) to consult when a request lands on `ask`, ahead of the interactive prompt.
|
The optional `authorizerChain` field names registered case-by-case decision links (e.g. a light model judge) to consult when a request lands on `ask`, ahead of the interactive prompt.
|
||||||
A downstream extension registers a link via `getPermissionsService().registerAuthorizer(name, authorize)`; it decides nothing until you name it here (opt-in), config order fixes the chain order, and the chain owner caps any link's `allow` on `external_directory`/`path` to keep it within your policy — see [docs/configuration.md](docs/configuration.md#authorizer-chain--case-by-case-decision-links).
|
A downstream extension registers a link via `getPermissionsService().registerAuthorizer(name, authorize)`; it decides nothing until you name it here (opt-in), config order fixes the chain order, and the chain owner caps any link's `allow` on `external_directory`/`path` to keep it within your policy — see [docs/configuration.md](docs/configuration.md#authorizer-chain--case-by-case-decision-links).
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
"authorizerChain": [],
|
"authorizerChain": [],
|
||||||
|
|
||||||
"shellTools": {
|
"shellTools": {
|
||||||
"exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" }
|
"exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" },
|
||||||
|
"ssh_bash": { "commandArgument": "command", "decisionFloor": "ask" }
|
||||||
},
|
},
|
||||||
|
|
||||||
"permission": {
|
"permission": {
|
||||||
|
|||||||
@@ -177,12 +177,14 @@ Some extensions replace `bash` with a differently-named tool — for example [`@
|
|||||||
Without a hint, the permission system cannot tell that such a tool is really a shell, so it gates it as a generic extension tool and the bash rules never apply.
|
Without a hint, the permission system cannot tell that such a tool is really a shell, so it gates it as a generic extension tool and the bash rules never apply.
|
||||||
|
|
||||||
`shellTools` records that hint, and an aliased tool is then gated at full parity with native `bash` — command decomposition, wrapper flooring, path and external-directory token gates, and `bash:` rules — with the invoked tool name preserved in the review log.
|
`shellTools` records that hint, and an aliased tool is then gated at full parity with native `bash` — command decomposition, wrapper flooring, path and external-directory token gates, and `bash:` rules — with the invoked tool name preserved in the review log.
|
||||||
|
If the extension registers a custom input formatter for the aliased tool, its preview is also carried as authorization evidence and persisted in the permission review context. This lets wrappers disclose execution context that is not part of the command itself, such as an SSH target, container, or remote working directory.
|
||||||
Each key is a tool name; its value maps the tool's input arguments (the keys of the tool call's `arguments` object):
|
Each key is a tool name; its value maps the tool's input arguments (the keys of the tool call's `arguments` object):
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"shellTools": {
|
"shellTools": {
|
||||||
"exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" }
|
"exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" },
|
||||||
|
"ssh_bash": { "commandArgument": "command", "decisionFloor": "ask" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -191,12 +193,13 @@ Each key is a tool name; its value maps the tool's input arguments (the keys of
|
|||||||
| ----------------- | -------- | ------------------------------------------------------------------------- |
|
| ----------------- | -------- | ------------------------------------------------------------------------- |
|
||||||
| `commandArgument` | yes | The tool's input argument holding the shell command string (e.g. `cmd`). |
|
| `commandArgument` | yes | The tool's input argument holding the shell command string (e.g. `cmd`). |
|
||||||
| `workdirArgument` | no | The tool's input argument holding the working directory (e.g. `workdir`). |
|
| `workdirArgument` | no | The tool's input argument holding the working directory (e.g. `workdir`). |
|
||||||
|
| `decisionFloor` | no | `"ask"` raises Bash `allow` to `ask`; existing `ask`/`deny` are preserved. |
|
||||||
|
|
||||||
|
Use `decisionFloor: "ask"` when the aliased shell crosses an extra trust boundary, such as SSH or container execution. With yolo mode disabled, every otherwise-allowed invocation then reaches the configured authorizer chain unless deterministic Bash, path, or external-directory policy already denies it. Only `"ask"` is accepted; the setting cannot weaken a hard deny.
|
||||||
|
|
||||||
When `workdirArgument` is set, the tool's working directory is the base the command's relative paths resolve against, and the working directory itself is gated by `external_directory` when it falls outside the session's working directory.
|
When `workdirArgument` is set, the tool's working directory is the base the command's relative paths resolve against, and the working directory itself is gated by `external_directory` when it falls outside the session's working directory.
|
||||||
|
|
||||||
Merge semantics: `shellTools` **shallow-merges by tool name** across global → project.
|
Merge semantics: `shellTools` merges by tool name and then by descriptor field across global → project. Project values can replace argument names, but omitting a globally configured `decisionFloor: "ask"` does not remove it. Because no weaker floor value is valid, a project cannot downgrade that global review boundary.
|
||||||
A project entry overrides a specific tool's mapping on a key collision but never drops a global entry — so adding a project-scoped alias cannot silently remove enforcement for a tool the global config already covers.
|
|
||||||
To change a specific tool's mapping, set that tool's key at the project scope (the alias object is replaced wholesale, not deep-merged).
|
|
||||||
|
|
||||||
`shellTools` only ever *tightens* enforcement and is inert when the named tool is not registered in the current session.
|
`shellTools` only ever *tightens* enforcement and is inert when the named tool is not registered in the current session.
|
||||||
Opting a project out of a shell-aliasing extension is a package-disable concern, not a `shellTools` edit.
|
Opting a project out of a shell-aliasing extension is a package-disable concern, not a `shellTools` edit.
|
||||||
|
|||||||
@@ -171,19 +171,31 @@
|
|||||||
"description": "Optional name of the tool's input argument holding the working directory (e.g. 'workdir').",
|
"description": "Optional name of the tool's input argument holding the working directory (e.g. 'workdir').",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"decisionFloor": {
|
||||||
|
"description": "Require every invocation of this shell alias to be reviewed at least as an ask; deny remains deny.",
|
||||||
|
"markdownDescription": "Sets the minimum review decision for this alias. `allow` results from Bash policy are raised to `ask`, while existing `ask` and `deny` results are preserved. Only `\"ask\"` is accepted so a project override cannot weaken a global floor.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "ask"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["commandArgument"],
|
"required": [
|
||||||
|
"commandArgument"
|
||||||
|
],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "Maps one shell-aliased tool to the input arguments holding its command and (optionally) its working directory."
|
"description": "Maps one shell-aliased tool to the input arguments holding its command and optional working directory, plus an optional ask decision floor."
|
||||||
},
|
},
|
||||||
"description": "Maps non-bash tool names that carry shell semantics to the input arguments holding their command and working directory.",
|
"description": "Maps non-bash tool names that carry shell semantics to the input arguments holding their command and working directory.",
|
||||||
"markdownDescription": "Records which non-`bash` tools carry shell semantics, mapping each tool name to the input argument holding its command (and optionally its working directory).\n\nUse this when an extension replaces the native `bash` tool under a different name — e.g. `@howaboua/pi-codex-conversion` registers `exec_command` with a `cmd` argument and an optional `workdir`. Recording the alias lets the permission system gate that tool through the same bash enforcement stack as native `bash` (command decomposition, wrapper flooring, path/external-directory token gates, and `bash:` rules).\n\nExample:\n\n```json\n\"shellTools\": {\n \"exec_command\": { \"commandArgument\": \"cmd\", \"workdirArgument\": \"workdir\" }\n}\n```\n\n**Merge order:** shallow-merge by tool name across global → project. A project entry overrides a specific tool's mapping on key collision but never drops a global entry.",
|
"markdownDescription": "Records which non-`bash` tools carry shell semantics, mapping each tool name to the input argument holding its command (and optionally its working directory).\n\nUse this when an extension replaces the native `bash` tool under a different name — e.g. `@howaboua/pi-codex-conversion` registers `exec_command` with a `cmd` argument and an optional `workdir`. Recording the alias lets the permission system gate that tool through the same bash enforcement stack as native `bash` (command decomposition, wrapper flooring, path/external-directory token gates, and `bash:` rules).\n\nSet `decisionFloor` to `\"ask\"` when the wrapper crosses an additional trust boundary, such as SSH or container execution. Bash `allow` results are raised to `ask`; existing `ask` and `deny` results remain unchanged.\n\nExample:\n\n```json\n\"shellTools\": {\n \"exec_command\": { \"commandArgument\": \"cmd\", \"workdirArgument\": \"workdir\" },\n \"ssh_bash\": { \"commandArgument\": \"command\", \"decisionFloor\": \"ask\" }\n}\n```\n\n**Merge order:** merge each tool mapping by field across global → project. Project values can replace argument names, while a global `decisionFloor: \"ask\"` survives when the project entry omits it.",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"exec_command": {
|
"exec_command": {
|
||||||
"commandArgument": "cmd",
|
"commandArgument": "cmd",
|
||||||
"workdirArgument": "workdir"
|
"workdirArgument": "workdir"
|
||||||
|
},
|
||||||
|
"ssh_bash": {
|
||||||
|
"commandArgument": "command",
|
||||||
|
"decisionFloor": "ask"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -248,7 +260,9 @@
|
|||||||
"maxLength": 500
|
"maxLength": 500
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["action"],
|
"required": [
|
||||||
|
"action"
|
||||||
|
],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "Deny with an optional custom reason shown to the agent when the action is blocked."
|
"description": "Deny with an optional custom reason shown to the agent when the action is blocked."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export interface ShellInvocation {
|
|||||||
command: string;
|
command: string;
|
||||||
/** The working directory the command runs in, if the tool projects one. */
|
/** The working directory the command runs in, if the tool projects one. */
|
||||||
workdir: string | undefined;
|
workdir: string | undefined;
|
||||||
|
/** Optional minimum review decision imposed by a configured shell alias. */
|
||||||
|
decisionFloor?: ShellToolsConfig[string]["decisionFloor"];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,6 +80,7 @@ export function resolveShellInvocation(
|
|||||||
return {
|
return {
|
||||||
command: getNonEmptyString(record.command) ?? "",
|
command: getNonEmptyString(record.command) ?? "",
|
||||||
workdir: undefined,
|
workdir: undefined,
|
||||||
|
decisionFloor: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +91,7 @@ export function resolveShellInvocation(
|
|||||||
workdir: alias.workdirArgument
|
workdir: alias.workdirArgument
|
||||||
? (getNonEmptyString(record[alias.workdirArgument]) ?? undefined)
|
? (getNonEmptyString(record[alias.workdirArgument]) ?? undefined)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
decisionFloor: alias.decisionFloor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -242,17 +242,22 @@ export function mergeUnifiedConfigs(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// shellTools: shallow-merge by tool name so a project entry overrides a
|
// shellTools: merge by tool name, then by descriptor field. A project can
|
||||||
// colliding tool's alias but never drops a global entry (a dropped alias is
|
// replace argument names, but omitting a global decisionFloor must not silently
|
||||||
// a silent enforcement regression).
|
// remove that security boundary.
|
||||||
const baseShell = base.shellTools;
|
const baseShell = base.shellTools;
|
||||||
const overrideShell = override.shellTools;
|
const overrideShell = override.shellTools;
|
||||||
if (baseShell && overrideShell) {
|
if (baseShell || overrideShell) {
|
||||||
merged.shellTools = { ...baseShell, ...overrideShell };
|
const toolNames = new Set([
|
||||||
} else if (baseShell) {
|
...Object.keys(baseShell ?? {}),
|
||||||
merged.shellTools = baseShell;
|
...Object.keys(overrideShell ?? {}),
|
||||||
} else if (overrideShell) {
|
]);
|
||||||
merged.shellTools = overrideShell;
|
merged.shellTools = Object.fromEntries(
|
||||||
|
[...toolNames].map((toolName) => [
|
||||||
|
toolName,
|
||||||
|
{ ...baseShell?.[toolName], ...overrideShell?.[toolName] },
|
||||||
|
]),
|
||||||
|
) as ShellToolsConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Permission: deep-shallow merge
|
// Permission: deep-shallow merge
|
||||||
|
|||||||
@@ -120,10 +120,16 @@ const shellToolAliasSchema = z
|
|||||||
description:
|
description:
|
||||||
"Optional name of the tool's input argument holding the working directory (e.g. 'workdir').",
|
"Optional name of the tool's input argument holding the working directory (e.g. 'workdir').",
|
||||||
}),
|
}),
|
||||||
|
decisionFloor: z.literal("ask").optional().meta({
|
||||||
|
description:
|
||||||
|
"Require every invocation of this shell alias to be reviewed at least as an ask; deny remains deny.",
|
||||||
|
markdownDescription:
|
||||||
|
"Sets the minimum review decision for this alias. `allow` results from Bash policy are raised to `ask`, while existing `ask` and `deny` results are preserved. Only `\"ask\"` is accepted so a project override cannot weaken a global floor.",
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.meta({
|
.meta({
|
||||||
description:
|
description:
|
||||||
"Maps one shell-aliased tool to the input arguments holding its command and (optionally) its working directory.",
|
"Maps one shell-aliased tool to the input arguments holding its command and optional working directory, plus an optional ask decision floor.",
|
||||||
});
|
});
|
||||||
|
|
||||||
const shellToolsSchema = z
|
const shellToolsSchema = z
|
||||||
@@ -137,10 +143,11 @@ const shellToolsSchema = z
|
|||||||
description:
|
description:
|
||||||
"Maps non-bash tool names that carry shell semantics to the input arguments holding their command and working directory.",
|
"Maps non-bash tool names that carry shell semantics to the input arguments holding their command and working directory.",
|
||||||
markdownDescription:
|
markdownDescription:
|
||||||
'Records which non-`bash` tools carry shell semantics, mapping each tool name to the input argument holding its command (and optionally its working directory).\n\nUse this when an extension replaces the native `bash` tool under a different name — e.g. `@howaboua/pi-codex-conversion` registers `exec_command` with a `cmd` argument and an optional `workdir`. Recording the alias lets the permission system gate that tool through the same bash enforcement stack as native `bash` (command decomposition, wrapper flooring, path/external-directory token gates, and `bash:` rules).\n\nExample:\n\n```json\n"shellTools": {\n "exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" }\n}\n```\n\n**Merge order:** shallow-merge by tool name across global → project. A project entry overrides a specific tool\'s mapping on key collision but never drops a global entry.',
|
'Records which non-`bash` tools carry shell semantics, mapping each tool name to the input argument holding its command (and optionally its working directory).\n\nUse this when an extension replaces the native `bash` tool under a different name — e.g. `@howaboua/pi-codex-conversion` registers `exec_command` with a `cmd` argument and an optional `workdir`. Recording the alias lets the permission system gate that tool through the same bash enforcement stack as native `bash` (command decomposition, wrapper flooring, path/external-directory token gates, and `bash:` rules).\n\nSet `decisionFloor` to `"ask"` when the wrapper crosses an additional trust boundary, such as SSH or container execution. Bash `allow` results are raised to `ask`; existing `ask` and `deny` results remain unchanged.\n\nExample:\n\n```json\n"shellTools": {\n "exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" },\n "ssh_bash": { "commandArgument": "command", "decisionFloor": "ask" }\n}\n```\n\n**Merge order:** merge each tool mapping by field across global → project. Project values can replace argument names, while a global `decisionFloor: "ask"` survives when the project entry omits it.',
|
||||||
examples: [
|
examples: [
|
||||||
{
|
{
|
||||||
exec_command: { commandArgument: "cmd", workdirArgument: "workdir" },
|
exec_command: { commandArgument: "cmd", workdirArgument: "workdir" },
|
||||||
|
ssh_bash: { commandArgument: "command", decisionFloor: "ask" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ShellToolsConfig } from "#src/config-schema";
|
||||||
|
import type { PermissionCheckResult } from "#src/types";
|
||||||
|
|
||||||
|
/** Synthetic rule marker recorded when a shell alias raises allow to ask. */
|
||||||
|
export const SHELL_TOOL_DECISION_FLOOR_PATTERN = "<shell-tool-decision-floor>";
|
||||||
|
|
||||||
|
type ShellDecisionFloor = ShellToolsConfig[string]["decisionFloor"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce the minimum review decision configured for a shell alias.
|
||||||
|
*
|
||||||
|
* The only supported floor is `ask`: it raises an allow to ask while preserving
|
||||||
|
* existing asks and hard denies. The synthetic result no longer reports a
|
||||||
|
* session source, so a prior Bash session approval cannot bypass an alias whose
|
||||||
|
* contract requires every invocation to enter the authorizer chain.
|
||||||
|
*/
|
||||||
|
export function applyShellDecisionFloor(
|
||||||
|
check: PermissionCheckResult,
|
||||||
|
floor: ShellDecisionFloor,
|
||||||
|
): PermissionCheckResult {
|
||||||
|
if (floor !== "ask" || check.state !== "allow") return check;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...check,
|
||||||
|
state: "ask",
|
||||||
|
source: "bash",
|
||||||
|
matchedPattern: SHELL_TOOL_DECISION_FLOOR_PATTERN,
|
||||||
|
reason: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import { describePathGate } from "./path";
|
|||||||
import type { GateRunner } from "./runner";
|
import type { GateRunner } from "./runner";
|
||||||
import { describeSkillReadGate } from "./skill-read";
|
import { describeSkillReadGate } from "./skill-read";
|
||||||
import { describeToolGate } from "./tool";
|
import { describeToolGate } from "./tool";
|
||||||
|
import { applyShellDecisionFloor } from "./shell-decision-floor";
|
||||||
import type { GateOutcome, ToolCallContext } from "./types";
|
import type { GateOutcome, ToolCallContext } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -164,25 +165,27 @@ export class ToolCallGatePipeline {
|
|||||||
): { toolCheck: PermissionCheckResult; accessPath?: AccessPath } {
|
): { toolCheck: PermissionCheckResult; accessPath?: AccessPath } {
|
||||||
if (shell) {
|
if (shell) {
|
||||||
if (bashProgram) {
|
if (bashProgram) {
|
||||||
return {
|
const check = resolveBashCommandCheck(
|
||||||
toolCheck: resolveBashCommandCheck(
|
|
||||||
bashProgram.commandText(),
|
bashProgram.commandText(),
|
||||||
bashProgram.commands(),
|
bashProgram.commands(),
|
||||||
tcc.agentName ?? undefined,
|
tcc.agentName ?? undefined,
|
||||||
this.resolver,
|
this.resolver,
|
||||||
),
|
);
|
||||||
|
return {
|
||||||
|
toolCheck: applyShellDecisionFloor(check, shell.decisionFloor),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// A shell invocation whose command did not parse (e.g. empty) still
|
// A shell invocation whose command did not parse (e.g. empty) still
|
||||||
// resolves on the `bash` surface, so an aliased tool never falls through
|
// resolves on the `bash` surface, so an aliased tool never falls through
|
||||||
// to its own extension-tool surface.
|
// to its own extension-tool surface.
|
||||||
return {
|
const check = this.resolver.resolve({
|
||||||
toolCheck: this.resolver.resolve({
|
|
||||||
kind: "tool",
|
kind: "tool",
|
||||||
surface: "bash",
|
surface: "bash",
|
||||||
input: { command: shell.command },
|
input: { command: shell.command },
|
||||||
agentName: tcc.agentName ?? undefined,
|
agentName: tcc.agentName ?? undefined,
|
||||||
}),
|
});
|
||||||
|
return {
|
||||||
|
toolCheck: applyShellDecisionFloor(check, shell.decisionFloor),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ export function describeToolGate(
|
|||||||
tcc.input,
|
tcc.input,
|
||||||
PATH_BEARING_TOOLS,
|
PATH_BEARING_TOOLS,
|
||||||
);
|
);
|
||||||
|
if (shell && tcc.toolName !== check.toolName) {
|
||||||
|
permissionLogContext.toolInputPreview =
|
||||||
|
formatter.formatToolInputForPrompt(tcc.toolName, tcc.input) || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Compute session approval suggestion for the "for this session" option.
|
// Compute session approval suggestion for the "for this session" option.
|
||||||
const suggestion = suggestSessionPattern(
|
const suggestion = suggestSessionPattern(
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function buildToolAskPayload(facts: ToolAskFacts): PromptPayload {
|
|||||||
executedUnit: check.executedUnit ?? null,
|
executedUnit: check.executedUnit ?? null,
|
||||||
},
|
},
|
||||||
evidence: bash
|
evidence: bash
|
||||||
? fullCommandEvidence(facts)
|
? [...fullCommandEvidence(facts), ...invokedShellInputPreviewEvidence(facts)]
|
||||||
: inputPreviewEvidence(facts, mcp),
|
: inputPreviewEvidence(facts, mcp),
|
||||||
annotations: [],
|
annotations: [],
|
||||||
};
|
};
|
||||||
@@ -86,6 +86,14 @@ function fullCommandEvidence(facts: ToolAskFacts): PromptEvidence[] {
|
|||||||
return [{ label: "full command", text: fullCommand, detail: null }];
|
return [{ label: "full command", text: fullCommand, detail: null }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Extra context supplied by the concrete tool that exposes shell semantics. */
|
||||||
|
function invokedShellInputPreviewEvidence(facts: ToolAskFacts): PromptEvidence[] {
|
||||||
|
const invokedToolName = distinctInvokedName(facts);
|
||||||
|
if (invokedToolName === null) return [];
|
||||||
|
const preview = facts.formatter?.formatToolInputForPrompt(invokedToolName, facts.input);
|
||||||
|
return preview ? [{ label: "input", text: preview, detail: null }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The per-tool input preview, when a formatter is registered and produces one.
|
* The per-tool input preview, when a formatter is registered and produces one.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -677,7 +677,7 @@ describe("mergeUnifiedConfigs", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shallow-merges shellTools by tool name: override adds without dropping base", () => {
|
it("merges shellTools by tool name: override adds without dropping base", () => {
|
||||||
const merged = mergeUnifiedConfigs(
|
const merged = mergeUnifiedConfigs(
|
||||||
{ shellTools: { exec_command: { commandArgument: "cmd" } } },
|
{ shellTools: { exec_command: { commandArgument: "cmd" } } },
|
||||||
{ shellTools: { run_shell: { commandArgument: "script" } } },
|
{ shellTools: { run_shell: { commandArgument: "script" } } },
|
||||||
@@ -688,17 +688,25 @@ describe("mergeUnifiedConfigs", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("override shellTools replaces a colliding tool's alias wholesale", () => {
|
it("field-merges a colliding shell alias and preserves its decision floor", () => {
|
||||||
const merged = mergeUnifiedConfigs(
|
const merged = mergeUnifiedConfigs(
|
||||||
{
|
{
|
||||||
shellTools: {
|
shellTools: {
|
||||||
exec_command: { commandArgument: "cmd", workdirArgument: "workdir" },
|
exec_command: {
|
||||||
|
commandArgument: "cmd",
|
||||||
|
workdirArgument: "workdir",
|
||||||
|
decisionFloor: "ask",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ shellTools: { exec_command: { commandArgument: "command" } } },
|
{ shellTools: { exec_command: { commandArgument: "command" } } },
|
||||||
);
|
);
|
||||||
expect(merged.shellTools).toEqual({
|
expect(merged.shellTools).toEqual({
|
||||||
exec_command: { commandArgument: "command" },
|
exec_command: {
|
||||||
|
commandArgument: "command",
|
||||||
|
workdirArgument: "workdir",
|
||||||
|
decisionFloor: "ask",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,25 @@ describe("unifiedConfigSchema", () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts ask as a shell alias decision floor", () => {
|
||||||
|
const result = unifiedConfigSchema.safeParse({
|
||||||
|
shellTools: {
|
||||||
|
ssh_bash: { commandArgument: "command", decisionFloor: "ask" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["allow", "deny", "invalid"] as const)(
|
||||||
|
"rejects %s as a shell alias decision floor",
|
||||||
|
(decisionFloor) => {
|
||||||
|
const result = unifiedConfigSchema.safeParse({
|
||||||
|
shellTools: { ssh_bash: { commandArgument: "command", decisionFloor } },
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("rejects an alias missing commandArgument", () => {
|
it("rejects an alias missing commandArgument", () => {
|
||||||
const result = unifiedConfigSchema.safeParse({
|
const result = unifiedConfigSchema.safeParse({
|
||||||
shellTools: { exec_command: { workdirArgument: "workdir" } },
|
shellTools: { exec_command: { workdirArgument: "workdir" } },
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyShellDecisionFloor,
|
||||||
|
SHELL_TOOL_DECISION_FLOOR_PATTERN,
|
||||||
|
} from "#src/handlers/gates/shell-decision-floor";
|
||||||
|
import type { PermissionCheckResult, PermissionState } from "#src/types";
|
||||||
|
|
||||||
|
function check(state: PermissionState, source: PermissionCheckResult["source"] = "bash") {
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
toolName: "bash",
|
||||||
|
source,
|
||||||
|
origin: source === "session" ? ("session" as const) : ("global" as const),
|
||||||
|
command: "pwd",
|
||||||
|
matchedPattern: source === "session" ? "pwd" : "*",
|
||||||
|
} satisfies PermissionCheckResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("applyShellDecisionFloor", () => {
|
||||||
|
it("raises allow to ask", () => {
|
||||||
|
expect(applyShellDecisionFloor(check("allow"), "ask")).toEqual({
|
||||||
|
...check("allow"),
|
||||||
|
state: "ask",
|
||||||
|
source: "bash",
|
||||||
|
matchedPattern: SHELL_TOOL_DECISION_FLOOR_PATTERN,
|
||||||
|
reason: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises a session allow to ask without retaining the session fast path", () => {
|
||||||
|
const result = applyShellDecisionFloor(check("allow", "session"), "ask");
|
||||||
|
expect(result.state).toBe("ask");
|
||||||
|
expect(result.source).toBe("bash");
|
||||||
|
expect(result.matchedPattern).toBe(SHELL_TOOL_DECISION_FLOOR_PATTERN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["ask", "deny"] as const)("preserves an existing %s", (state) => {
|
||||||
|
const original = check(state);
|
||||||
|
expect(applyShellDecisionFloor(original, "ask")).toBe(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when no floor is configured", () => {
|
||||||
|
const original = check("allow");
|
||||||
|
expect(applyShellDecisionFloor(original, undefined)).toBe(original);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -120,6 +120,36 @@ describe("describeToolGate", () => {
|
|||||||
expect(desc.payload.request.invokedToolName).toBe("exec_command");
|
expect(desc.payload.request.invokedToolName).toBe("exec_command");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("records a shell alias formatter preview for review and authorization context", () => {
|
||||||
|
const shell: ShellInvocation = { command: "rm -rf dist", workdir: undefined };
|
||||||
|
const formatter = new ToolPreviewFormatter(
|
||||||
|
{
|
||||||
|
toolInputPreviewMaxLength: TOOL_INPUT_PREVIEW_MAX_LENGTH,
|
||||||
|
toolTextSummaryMaxLength: TOOL_TEXT_SUMMARY_MAX_LENGTH,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
get: (name) =>
|
||||||
|
name === "ssh_bash"
|
||||||
|
? () => "SSH target 'packaging-server' in remote cwd '/srv/build'"
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const desc = describeToolGate(
|
||||||
|
makeTcc({ toolName: "ssh_bash", input: { command: "rm -rf dist" } }),
|
||||||
|
makeCheckResult("ask", { toolName: "bash", source: "bash", command: "rm -rf dist" }),
|
||||||
|
formatter,
|
||||||
|
undefined,
|
||||||
|
shell,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(desc.promptDetails.toolInputPreview).toBe(
|
||||||
|
"SSH target 'packaging-server' in remote cwd '/srv/build'",
|
||||||
|
);
|
||||||
|
expect(desc.logContext.toolInputPreview).toBe(
|
||||||
|
"SSH target 'packaging-server' in remote cwd '/srv/build'",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns mcp surface with target in decision.value for MCP tools", () => {
|
it("returns mcp surface with target in decision.value for MCP tools", () => {
|
||||||
const check = makeCheckResult("ask", {
|
const check = makeCheckResult("ask", {
|
||||||
toolName: "mcp",
|
toolName: "mcp",
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ const execShellTools = {
|
|||||||
exec_command: { commandArgument: "cmd", workdirArgument: "workdir" },
|
exec_command: { commandArgument: "cmd", workdirArgument: "workdir" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const reviewedExecShellTools = {
|
||||||
|
exec_command: {
|
||||||
|
commandArgument: "cmd",
|
||||||
|
workdirArgument: "workdir",
|
||||||
|
decisionFloor: "ask" as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
describe("shell-tool alias gating (#574)", () => {
|
describe("shell-tool alias gating (#574)", () => {
|
||||||
it("denies an aliased command that a bash: rule denies", async () => {
|
it("denies an aliased command that a bash: rule denies", async () => {
|
||||||
const { handler, events } = makeHandler({
|
const { handler, events } = makeHandler({
|
||||||
@@ -84,6 +92,37 @@ describe("shell-tool alias gating (#574)", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("raises an allowed aliased command to ask before execution", async () => {
|
||||||
|
const prompter = denyingPrompter();
|
||||||
|
const { handler, events } = makeHandler({
|
||||||
|
shellTools: reviewedExecShellTools,
|
||||||
|
tools: ["exec_command"],
|
||||||
|
prompter,
|
||||||
|
session: {
|
||||||
|
checkPermission: makeBashCommandCheck({
|
||||||
|
deny: /rm -rf/,
|
||||||
|
denyMatched: "rm -rf *",
|
||||||
|
allowMatched: "*",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler.handleToolCall(
|
||||||
|
makeToolCallEvent("exec_command", { input: { cmd: "git status" } }),
|
||||||
|
makeCtx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prompter.escalate).toHaveBeenCalledOnce();
|
||||||
|
expect(getDecisionEvents(events)).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
surface: "bash",
|
||||||
|
value: "git status",
|
||||||
|
result: "deny",
|
||||||
|
matchedPattern: "<shell-tool-decision-floor>",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("decomposes a chained aliased command so a denied sub-command still blocks", async () => {
|
it("decomposes a chained aliased command so a denied sub-command still blocks", async () => {
|
||||||
const { handler, events } = makeHandler({
|
const { handler, events } = makeHandler({
|
||||||
shellTools: execShellTools,
|
shellTools: execShellTools,
|
||||||
|
|||||||
@@ -189,6 +189,28 @@ describe("buildToolAskPayload", () => {
|
|||||||
),
|
),
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
test("carries a registered shell alias preview as authorization evidence", () => {
|
||||||
|
const formatter = makeFormatter({
|
||||||
|
get: (name) =>
|
||||||
|
name === "ssh_bash"
|
||||||
|
? () => "SSH target 'packaging-server' in remote cwd '/srv/build'"
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
const payload = buildPayload({
|
||||||
|
check: toolResult("bash", { command: "rm -rf dist" }),
|
||||||
|
surface: "bash",
|
||||||
|
invokedToolName: "ssh_bash",
|
||||||
|
input: { command: "rm -rf dist" },
|
||||||
|
formatter,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(findEvidence(payload, "input")).toEqual({
|
||||||
|
label: "input",
|
||||||
|
text: "SSH target 'packaging-server' in remote cwd '/srv/build'",
|
||||||
|
detail: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("mcp", () => {
|
describe("mcp", () => {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Treat `ssh_bash` as a Bash output-compaction alias for ANSI stripping, build/test/Git/linter aggregation, and bounded truncation without enabling remote command rewriting; keep `ssh_find`, `ssh_grep`, and `ssh_read` outside RTK processing.
|
||||||
|
|
||||||
## [0.9.0] - 2026-07-03
|
## [0.9.0] - 2026-07-03
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ Multi-stage pipeline to reduce token consumption:
|
|||||||
| Anchor-Safe Read Compaction | Detects hashline/anchored `read` output and preserves complete edit anchors when filtering or truncating anchored lines |
|
| Anchor-Safe Read Compaction | Detects hashline/anchored `read` output and preserves complete edit anchors when filtering or truncating anchored lines |
|
||||||
| Hard Truncation | Final character limit enforcement |
|
| Hard Truncation | Final character limit enforcement |
|
||||||
|
|
||||||
|
Bash output compaction applies to both the local `bash` tool and `pi-ssh`'s `ssh_bash` output alias. This only sanitizes/compacts returned text: RTK never rewrites remote commands. Structured remote search results from `ssh_find` and `ssh_grep`, and exact remote file reads from `ssh_read`, remain outside RTK processing.
|
||||||
|
|
||||||
### Interactive Settings
|
### Interactive Settings
|
||||||
|
|
||||||
- Tabbed TUI settings modal via `/rtk` command
|
- Tabbed TUI settings modal via `/rtk` command
|
||||||
|
|||||||
@@ -318,6 +318,10 @@ await runTest("session_start refreshes RTK provenance and runtime guard skips mi
|
|||||||
|
|
||||||
assert.equal(rewriteCalls, 1);
|
assert.equal(rewriteCalls, 1);
|
||||||
assert.ok((rewrittenEvent.input as { command: string }).command.includes("rtk git status"));
|
assert.ok((rewrittenEvent.input as { command: string }).command.includes("rtk git status"));
|
||||||
|
const remoteEvent = { toolName: "ssh_bash", input: { command: "git status" } };
|
||||||
|
await toolCallHandler(remoteEvent, createNotificationContext(notifications));
|
||||||
|
assert.equal(remoteEvent.input.command, "git status");
|
||||||
|
assert.equal(rewriteCalls, 1, "ssh_bash output support must not enable remote command rewriting");
|
||||||
assert.ok(execCommands.includes("/opt/rtk/bin/rtk"));
|
assert.ok(execCommands.includes("/opt/rtk/bin/rtk"));
|
||||||
writeTestConfig(false);
|
writeTestConfig(false);
|
||||||
});
|
});
|
||||||
@@ -367,6 +371,26 @@ await runTest("tool execution lifecycle sanitizes streamed bash output", async (
|
|||||||
};
|
};
|
||||||
await endHandler(endEvent, {});
|
await endHandler(endEvent, {});
|
||||||
assert.equal(firstText(endEvent.result.content), "error: build failed\n");
|
assert.equal(firstText(endEvent.result.content), "error: build failed\n");
|
||||||
|
|
||||||
|
await startHandler(
|
||||||
|
{ toolName: "ssh_bash", toolCallId: "ssh-bash-1", args: { command: "npm test" } },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const remoteUpdate = {
|
||||||
|
toolName: "ssh_bash",
|
||||||
|
toolCallId: "ssh-bash-1",
|
||||||
|
args: { command: "npm test" },
|
||||||
|
partialResult: { content: [{ type: "text", text: "\x1B[32mremote test\x1B[0m\n" }] },
|
||||||
|
};
|
||||||
|
await updateHandler(remoteUpdate, {});
|
||||||
|
assert.equal(firstText(remoteUpdate.partialResult.content), "remote test\n");
|
||||||
|
const remoteEnd = {
|
||||||
|
toolName: "ssh_bash",
|
||||||
|
toolCallId: "ssh-bash-1",
|
||||||
|
result: { content: [{ type: "text", text: "\x1B[31mremote failed\x1B[0m\n" }] },
|
||||||
|
};
|
||||||
|
await endHandler(remoteEnd, {});
|
||||||
|
assert.equal(firstText(remoteEnd.result.content), "remote failed\n");
|
||||||
});
|
});
|
||||||
|
|
||||||
await runTest("tool_result lifecycle merges compaction metadata with existing details", async () => {
|
await runTest("tool_result lifecycle merges compaction metadata with existing details", async () => {
|
||||||
|
|||||||
@@ -234,8 +234,8 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared guard for bash tool-execution events: skips when compaction is
|
* Shared guard for local and SSH bash-output execution events: skips when
|
||||||
* disabled, normalizes the event to a record, tracks the bash command, and
|
* compaction is disabled, normalizes the event, tracks the command, and
|
||||||
* returns the record for further handler-specific processing.
|
* returns the record for further handler-specific processing.
|
||||||
*/
|
*/
|
||||||
const recordBashEventIfEnabled = (
|
const recordBashEventIfEnabled = (
|
||||||
@@ -246,7 +246,7 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const eventRecord = toRecord(event);
|
const eventRecord = toRecord(event);
|
||||||
if (eventRecord.toolName !== "bash") {
|
if (eventRecord.toolName !== "bash" && eventRecord.toolName !== "ssh_bash") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,7 +409,7 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
|
|||||||
|
|
||||||
pi.on("tool_execution_end", async (event) => {
|
pi.on("tool_execution_end", async (event) => {
|
||||||
const eventRecord = toRecord(event);
|
const eventRecord = toRecord(event);
|
||||||
if (eventRecord.toolName !== "bash") {
|
if (eventRecord.toolName !== "bash" && eventRecord.toolName !== "ssh_bash") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,34 @@ function assertNoPartialHashlineAnchors(text: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runTest("ssh_bash output uses Bash compaction while remote search output remains untouched", () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
const sshBash = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "ssh_bash",
|
||||||
|
input: { command: "npm test" },
|
||||||
|
content: [{ type: "text", text: "\x1B[32mTests: 2 passed\x1B[0m\n" }],
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
assert.equal(sshBash.changed, true);
|
||||||
|
assert.ok(sshBash.techniques.includes("ansi"));
|
||||||
|
assert.equal(firstTextBlock(sshBash.content).includes("\x1B"), false);
|
||||||
|
|
||||||
|
for (const toolName of ["ssh_find", "ssh_grep"]) {
|
||||||
|
const search = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName,
|
||||||
|
input: { pattern: "TODO" },
|
||||||
|
content: [{ type: "text", text: "\x1B[31mremote result\x1B[0m\n" }],
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
assert.equal(search.changed, false);
|
||||||
|
assert.deepEqual(search.techniques, []);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
runTest("precision read with offset keeps exact output (no source/smart/hard truncation)", () => {
|
runTest("precision read with offset keeps exact output (no source/smart/hard truncation)", () => {
|
||||||
const config = cloneDefaultConfig();
|
const config = cloneDefaultConfig();
|
||||||
setReadCompaction(config, true);
|
setReadCompaction(config, true);
|
||||||
|
|||||||
@@ -627,7 +627,7 @@ export function compactToolResult(
|
|||||||
|
|
||||||
const { changed, mapped: nextContent } = mapTextContentBlocks(sourceContent, (contentBlock) => {
|
const { changed, mapped: nextContent } = mapTextContentBlocks(sourceContent, (contentBlock) => {
|
||||||
let transformed = { text: contentBlock.text, techniques: [] as string[] };
|
let transformed = { text: contentBlock.text, techniques: [] as string[] };
|
||||||
if (event.toolName === "bash") {
|
if (event.toolName === "bash" || event.toolName === "ssh_bash") {
|
||||||
transformed = compactBashText(contentBlock.text, normalizeCommand(input), config);
|
transformed = compactBashText(contentBlock.text, normalizeCommand(input), config);
|
||||||
} else if (event.toolName === "read") {
|
} else if (event.toolName === "read") {
|
||||||
const normalizedPath = normalizePath(input);
|
const normalizedPath = normalizePath(input);
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
|
.pi/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- Keep the SSH transport persistent while making `ssh_bash` explicitly stateless: every call starts in the active remote cwd and local Pi cwd/environment metadata can no longer leak into remote execution.
|
||||||
|
- Add reviewed, sequential `ssh_cd` workspace changes for persistent remote cwd selection without a hidden interactive shell; dependent remote calls are explicitly deferred until the change succeeds.
|
||||||
|
- Clarify remote shell prompt metadata and add regression coverage for cwd isolation, atomic workspace changes, failure preservation, and subsequent shell/file/search path resolution.
|
||||||
|
- Make `ssh_connect` a sequential reviewed state transition so establishing or replacing the active connection cannot overlap dependent remote operations.
|
||||||
|
- Separate remote-search execution cwd from its absolute target root, including regression coverage for single-file `ssh_grep` targets.
|
||||||
|
- Resolve relative and `~/` remote paths to absolute targets in permission previews while retaining the original request and excluding remote paths from local filesystem gates.
|
||||||
|
- Replace unbounded HOME/cwd capture with cancellable, bounded, random-marker probes that accept one absolute POSIX path and preserve prior state on failure.
|
||||||
|
- Allow up to four independent SSH exec channels while retaining serialized SFTP operations; queued exec cancellation is immediate.
|
||||||
|
- Report bounded imported host ID alternatives for unknown IDs and add actionable resolved-root guidance for 30-second remote-search timeouts.
|
||||||
|
- Normalize `ssh_grep` hidden-file, basename-glob, and portable ERE behavior across ripgrep, Git, and fallback backends; reject `~user` paths.
|
||||||
|
- Treat fallback grep exit 1 as a genuine no-match without hiding find or grep errors, separate backend stderr, and use bounded NUL-delimited grep records for newline-safe filenames.
|
||||||
|
|
||||||
|
## 0.9.0 - 2026-08-21
|
||||||
|
|
||||||
|
- Add reviewed, agent-callable `ssh_connect` for explicitly imported hosts.
|
||||||
|
- Remove the user-side `/ssh` command, `--ssh` startup flag, session-resume reconnect, and remote user-`!` override.
|
||||||
|
- Route connection authorization through the existing `pi-permission-system` and AutoReview chain with a non-secret target preview.
|
||||||
|
- Disconnect active transports at session shutdown and require an explicit reviewed connection in each session.
|
||||||
|
|
||||||
|
## 0.8.0 - 2026-08-20
|
||||||
|
|
||||||
|
- Replace the OpenSSH subprocess, ControlMaster, and persistent PTY implementation with a pure `ssh2` transport.
|
||||||
|
- Add password, keyboard-interactive, private-key, and encrypted-private-key authentication.
|
||||||
|
- Add selective `ssh -G` host import through the root `ssh_config.sh` helper.
|
||||||
|
- Add an AES-256-GCM host vault with an adjacent owner-only random key.
|
||||||
|
- Pin and verify SHA256 SSH host-key fingerprints.
|
||||||
|
- Move remote file operations to SFTP and keep dedicated collision-free `ssh_*` tools.
|
||||||
|
- Restrict runtime connections to explicitly imported host IDs and reject ProxyJump/ProxyCommand in this release.
|
||||||
|
- Remove automatic remote project instruction discovery and preserve the existing permission-system/auto-review integration.
|
||||||
|
- Add reviewed `ssh_find` and `ssh_grep` tools with bounded `fd`/Git/POSIX fallback pipelines and no remote installation.
|
||||||
|
- Route `ssh_bash` output through RTK's Bash compaction alias while leaving remote command rewriting and structured search/read output disabled.
|
||||||
|
- Preserve adaptive-search backend exit status through bounded pipelines, treating genuine no-match results as success while surfacing invalid regexes, missing roots, and backend failures.
|
||||||
|
- Add regression coverage for private-key/passphrase and keyboard-interactive authentication, pinned-key probing, SFTP reads/access/writes/rename fallback, image detection, aborts, timeouts, and disconnect fail-closed behavior.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Helmut Januschka
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# pi-ssh
|
||||||
|
|
||||||
|
`pi-ssh` keeps Pi and its local tools on the local machine while exposing explicit remote tools over a persistent Node `ssh2` connection:
|
||||||
|
|
||||||
|
- `ssh_connect`
|
||||||
|
- `ssh_cd`
|
||||||
|
- `ssh_read`
|
||||||
|
- `ssh_write`
|
||||||
|
- `ssh_edit`
|
||||||
|
- `ssh_find`
|
||||||
|
- `ssh_grep`
|
||||||
|
- `ssh_bash`
|
||||||
|
|
||||||
|
The extension does not override Pi's local `read`, `write`, `edit`, `find`, `grep`, or `bash` tools.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Runtime connections are pure `ssh2`; the extension does not spawn OpenSSH and does not require `sshpass`, `ControlMaster`, or passwordless login. Remote file operations use SFTP and remote shell commands use an SSH exec channel. The SSH transport persists, but commands intentionally use fresh non-interactive Bash processes rather than a hidden stateful PTY.
|
||||||
|
|
||||||
|
Hosts must be explicitly imported before use. OpenSSH remains only an import source: the configuration helper runs `ssh -G <alias>` once to resolve the selected alias, then stores the resulting endpoint and authentication data in the pi-ssh vault. Later changes to `~/.ssh/config` require re-importing the host.
|
||||||
|
|
||||||
|
Connections are agent-controlled: when the user explicitly names an imported host as part of a concrete remote task, the model calls `ssh_connect`. The connection request enters the normal permission and auto-review chain before any network connection is opened. The extension does not register `/ssh`, `--ssh`, automatic session resume, or remote user-`!` overrides.
|
||||||
|
|
||||||
|
ProxyJump and ProxyCommand are intentionally rejected in the first ssh2 release.
|
||||||
|
|
||||||
|
## Configure hosts
|
||||||
|
|
||||||
|
From the installed bundle or this repository:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./ssh_config.sh import
|
||||||
|
./ssh_config.sh import packaging-server
|
||||||
|
```
|
||||||
|
|
||||||
|
The helper:
|
||||||
|
|
||||||
|
1. lets you select concrete aliases from `~/.ssh/config`;
|
||||||
|
2. uses `ssh -G` to resolve HostName, User, Port, and IdentityFile;
|
||||||
|
3. asks whether the selected host uses a private key or password;
|
||||||
|
4. asks for a private-key passphrase when needed;
|
||||||
|
5. obtains and displays the server's SHA256 host-key fingerprint;
|
||||||
|
6. connects with `ssh2` to verify authentication;
|
||||||
|
7. optionally assigns a display label and group;
|
||||||
|
8. writes the encrypted vault.
|
||||||
|
|
||||||
|
Other commands:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./ssh_config.sh list
|
||||||
|
./ssh_config.sh update packaging-server
|
||||||
|
./ssh_config.sh remove packaging-server
|
||||||
|
./ssh_config.sh rotate-key
|
||||||
|
```
|
||||||
|
|
||||||
|
Secret prompts require an interactive terminal. Passwords and passphrases are never passed as command-line arguments.
|
||||||
|
|
||||||
|
## Vault
|
||||||
|
|
||||||
|
The default paths are:
|
||||||
|
|
||||||
|
```text
|
||||||
|
${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/pi-ssh/
|
||||||
|
├── hosts.enc
|
||||||
|
└── vault.key
|
||||||
|
```
|
||||||
|
|
||||||
|
On POSIX systems the directory is mode `700` and both files are mode `600`. `hosts.enc` is encrypted and authenticated with AES-256-GCM; `vault.key` contains the adjacent random 256-bit key. Writes use a fresh IV and an atomic temporary-file rename. Plaintext configuration is never written to a temporary file.
|
||||||
|
|
||||||
|
This is deliberately a **casual-disclosure boundary**, not protection against compromise of the local account: anyone who can read both files can decrypt the vault. Encryption prevents the host configuration and passwords from being exposed by accidentally viewing or copying `hosts.enc` alone.
|
||||||
|
|
||||||
|
The encrypted payload contains host endpoints, pinned host-key fingerprints, groups, and either:
|
||||||
|
|
||||||
|
- a private-key path plus optional passphrase; or
|
||||||
|
- the server password.
|
||||||
|
|
||||||
|
Private-key contents are not copied into the vault.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Ask Pi for a concrete task on an imported host, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Connect to packaging-server, inspect the API logs, and identify the recent 500 errors.
|
||||||
|
```
|
||||||
|
|
||||||
|
The model first calls:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ssh_connect({ hostId: "packaging-server" })
|
||||||
|
```
|
||||||
|
|
||||||
|
It may set `remotePath` to an absolute path, `~`, or a path beginning with `~/`. The model calls sequential `ssh_connect` as a separate step and waits for the reviewed connection to succeed before using other `ssh_*` tools. `ssh_cd` explicitly changes the active remote workspace for subsequent shell, relative file, and relative search operations; it accepts absolute paths, paths relative to the current remote cwd, and `~/` paths relative to remote HOME. The model must also call `ssh_cd` as a separate step and wait for its successful result before issuing dependent remote operations. Only imported host IDs are accepted; arbitrary `user@host` targets are rejected. Unknown IDs report a bounded list of available imported IDs without exposing credentials. A new connection replaces the previous active connection, and session shutdown disconnects it.
|
||||||
|
|
||||||
|
## Runtime behavior
|
||||||
|
|
||||||
|
- `ssh_connect` is the only runtime connection surface; it is agent-callable, permission-reviewed, sequential, and cancellable so connection replacement cannot overlap another tool call or remain stuck after user cancellation.
|
||||||
|
- One persistent `ssh2` client is used for the active host; no interactive shell or PTY state is retained.
|
||||||
|
- `ssh_cd` is a sequential state transition: it validates a remote directory and updates the active workspace without reconnecting. Dependent tool calls must wait for it to succeed.
|
||||||
|
- HOME/cwd probes use bounded random-marker framing, require one absolute POSIX path, and preserve the previous state on malformed output, timeout, or cancellation.
|
||||||
|
- Independent exec channels use bounded concurrency of four; shared SFTP operations remain serialized.
|
||||||
|
- Each `ssh_bash` call opens a fresh exec channel and runs under `bash -lc` in the active remote cwd. A command-local `cd` is temporary, and `cd`, `export`, alias, function, or other shell state inside one call does not persist to the next call.
|
||||||
|
- SFTP provides remote reads and writes.
|
||||||
|
- Writes use a temporary remote file and prefer OpenSSH's atomic rename SFTP extension when the server supports it.
|
||||||
|
- A pinned SHA256 host-key mismatch fails closed.
|
||||||
|
- Connection loss fails closed; the extension does not silently replay a command.
|
||||||
|
- Remote `AGENTS.md` and `CLAUDE.md` files are never discovered or injected.
|
||||||
|
- `ssh_find` and `ssh_grep` perform capability detection inside each approved call and return at most 200 bounded result lines. Search targets are always separate from the execution cwd, so `ssh_grep` can target a single remote file without attempting to enter it as a directory. A 30-second timeout reports the resolved root and instructs the model to narrow it with `ssh_find`.
|
||||||
|
- RTK treats only `ssh_bash` as a Bash output-compaction alias; it does not rewrite remote commands or process remote search/read results.
|
||||||
|
- Permission previews display resolved absolute remote paths together with the original relative or `~/` request; remote paths never enter local filesystem gates.
|
||||||
|
|
||||||
|
### Adaptive remote search
|
||||||
|
|
||||||
|
`ssh_find` performs fixed-substring path matching with this backend order:
|
||||||
|
|
||||||
|
```text
|
||||||
|
fd → fdfind → git ls-files → find
|
||||||
|
```
|
||||||
|
|
||||||
|
`ssh_grep` defaults to literal, case-insensitive content matching with this backend order. Hidden paths are excluded unless `includeHidden` is true, and `include` is a basename-only glob such as `*.ts` (globs containing `/` are rejected):
|
||||||
|
|
||||||
|
```text
|
||||||
|
ripgrep → git grep → find + grep
|
||||||
|
```
|
||||||
|
|
||||||
|
No backend is installed or uploaded. The tools use what the server already provides, report the chosen backend and truncation state, and cap `limit` at 200. `literal: false` accepts the portable POSIX ERE subset shared by the backends; Git and fallback grep are explicitly run in ERE mode. Grep results use bounded NUL-delimited path/line/content records, so newline-containing filenames cannot corrupt match counts or truncation. Backend stderr is kept out of result rows and is reported as a warning on success or failure detail on error.
|
||||||
|
|
||||||
|
A genuine no-match result—including the `find + grep` fallback—succeeds with zero rows; invalid regexes, missing roots, and backend failures remain errors. Search paths accept absolute paths, relative paths, `~`, and `~/...`; `~user` expansion is rejected. Narrow `path` and `pattern` when truncated rather than increasing the limit. Direct `find`, `fd`, `grep`, and `rg` commands remain forbidden through `ssh_bash`; use the structured search tools instead.
|
||||||
|
|
||||||
|
The remote host must provide `bash`. SFTP support is required for file tools.
|
||||||
|
|
||||||
|
## Permission-system integration
|
||||||
|
|
||||||
|
All remote operations enter the bundle's existing permission chain:
|
||||||
|
|
||||||
|
- `ssh_connect` starts as `ask`, so AutoReview can verify that the direct user request names the requested imported host;
|
||||||
|
- `ssh_cd`, `ssh_read`, `ssh_write`, `ssh_edit`, `ssh_find`, and `ssh_grep` start as `ask`;
|
||||||
|
- `ssh_bash` uses the full deterministic Bash policy and `decisionFloor: "ask"`;
|
||||||
|
- deterministic hard denies remain denies;
|
||||||
|
- asks enter the configured `auto-review` authorizer;
|
||||||
|
- reviewer failures defer to the normal terminal prompt.
|
||||||
|
|
||||||
|
Permission evidence includes the configured host ID, resolved endpoint, port, remote cwd, and a bounded operation summary. It never includes passwords, passphrases, private-key contents, or the vault key. Remote paths are not normalized as local filesystem paths.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Import only servers you control or trust.
|
||||||
|
- Verify host-key fingerprints through an independent channel before accepting them.
|
||||||
|
- Treat both vault files as secrets even though `hosts.enc` is encrypted.
|
||||||
|
- The extension's threat model does not protect credentials from malicious code already running as the same local user.
|
||||||
|
- Password keyboard-interactive mode reuses the configured password for the server's prompts; use it only with a trusted pinned host.
|
||||||
|
- Remote content reaches the model only through an explicit reviewed `ssh_*` tool call.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Important files:
|
||||||
|
|
||||||
|
- `index.ts` — Pi extension and tool registration
|
||||||
|
- `src/ssh2-transport.ts` — persistent ssh2, exec, and SFTP transport
|
||||||
|
- `src/remote-bash.ts` — stateless Bash adapter pinned to the active remote cwd
|
||||||
|
- `src/remote-cwd.ts` — explicit remote workspace path resolution
|
||||||
|
- `src/config.ts` — validated configuration types
|
||||||
|
- `src/vault.ts` — AES-GCM vault
|
||||||
|
- `src/import.ts` — `ssh -G` import helpers
|
||||||
|
- `scripts/ssh-config.mjs` — interactive configuration CLI
|
||||||
|
- `permission-integration.ts` — permission-system bridge
|
||||||
|
|
||||||
|
## Upstream and license
|
||||||
|
|
||||||
|
This maintained fork originates from `pansapiens/pi-ssh`; see [UPSTREAM.md](UPSTREAM.md). The pure ssh2 design also references the transport architecture in `@99percentpeople/pi-ssh-remote` without adopting its local-tool override model. Licensed under MIT; see [LICENSE](LICENSE).
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Upstream Source
|
||||||
|
|
||||||
|
This directory was imported from the source repository and is maintained directly in `my-pi`.
|
||||||
|
|
||||||
|
- Upstream: <https://github.com/pansapiens/pi-ssh>
|
||||||
|
- Initial snapshot: `e9a1059a0f37ab14b6a73ee608cb203edf803f31`
|
||||||
|
- Upstream version: `0.7.0`
|
||||||
|
- Snapshot date: 2026-06-23
|
||||||
|
|
||||||
|
Pure-`ssh2` connection management was designed with reference to `@99percentpeople/pi-ssh-remote` in <https://github.com/99percentpeople/pi-extensions> at commit `fe4c44f3b4d4b52bd2c31d6d4d5a833c4ad60d9d`. The local implementation keeps this fork's dedicated `ssh_*` tools and does not import that extension's local-tool override or OpenSSH fallback model.
|
||||||
|
|
||||||
|
The directory is not a Git submodule and intentionally contains no nested `.git`, `node_modules`, or generated build artifacts. Future upstream changes must be reviewed and ported from an explicit tag or commit without overwriting local modifications.
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# pi-ssh extension specification
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Pi and its default tools remain local. Explicit collision-free tools perform selected operations on one configured remote server:
|
||||||
|
|
||||||
|
- `ssh_connect`
|
||||||
|
- `ssh_cd`
|
||||||
|
- `ssh_read`
|
||||||
|
- `ssh_write`
|
||||||
|
- `ssh_edit`
|
||||||
|
- `ssh_find`
|
||||||
|
- `ssh_grep`
|
||||||
|
- `ssh_bash`
|
||||||
|
|
||||||
|
The extension is for operating servers from local projects, not for replacing Pi's local workspace. It never auto-loads remote project instructions.
|
||||||
|
|
||||||
|
## Connection model
|
||||||
|
|
||||||
|
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 <alias>
|
||||||
|
→ ssh -G <alias>
|
||||||
|
→ user chooses private-key or password auth
|
||||||
|
→ ssh2 obtains host key, user confirms fingerprint
|
||||||
|
→ ssh2 verifies authentication
|
||||||
|
→ encrypted vault is updated
|
||||||
|
```
|
||||||
|
|
||||||
|
The resulting host ID is the only runtime selector. When a direct user request names that imported host as part of a concrete remote task, the agent calls:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ssh_connect({ hostId: "<host-id>", remotePath?: "/absolute/or/~/path" })
|
||||||
|
```
|
||||||
|
|
||||||
|
`ssh_connect` is the only runtime connection surface. It is a sequential, cancellable model tool governed by `pi-permission-system`: the agent calls it as a separate step and waits for success before dependent remote tools, so establishing or replacing a connection cannot overlap another tool call. Unknown IDs return at most ten sorted imported IDs and never endpoint credentials. There is no `/ssh` command, `--ssh` flag, session-resume reconnect, or user `!` remote-shell override.
|
||||||
|
|
||||||
|
Arbitrary `user@host`, port overrides, ProxyJump, and ProxyCommand are not supported in the first pure-ssh2 version. Unsupported imported configuration is rejected rather than ignored.
|
||||||
|
|
||||||
|
## Vault
|
||||||
|
|
||||||
|
Default paths:
|
||||||
|
|
||||||
|
```text
|
||||||
|
${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/pi-ssh/hosts.enc
|
||||||
|
${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/pi-ssh/vault.key
|
||||||
|
```
|
||||||
|
|
||||||
|
The directory is mode `700` and files are mode `600` on POSIX. A random adjacent 256-bit key encrypts and authenticates the complete validated configuration with AES-256-GCM. Each write uses a fresh IV and atomic replacement; plaintext is never written to a temporary file.
|
||||||
|
|
||||||
|
The adjacent-key design prevents casual disclosure of `hosts.enc` alone but does not protect against code able to read both files as the same local user.
|
||||||
|
|
||||||
|
The encrypted payload includes endpoint, authentication data, host groups, default cwd, and a pinned SHA256 host-key fingerprint. Private keys remain in their original paths; only their path and optional passphrase are stored.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Supported methods:
|
||||||
|
|
||||||
|
- password;
|
||||||
|
- single-password keyboard-interactive;
|
||||||
|
- OpenSSH private key;
|
||||||
|
- encrypted private key with passphrase.
|
||||||
|
|
||||||
|
Passwords, passphrases, private-key contents, vault key, and decrypted configuration must never enter command arguments, logs, permission evidence, system prompts, or Pi session entries.
|
||||||
|
|
||||||
|
## Host-key verification
|
||||||
|
|
||||||
|
Import displays the observed host-key algorithm and SHA256 fingerprint for explicit confirmation. Runtime `hostVerifier` must compare the server key with the pinned fingerprint and fail closed on any mismatch. Key changes require explicit host update.
|
||||||
|
|
||||||
|
## Transport
|
||||||
|
|
||||||
|
One persistent `ssh2.Client` belongs to the active host. The transport persists, but there is no persistent interactive shell or PTY. Connection loss fails closed and no operation is automatically replayed.
|
||||||
|
|
||||||
|
### Shell
|
||||||
|
|
||||||
|
`ssh_bash` opens a fresh exec channel and non-interactive Bash process for every call. Commands run through `bash -lc` after changing to the active remote cwd. The cwd supplied by Pi's local Bash factory is ignored at the transport adapter boundary; only connection state may select the remote cwd. A command-local `cd` is intentionally temporary; shell-local `cd`, exports, aliases, functions, and other process state do not persist across calls. Output streams through the normal Pi Bash operations callback. Abort or timeout closes the channel without reconnecting or replaying.
|
||||||
|
|
||||||
|
`ssh_cd` is the explicit persistent workspace operation and declares Pi's sequential execution mode so it cannot overlap sibling tool calls. It resolves absolute paths, paths relative to the active remote cwd, and `~/` paths relative to remote HOME; validates the directory by executing `pwd` from it; and updates connection state without reconnecting. The model calls it as a separate step and waits for success before issuing subsequent relative file/search operations or `ssh_bash` calls that depend on the updated cwd.
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
SFTP implements file operations:
|
||||||
|
|
||||||
|
- read and access checks;
|
||||||
|
- recursive directory creation;
|
||||||
|
- remote write through a temporary file;
|
||||||
|
- atomic OpenSSH rename extension when available;
|
||||||
|
- safe direct-write fallback when SFTP v3 cannot replace an existing target.
|
||||||
|
|
||||||
|
Remote paths map from Pi's local factory cwd into the selected remote cwd, but permission-system access extractors prevent those paths from entering local path normalization.
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
Search tools execute one capability-adaptive, bounded shell pipeline inside the already approved tool call. They never install or upload binaries.
|
||||||
|
|
||||||
|
- `ssh_find`: `fd` → `fdfind` → `git ls-files` → `find`; fixed filename/path substring semantics.
|
||||||
|
- `ssh_grep`: `rg` → `git grep` → `find + grep`; literal case-insensitive semantics by default, optional hidden-path inclusion, basename-only include globs, and a portable POSIX ERE subset when literal mode is disabled.
|
||||||
|
- each backend emits at most `limit + 1` NUL-delimited path/line/content records so truncation is explicit and newline filenames remain one result; public `limit` is 1–200; each returned line is capped.
|
||||||
|
- relative paths resolve against remote cwd and `~/` against remote home; other `~user` forms are rejected.
|
||||||
|
- the search target root is passed to the backend as an absolute argument, while the search process executes from the active remote cwd; a single-file `ssh_grep` target is never used as a process cwd.
|
||||||
|
- searches time out after 30 seconds with the resolved root and explicit guidance to narrow it before retrying.
|
||||||
|
- user strings are single-quoted as shell arguments and NUL/newline input is rejected.
|
||||||
|
- backend stdout contains only parseable results; stderr is captured independently for warnings and failure diagnostics.
|
||||||
|
- capability detection occurs only within the reviewed search call.
|
||||||
|
- search output is normalized by `pi-ssh` and excluded from RTK compaction.
|
||||||
|
|
||||||
|
Direct search commands remain denied through `ssh_bash`; structured search tools are the authoritative remote search surface.
|
||||||
|
|
||||||
|
## Permission boundary
|
||||||
|
|
||||||
|
There is one permission gate: `pi-permission-system`.
|
||||||
|
|
||||||
|
- `ssh_connect` defaults to `ask`; its preview resolves the imported host ID to the non-secret endpoint, port, and requested/default cwd before connection.
|
||||||
|
- `ssh_cd`, `ssh_read`, `ssh_write`, `ssh_edit`, `ssh_find`, and `ssh_grep` default to `ask`; remote path extractors disable local path normalization for all six tools, while previews resolve relative and `~/` requests against the active remote cwd/HOME and display both requested and absolute paths.
|
||||||
|
- `ssh_bash` is a Bash-semantic `shellTools` alias with `decisionFloor: "ask"`.
|
||||||
|
- Bash hard denies remain denies.
|
||||||
|
- All asks enter the configured authorizer chain.
|
||||||
|
- Permission service absence or bridge registration failure must not install a permissive fallback.
|
||||||
|
|
||||||
|
Evidence includes configured host ID, endpoint, port, remote cwd, and a bounded operation summary, never credentials.
|
||||||
|
|
||||||
|
Connection HOME/cwd discovery and `ssh_cd` validation use a bounded random-marker probe over a cancellable exec channel. The parser accepts exactly one framed absolute POSIX path; startup banners, malformed/multiline values, overflow, timeout, or cancellation cannot update connection state. Independent exec operations use at most four concurrent SSH channels, while SFTP operations remain serialized.
|
||||||
|
|
||||||
|
## Session lifecycle
|
||||||
|
|
||||||
|
Connections are created only by an approved `ssh_connect` call after session start. They are not persisted or automatically resumed. Connecting another imported host disposes the previous client, and session shutdown disposes the active client.
|
||||||
|
|
||||||
|
The system prompt states that default tools are local, `ssh_*` tools are remote, and each `ssh_bash` call starts a fresh non-interactive shell after a connection becomes active. It directs persistent workspace changes through `ssh_cd` and does not include remote file content.
|
||||||
+369
@@ -0,0 +1,369 @@
|
|||||||
|
import { homedir } from "node:os";
|
||||||
|
import { posix as posixPath } from "node:path";
|
||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import {
|
||||||
|
createBashTool,
|
||||||
|
createEditTool,
|
||||||
|
createReadTool,
|
||||||
|
createWriteTool,
|
||||||
|
type EditOperations,
|
||||||
|
type ReadOperations,
|
||||||
|
type WriteOperations,
|
||||||
|
} from "@earendil-works/pi-coding-agent";
|
||||||
|
import { getPermissionsService, PERMISSIONS_READY_CHANNEL } from "@gotgenes/pi-permission-system";
|
||||||
|
import {
|
||||||
|
installSshPermissionIntegration,
|
||||||
|
type SshPermissionConnection,
|
||||||
|
} from "./permission-integration.ts";
|
||||||
|
import {
|
||||||
|
getConfiguredHost,
|
||||||
|
parseConnectInput,
|
||||||
|
SSH_CONNECT_TOOL_METADATA,
|
||||||
|
type HostSelection,
|
||||||
|
} from "./src/agent-connection.ts";
|
||||||
|
import { loadVault } from "./src/vault.ts";
|
||||||
|
import { Ssh2Transport, type RemoteTransport } from "./src/ssh2-transport.ts";
|
||||||
|
import type { SshHostConfig } from "./src/config.ts";
|
||||||
|
import {
|
||||||
|
runRemoteFind,
|
||||||
|
runRemoteGrep,
|
||||||
|
type RemoteFindInput,
|
||||||
|
type RemoteGrepInput,
|
||||||
|
} from "./src/remote-search.ts";
|
||||||
|
import { createRemoteBashOps } from "./src/remote-bash.ts";
|
||||||
|
import {
|
||||||
|
changeRemoteCwd,
|
||||||
|
mapLocalPathToRemote,
|
||||||
|
SSH_CD_EXECUTION_MODE,
|
||||||
|
} from "./src/remote-cwd.ts";
|
||||||
|
import { probeRemotePath } from "./src/remote-probe.ts";
|
||||||
|
|
||||||
|
|
||||||
|
interface SshConnection {
|
||||||
|
hostId: string;
|
||||||
|
remote: string;
|
||||||
|
port: number;
|
||||||
|
remoteCwd: string;
|
||||||
|
remoteHome: string;
|
||||||
|
localCwd: string;
|
||||||
|
localHome: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function createRemoteReadOps(connection: SshConnection, transport: RemoteTransport): ReadOperations {
|
||||||
|
return {
|
||||||
|
readFile: (absolutePath) => transport.readFile(mapLocalPathToRemote(absolutePath, connection)),
|
||||||
|
access: (absolutePath) => transport.ensureReadable(mapLocalPathToRemote(absolutePath, connection)),
|
||||||
|
detectImageMimeType: async (absolutePath) => {
|
||||||
|
try {
|
||||||
|
return await transport.detectImageMimeType(mapLocalPathToRemote(absolutePath, connection));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRemoteWriteOps(connection: SshConnection, transport: RemoteTransport): WriteOperations {
|
||||||
|
return {
|
||||||
|
mkdir: (absoluteDir) => transport.mkdir(mapLocalPathToRemote(absoluteDir, connection)),
|
||||||
|
writeFile: (absolutePath, content) =>
|
||||||
|
transport.writeFile(mapLocalPathToRemote(absolutePath, connection), Buffer.from(content, "utf8")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRemoteEditOps(connection: SshConnection, transport: RemoteTransport): EditOperations {
|
||||||
|
const read = createRemoteReadOps(connection, transport);
|
||||||
|
const write = createRemoteWriteOps(connection, transport);
|
||||||
|
return {
|
||||||
|
readFile: read.readFile,
|
||||||
|
writeFile: write.writeFile,
|
||||||
|
access: (absolutePath) => transport.ensureReadableWritable(mapLocalPathToRemote(absolutePath, connection)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function resolveRequestedPath(selection: HostSelection, host: SshHostConfig, remoteHome: string, remotePwd: string): string {
|
||||||
|
const requested = selection.remotePath ?? host.defaultCwd ?? remotePwd;
|
||||||
|
if (requested === "~") return remoteHome;
|
||||||
|
if (requested.startsWith("~/")) return posixPath.join(remoteHome, requested.slice(2));
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function connectSelection(
|
||||||
|
selection: HostSelection,
|
||||||
|
localCwd: string,
|
||||||
|
localHome: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{ connection: SshConnection; transport: Ssh2Transport }> {
|
||||||
|
const config = loadVault();
|
||||||
|
const host = getConfiguredHost(config, selection.hostId);
|
||||||
|
const transport = new Ssh2Transport(host);
|
||||||
|
try {
|
||||||
|
await transport.connect(signal);
|
||||||
|
const remoteHome = await probeRemotePath(transport, "home", ".", signal);
|
||||||
|
const remotePwd = await probeRemotePath(transport, "cwd", ".", signal);
|
||||||
|
const requestedPath = resolveRequestedPath(selection, host, remoteHome, remotePwd);
|
||||||
|
const remoteCwd = await probeRemotePath(transport, "cwd", requestedPath, signal);
|
||||||
|
return {
|
||||||
|
connection: {
|
||||||
|
hostId: selection.hostId,
|
||||||
|
remote: `${selection.hostId} [${host.user}@${host.hostName}]`,
|
||||||
|
port: host.port,
|
||||||
|
remoteCwd,
|
||||||
|
remoteHome,
|
||||||
|
localCwd,
|
||||||
|
localHome,
|
||||||
|
},
|
||||||
|
transport,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await transport.dispose();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeRequestedTarget(input: Record<string, unknown>): SshPermissionConnection | null {
|
||||||
|
try {
|
||||||
|
const selection = parseConnectInput(input);
|
||||||
|
const host = getConfiguredHost(loadVault(), selection.hostId);
|
||||||
|
return {
|
||||||
|
remote: `${selection.hostId} [${host.user}@${host.hostName}]`,
|
||||||
|
port: host.port,
|
||||||
|
remoteCwd: selection.remotePath ?? host.defaultCwd ?? "<server default>",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function piSshExtension(pi: ExtensionAPI): void {
|
||||||
|
|
||||||
|
const localCwd = process.cwd();
|
||||||
|
const localHome = homedir();
|
||||||
|
const localRead = createReadTool(localCwd);
|
||||||
|
const localWrite = createWriteTool(localCwd);
|
||||||
|
const localEdit = createEditTool(localCwd);
|
||||||
|
const localBash = createBashTool(localCwd, { exposeSessionEnvironment: false });
|
||||||
|
|
||||||
|
let connection: SshConnection | null = null;
|
||||||
|
let transport: Ssh2Transport | null = null;
|
||||||
|
const getConnection = () => connection;
|
||||||
|
installSshPermissionIntegration(pi, getConnection, {
|
||||||
|
getPermissionsService,
|
||||||
|
permissionsReadyChannel: PERMISSIONS_READY_CHANNEL,
|
||||||
|
getConnectTarget: describeRequestedTarget,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requireSsh = (toolName: string): { connection: SshConnection; transport: Ssh2Transport } => {
|
||||||
|
if (!connection || !transport) {
|
||||||
|
throw new Error(`${toolName} requires an active SSH2 connection. Call ssh_connect with an imported host ID first.`);
|
||||||
|
}
|
||||||
|
return { connection, transport };
|
||||||
|
};
|
||||||
|
|
||||||
|
const activateConnection = async (
|
||||||
|
nextConnection: SshConnection,
|
||||||
|
nextTransport: Ssh2Transport,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (transport) await transport.dispose();
|
||||||
|
connection = nextConnection;
|
||||||
|
transport = nextTransport;
|
||||||
|
console.log(`pi-ssh connected: ${nextConnection.remote}:${nextConnection.remoteCwd} (port ${nextConnection.port})`);
|
||||||
|
};
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
...SSH_CONNECT_TOOL_METADATA,
|
||||||
|
async execute(_id, params, signal) {
|
||||||
|
const selection = parseConnectInput(params as Record<string, unknown>);
|
||||||
|
const connected = await connectSelection(selection, localCwd, localHome, signal);
|
||||||
|
if (signal?.aborted) {
|
||||||
|
await connected.transport.dispose();
|
||||||
|
throw new Error("SSH connection aborted");
|
||||||
|
}
|
||||||
|
await activateConnection(connected.connection, connected.transport);
|
||||||
|
const text = `Connected to ${connected.connection.remote}:${connected.connection.remoteCwd} (port ${connected.connection.port}).`;
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
details: {
|
||||||
|
hostId: connected.connection.hostId,
|
||||||
|
remote: connected.connection.remote,
|
||||||
|
port: connected.connection.port,
|
||||||
|
remoteCwd: connected.connection.remoteCwd,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "ssh_cd",
|
||||||
|
label: "ssh_cd",
|
||||||
|
description: "Change the active SSH workspace directory as a reviewed, persistent state transition. Call ssh_cd separately and wait for it to succeed before issuing ssh_bash or relative remote file/search operations that depend on the new directory.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
path: {
|
||||||
|
type: "string",
|
||||||
|
minLength: 1,
|
||||||
|
description: "Remote directory: absolute, relative to the active remote cwd, or ~/ relative to remote HOME",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["path"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
executionMode: SSH_CD_EXECUTION_MODE,
|
||||||
|
async execute(_id, params, signal) {
|
||||||
|
const active = requireSsh("ssh_cd");
|
||||||
|
const changed = await changeRemoteCwd(
|
||||||
|
active.connection,
|
||||||
|
(params as { path: string }).path,
|
||||||
|
(requestedCwd) => probeRemotePath(active.transport, "cwd", requestedCwd, signal),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Remote cwd changed from ${changed.previousCwd} to ${changed.remoteCwd}. Dependent remote tools may now run.` }],
|
||||||
|
details: changed,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
...localRead,
|
||||||
|
name: "ssh_read",
|
||||||
|
label: "ssh_read",
|
||||||
|
description: `Read a file through the active SSH2 connection. ${localRead.description}`,
|
||||||
|
async execute(id, params, signal, onUpdate) {
|
||||||
|
const active = requireSsh("ssh_read");
|
||||||
|
return createReadTool(localCwd, { operations: createRemoteReadOps(active.connection, active.transport) })
|
||||||
|
.execute(id, params, signal, onUpdate);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
...localWrite,
|
||||||
|
name: "ssh_write",
|
||||||
|
label: "ssh_write",
|
||||||
|
description: `Write a file through the active SSH2 connection. ${localWrite.description}`,
|
||||||
|
async execute(id, params, signal, onUpdate) {
|
||||||
|
const active = requireSsh("ssh_write");
|
||||||
|
return createWriteTool(localCwd, { operations: createRemoteWriteOps(active.connection, active.transport) })
|
||||||
|
.execute(id, params, signal, onUpdate);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
...localEdit,
|
||||||
|
name: "ssh_edit",
|
||||||
|
label: "ssh_edit",
|
||||||
|
description: `Edit a file through the active SSH2 connection. ${localEdit.description}`,
|
||||||
|
async execute(id, params, signal, onUpdate) {
|
||||||
|
const active = requireSsh("ssh_edit");
|
||||||
|
return createEditTool(localCwd, { operations: createRemoteEditOps(active.connection, active.transport) })
|
||||||
|
.execute(id, params, signal, onUpdate);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "ssh_find",
|
||||||
|
label: "ssh_find",
|
||||||
|
description: "Find remote files by a fixed filename/path substring using fd, git ls-files, or find. Results are bounded and require an active SSH2 connection.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
pattern: { type: "string", description: "Fixed substring to match in remote file paths" },
|
||||||
|
path: { type: "string", description: "Remote root path; defaults to the active remote cwd" },
|
||||||
|
caseSensitive: { type: "boolean", description: "Use case-sensitive matching (default: false)" },
|
||||||
|
includeHidden: { type: "boolean", description: "Include hidden paths (default: false)" },
|
||||||
|
limit: { type: "integer", minimum: 1, maximum: 200, description: "Maximum results (default: 50)" },
|
||||||
|
},
|
||||||
|
required: ["pattern"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
async execute(_id, params, signal) {
|
||||||
|
const active = requireSsh("ssh_find");
|
||||||
|
const result = await runRemoteFind(
|
||||||
|
active.transport,
|
||||||
|
params as RemoteFindInput,
|
||||||
|
active.connection.remoteCwd,
|
||||||
|
active.connection.remoteHome,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: result.text }],
|
||||||
|
details: { backend: result.backend, matchCount: result.matchCount, truncated: result.truncated },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "ssh_grep",
|
||||||
|
label: "ssh_grep",
|
||||||
|
description: "Search remote file contents using ripgrep, git grep, or grep with consistent hidden-file, basename-glob, and portable POSIX ERE semantics. Literal case-insensitive matching is the default; results are bounded and require an active SSH2 connection.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
pattern: { type: "string", description: "Text to search for, or a portable POSIX ERE when literal is false" },
|
||||||
|
path: { type: "string", description: "Remote root path; defaults to the active remote cwd" },
|
||||||
|
literal: { type: "boolean", description: "Treat pattern as fixed text; false uses portable POSIX ERE syntax (default: true)" },
|
||||||
|
caseSensitive: { type: "boolean", description: "Use case-sensitive matching (default: false)" },
|
||||||
|
include: { type: "string", description: "Optional basename-only glob such as *.ts; / is not allowed" },
|
||||||
|
includeHidden: { type: "boolean", description: "Search hidden files and directories (default: false)" },
|
||||||
|
limit: { type: "integer", minimum: 1, maximum: 200, description: "Maximum result lines (default: 50)" },
|
||||||
|
},
|
||||||
|
required: ["pattern"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
async execute(_id, params, signal) {
|
||||||
|
const active = requireSsh("ssh_grep");
|
||||||
|
const result = await runRemoteGrep(
|
||||||
|
active.transport,
|
||||||
|
params as RemoteGrepInput,
|
||||||
|
active.connection.remoteCwd,
|
||||||
|
active.connection.remoteHome,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: result.text }],
|
||||||
|
details: { backend: result.backend, matchCount: result.matchCount, truncated: result.truncated },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
...localBash,
|
||||||
|
name: "ssh_bash",
|
||||||
|
label: "ssh_bash",
|
||||||
|
description: "Run a non-search shell command on the active SSH server. Every call starts a fresh non-interactive Bash process in the active remote cwd; cd, exported variables, aliases, and other shell state do not persist. For a persistent workspace change, call ssh_cd separately and wait for success before calling ssh_bash. Use command-local cd only for an intentionally temporary directory change. Relative paths are remote. Use ssh_find or ssh_grep instead of find, fd, grep, or rg.",
|
||||||
|
promptSnippet: undefined,
|
||||||
|
promptGuidelines: undefined,
|
||||||
|
async execute(id, params, signal, onUpdate) {
|
||||||
|
const active = requireSsh("ssh_bash");
|
||||||
|
return createBashTool(active.connection.remoteCwd, {
|
||||||
|
operations: createRemoteBashOps(active.connection, active.transport),
|
||||||
|
exposeSessionEnvironment: false,
|
||||||
|
}).execute(id, params, signal, onUpdate);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_shutdown", async () => {
|
||||||
|
if (transport) await transport.dispose();
|
||||||
|
transport = null;
|
||||||
|
connection = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("before_agent_start", async (event) => {
|
||||||
|
if (!connection) return;
|
||||||
|
const guidance =
|
||||||
|
`\n\n# Remote SSH connection\n\n` +
|
||||||
|
`An SSH2 connection to configured host ${connection.remote} on port ${connection.port} is active. ` +
|
||||||
|
`The default read/write/edit/bash/find/grep tools act on the LOCAL machine. ` +
|
||||||
|
`Use ssh_cd, ssh_read, ssh_write, ssh_edit, ssh_find, ssh_grep, and ssh_bash for explicit remote operations. ` +
|
||||||
|
`Use ssh_find before ssh_grep to narrow remote searches; both tools return bounded results and choose the fastest available remote backend. ` +
|
||||||
|
`Remote operations are rooted at ${connection.remoteCwd}; relative paths resolve against that directory. ` +
|
||||||
|
`For a persistent workspace change, call ssh_cd as a separate step and wait for its successful result before issuing dependent remote tool calls. ` +
|
||||||
|
`Each ssh_bash call starts a fresh non-interactive shell, so use command-local cd only for an intentionally temporary change; cd, exports, aliases, and other shell state inside one command do not persist to the next call.`;
|
||||||
|
return { systemPrompt: `${event.systemPrompt}${guidance}` };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"name": "pi-ssh",
|
||||||
|
"version": "0.9.0",
|
||||||
|
"description": "Reviewed agent-controlled SSH tools for Pi using a pure ssh2 transport and encrypted host vault",
|
||||||
|
"type": "module",
|
||||||
|
"private": false,
|
||||||
|
"main": "index.ts",
|
||||||
|
"files": [
|
||||||
|
"index.ts",
|
||||||
|
"permission-integration.ts",
|
||||||
|
"src",
|
||||||
|
"scripts",
|
||||||
|
"README.md",
|
||||||
|
"CHANGELOG.md",
|
||||||
|
"extension-spec.md",
|
||||||
|
"UPSTREAM.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"pi-package",
|
||||||
|
"pi",
|
||||||
|
"pi-coding-agent",
|
||||||
|
"theme",
|
||||||
|
"ssh",
|
||||||
|
"dark-mode",
|
||||||
|
"ghostty"
|
||||||
|
],
|
||||||
|
"author": "Helmut Januschka",
|
||||||
|
"contributors": [
|
||||||
|
"Iliya Anastasov <ilia.anastasov@gmail.com>",
|
||||||
|
"Furkan Bilgin <info@furkanbilgin.net>",
|
||||||
|
"Tim Smith",
|
||||||
|
"Ignas (fairusage)"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/pansapiens/pi-ssh.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/pansapiens/pi-ssh/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/pansapiens/pi-ssh#readme",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test test/*.test.ts"
|
||||||
|
},
|
||||||
|
"pi": {
|
||||||
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"jiti": "2.7.0",
|
||||||
|
"ssh2": "1.17.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@earendil-works/pi-coding-agent": "*",
|
||||||
|
"@gotgenes/pi-permission-system": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import type { PermissionsService } from "@gotgenes/pi-permission-system";
|
||||||
|
import { resolveRemoteCwd } from "./src/remote-cwd.ts";
|
||||||
|
|
||||||
|
export interface SshPermissionConnection {
|
||||||
|
remote: string;
|
||||||
|
port?: number;
|
||||||
|
remoteCwd: string;
|
||||||
|
remoteHome?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SshPermissionIntegrationDependencies {
|
||||||
|
getPermissionsService: () => PermissionsService | undefined;
|
||||||
|
permissionsReadyChannel: string;
|
||||||
|
getConnectTarget?: (input: Record<string, unknown>) => SshPermissionConnection | null;
|
||||||
|
warn?: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PermissionIntegrationApi = Pick<ExtensionAPI, "events" | "on">;
|
||||||
|
type ToolInput = Record<string, unknown>;
|
||||||
|
|
||||||
|
const REMOTE_PATH_TOOLS = ["ssh_cd", "ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"] as const;
|
||||||
|
const REMOTE_TOOLS = ["ssh_connect", ...REMOTE_PATH_TOOLS, "ssh_bash"] as const;
|
||||||
|
|
||||||
|
function inline(value: string, limit = 240): string {
|
||||||
|
const normalized = value.replace(/\s+/g, " ").trim();
|
||||||
|
return normalized.length > limit ? `${normalized.slice(0, limit)}…` : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringField(input: ToolInput, key: string): string | undefined {
|
||||||
|
const value = input[key];
|
||||||
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countLines(value: string): number {
|
||||||
|
return value.length === 0 ? 0 : value.split(/\r\n|\r|\n/).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTarget(connection: SshPermissionConnection | null): string {
|
||||||
|
if (connection === null) return "an inactive SSH connection";
|
||||||
|
const port = connection.port === undefined ? "" : `:${connection.port}`;
|
||||||
|
return `SSH target '${inline(connection.remote)}${port}' in remote cwd '${inline(connection.remoteCwd)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remotePathDetail(
|
||||||
|
requested: string,
|
||||||
|
connection: SshPermissionConnection | null,
|
||||||
|
label = "remote path",
|
||||||
|
): string {
|
||||||
|
let resolved: string | undefined;
|
||||||
|
if (connection?.remoteHome) {
|
||||||
|
try {
|
||||||
|
resolved = resolveRemoteCwd(requested, connection.remoteCwd, connection.remoteHome);
|
||||||
|
} catch {
|
||||||
|
// Execution performs authoritative validation; preserve the raw request.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!resolved || resolved === requested) return `${label} '${inline(requested)}'`;
|
||||||
|
return `${label} '${inline(resolved)}' (requested '${inline(requested)}')`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSshPermissionInput(
|
||||||
|
toolName: string,
|
||||||
|
input: ToolInput,
|
||||||
|
connection: SshPermissionConnection | null,
|
||||||
|
): string {
|
||||||
|
const target = formatTarget(connection);
|
||||||
|
const path = stringField(input, "path");
|
||||||
|
|
||||||
|
if (toolName === "ssh_connect") {
|
||||||
|
if (connection !== null) return `${target}; establish a persistent SSH2 connection`;
|
||||||
|
const hostId = inline(stringField(input, "hostId") ?? "<unspecified>");
|
||||||
|
const remotePath = stringField(input, "remotePath");
|
||||||
|
return `requested imported SSH host '${hostId}'${remotePath ? ` in remote cwd '${inline(remotePath)}'` : ""}; establish a persistent SSH2 connection`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName === "ssh_cd") {
|
||||||
|
return `${target}; change the active ${path ? remotePathDetail(path, connection, "remote cwd to") : "remote cwd to '<unspecified>'"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName === "ssh_read") {
|
||||||
|
const details = path ? [remotePathDetail(path, connection)] : ["an unspecified remote path"];
|
||||||
|
if (typeof input.offset === "number") details.push(`offset ${input.offset}`);
|
||||||
|
if (typeof input.limit === "number") details.push(`limit ${input.limit}`);
|
||||||
|
return `${target}; read ${details.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName === "ssh_write") {
|
||||||
|
const content = stringField(input, "content") ?? "";
|
||||||
|
return `${target}; write ${path ? remotePathDetail(path, connection) : "remote path '<unspecified>'"} (${countLines(content)} lines, ${content.length} characters)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName === "ssh_edit") {
|
||||||
|
const oldText = stringField(input, "oldText") ?? "";
|
||||||
|
const newText = stringField(input, "newText") ?? "";
|
||||||
|
return `${target}; edit ${path ? remotePathDetail(path, connection) : "remote path '<unspecified>'"} (replace ${countLines(oldText)} lines with ${countLines(newText)} lines)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName === "ssh_find" || toolName === "ssh_grep") {
|
||||||
|
const pattern = inline(stringField(input, "pattern") ?? "<unspecified>");
|
||||||
|
const operation = toolName === "ssh_find" ? "find remote files" : "search remote file contents";
|
||||||
|
const details = [
|
||||||
|
remotePathDetail(path ?? ".", connection, "under"),
|
||||||
|
`for '${pattern}'`,
|
||||||
|
`limit ${typeof input.limit === "number" ? input.limit : 50}`,
|
||||||
|
];
|
||||||
|
const include = stringField(input, "include");
|
||||||
|
if (include) details.push(`file glob '${inline(include)}'`);
|
||||||
|
return `${target}; ${operation} ${details.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName === "ssh_bash") {
|
||||||
|
return `${target}; execute the separately displayed remote shell command`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register pi-ssh's permission previews and remote-path semantics with the
|
||||||
|
* bundle's published permission service.
|
||||||
|
*
|
||||||
|
* Remote paths must not enter the local `path` / `external_directory` gates:
|
||||||
|
* those gates resolve against the local cwd and filesystem. Returning
|
||||||
|
* `undefined` from an explicit extractor disables the default `input.path`
|
||||||
|
* convention, leaving the dedicated ssh_* policy surfaces authoritative.
|
||||||
|
*/
|
||||||
|
export function installSshPermissionIntegration(
|
||||||
|
pi: PermissionIntegrationApi,
|
||||||
|
getConnection: () => SshPermissionConnection | null,
|
||||||
|
dependencies: SshPermissionIntegrationDependencies,
|
||||||
|
): () => void {
|
||||||
|
const getPermissionsService = dependencies.getPermissionsService;
|
||||||
|
const warn = dependencies.warn ?? ((message: string) => console.warn(`[pi-ssh] ${message}`));
|
||||||
|
let registeredService: PermissionsService | undefined;
|
||||||
|
let disposers: Array<() => void> = [];
|
||||||
|
|
||||||
|
const unregister = (): void => {
|
||||||
|
for (const dispose of disposers.splice(0).reverse()) {
|
||||||
|
try {
|
||||||
|
dispose();
|
||||||
|
} catch (error) {
|
||||||
|
warn(`failed to unregister permission integration: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registeredService = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryRegister = (): void => {
|
||||||
|
const service = getPermissionsService();
|
||||||
|
if (service === undefined || (service === registeredService && disposers.length > 0)) return;
|
||||||
|
unregister();
|
||||||
|
|
||||||
|
const pending: Array<() => void> = [];
|
||||||
|
try {
|
||||||
|
for (const toolName of REMOTE_TOOLS) {
|
||||||
|
pending.push(
|
||||||
|
service.registerToolInputFormatter(toolName, (input) => {
|
||||||
|
const target = toolName === "ssh_connect"
|
||||||
|
? dependencies.getConnectTarget?.(input) ?? null
|
||||||
|
: getConnection();
|
||||||
|
return formatSshPermissionInput(toolName, input, target);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const toolName of REMOTE_PATH_TOOLS) {
|
||||||
|
pending.push(service.registerToolAccessExtractor(toolName, () => undefined));
|
||||||
|
}
|
||||||
|
disposers = pending;
|
||||||
|
registeredService = service;
|
||||||
|
} catch (error) {
|
||||||
|
for (const dispose of pending.reverse()) {
|
||||||
|
try {
|
||||||
|
dispose();
|
||||||
|
} catch {
|
||||||
|
// Best-effort rollback; the permission gate remains conservative.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
warn(`failed to register permission integration: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pi.on("session_start", tryRegister);
|
||||||
|
pi.events.on(dependencies.permissionsReadyChannel, tryRegister);
|
||||||
|
pi.on("session_shutdown", unregister);
|
||||||
|
|
||||||
|
// Handles extension load after the permission service has already published.
|
||||||
|
tryRegister();
|
||||||
|
return unregister;
|
||||||
|
}
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { createJiti } from "jiti";
|
||||||
|
|
||||||
|
const jiti = createJiti(import.meta.url, { interopDefault: true });
|
||||||
|
await jiti.import("./ssh-config.mjs");
|
||||||
Executable
+202
@@ -0,0 +1,202 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { stdin, stdout } from "node:process";
|
||||||
|
import readline from "node:readline/promises";
|
||||||
|
import {
|
||||||
|
effectiveValue,
|
||||||
|
effectiveValues,
|
||||||
|
listDirectSshAliases,
|
||||||
|
resolveOpenSshAlias,
|
||||||
|
} from "../src/import.ts";
|
||||||
|
import { expandUserPath } from "../src/config.ts";
|
||||||
|
import { probeHostKey, Ssh2Transport } from "../src/ssh2-transport.ts";
|
||||||
|
import { probeRemotePath } from "../src/remote-probe.ts";
|
||||||
|
import { loadVaultOrEmpty, rotateVaultKey, saveVault } from "../src/vault.ts";
|
||||||
|
|
||||||
|
async function question(prompt) {
|
||||||
|
const rl = readline.createInterface({ input: stdin, output: stdout });
|
||||||
|
try {
|
||||||
|
return (await rl.question(prompt)).trim();
|
||||||
|
} finally {
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hiddenQuestion(prompt) {
|
||||||
|
if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") {
|
||||||
|
throw new Error("secret input requires an interactive terminal");
|
||||||
|
}
|
||||||
|
stdout.write(prompt);
|
||||||
|
stdin.setRawMode(true);
|
||||||
|
stdin.resume();
|
||||||
|
let value = "";
|
||||||
|
try {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const onData = (chunk) => {
|
||||||
|
const text = chunk.toString("utf8");
|
||||||
|
for (const char of text) {
|
||||||
|
if (char === "\u0003") {
|
||||||
|
stdin.off("data", onData);
|
||||||
|
stdout.write("\n");
|
||||||
|
reject(new Error("cancelled"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (char === "\r" || char === "\n") {
|
||||||
|
stdin.off("data", onData);
|
||||||
|
stdout.write("\n");
|
||||||
|
resolve(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (char === "\u007f" || char === "\b") value = value.slice(0, -1);
|
||||||
|
else if (char >= " ") value += char;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
stdin.on("data", onData);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
stdin.setRawMode(false);
|
||||||
|
stdin.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirm(prompt, defaultYes = false) {
|
||||||
|
const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
|
||||||
|
const value = (await question(`${prompt}${suffix}`)).toLowerCase();
|
||||||
|
return value ? value === "y" || value === "yes" : defaultYes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireValue(config, key, alias) {
|
||||||
|
const value = effectiveValue(config, key);
|
||||||
|
if (!value) throw new Error(`ssh -G ${alias} did not provide ${key}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseAliases(arguments_) {
|
||||||
|
if (arguments_.length > 0) return arguments_;
|
||||||
|
const aliases = listDirectSshAliases();
|
||||||
|
if (aliases.length === 0) {
|
||||||
|
const explicit = await question("SSH Host alias to import: ");
|
||||||
|
if (!explicit) throw new Error("no SSH alias selected");
|
||||||
|
return [explicit];
|
||||||
|
}
|
||||||
|
console.log("Available SSH aliases:");
|
||||||
|
aliases.forEach((alias, index) => console.log(` ${index + 1}. ${alias}`));
|
||||||
|
const selection = await question("Select numbers or aliases (comma-separated): ");
|
||||||
|
const chosen = selection.split(",").map((value) => value.trim()).filter(Boolean).map((value) => {
|
||||||
|
const index = Number.parseInt(value, 10);
|
||||||
|
return Number.isInteger(index) && String(index) === value && aliases[index - 1] ? aliases[index - 1] : value;
|
||||||
|
});
|
||||||
|
if (chosen.length === 0) throw new Error("no SSH alias selected");
|
||||||
|
return [...new Set(chosen)];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importHost(config, alias) {
|
||||||
|
const effective = resolveOpenSshAlias(alias);
|
||||||
|
const proxyJump = effectiveValue(effective, "proxyjump");
|
||||||
|
const proxyCommand = effectiveValue(effective, "proxycommand");
|
||||||
|
if (proxyJump && proxyJump !== "none") throw new Error(`${alias} uses ProxyJump, which is not supported yet`);
|
||||||
|
if (proxyCommand && proxyCommand !== "none") throw new Error(`${alias} uses ProxyCommand, which is not supported yet`);
|
||||||
|
|
||||||
|
const hostName = requireValue(effective, "hostname", alias);
|
||||||
|
const user = requireValue(effective, "user", alias);
|
||||||
|
const port = Number.parseInt(requireValue(effective, "port", alias), 10);
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`${alias} has an invalid port`);
|
||||||
|
console.log(`\n${alias}: ${user}@${hostName}:${port}`);
|
||||||
|
|
||||||
|
const identityFiles = effectiveValues(effective, "identityfile")
|
||||||
|
.map((path) => path.replace(/^"|"$/g, ""))
|
||||||
|
.filter((path) => existsSync(expandUserPath(path)));
|
||||||
|
const defaultMode = identityFiles.length > 0 ? "key" : "password";
|
||||||
|
const modeInput = (await question(`Authentication [key/password] (${defaultMode}): `)).toLowerCase();
|
||||||
|
const mode = modeInput || defaultMode;
|
||||||
|
let auth;
|
||||||
|
if (mode === "key") {
|
||||||
|
const suggested = identityFiles[0] ?? "";
|
||||||
|
const identityFile = await question(`Private key path${suggested ? ` (${suggested})` : ""}: `) || suggested;
|
||||||
|
if (!identityFile || !existsSync(expandUserPath(identityFile))) throw new Error(`private key does not exist: ${identityFile}`);
|
||||||
|
const encrypted = await confirm("Does this private key require a passphrase?");
|
||||||
|
const passphrase = encrypted ? await hiddenQuestion("Private key passphrase: ") : undefined;
|
||||||
|
if (encrypted && !passphrase) throw new Error("private key passphrase cannot be empty");
|
||||||
|
auth = { type: "private-key", identityFile, ...(passphrase ? { passphrase } : {}) };
|
||||||
|
} else if (mode === "password") {
|
||||||
|
const password = await hiddenQuestion("Server password: ");
|
||||||
|
if (!password) throw new Error("server password cannot be empty");
|
||||||
|
auth = { type: "password", password, method: "auto" };
|
||||||
|
} else {
|
||||||
|
throw new Error("authentication must be key or password");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Obtaining SSH host key fingerprint...");
|
||||||
|
const hostKey = await probeHostKey(hostName, port, user);
|
||||||
|
console.log(`Host key: ${hostKey.algorithm} ${hostKey.fingerprint}`);
|
||||||
|
if (!await confirm("Trust and pin this host key?")) throw new Error(`host key for ${alias} was not trusted`);
|
||||||
|
|
||||||
|
const label = await question(`Display label (${config.hosts[alias]?.label ?? alias}): `) || config.hosts[alias]?.label || alias;
|
||||||
|
const host = { label, sourceAlias: alias, hostName, user, port, auth, hostKey };
|
||||||
|
const transport = new Ssh2Transport(host);
|
||||||
|
try {
|
||||||
|
console.log("Testing SSH2 authentication...");
|
||||||
|
await transport.connect();
|
||||||
|
await probeRemotePath(transport, "home");
|
||||||
|
const remoteCwd = await probeRemotePath(transport, "cwd");
|
||||||
|
host.defaultCwd = remoteCwd;
|
||||||
|
console.log(`Connected successfully; default cwd: ${remoteCwd}`);
|
||||||
|
} finally {
|
||||||
|
await transport.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
config.hosts[alias] = host;
|
||||||
|
const groupId = await question("Group id (optional): ");
|
||||||
|
if (groupId) {
|
||||||
|
const groupLabel = config.groups?.[groupId]?.label || await question(`Group label (${groupId}): `) || groupId;
|
||||||
|
config.groups ??= {};
|
||||||
|
const hosts = new Set(config.groups[groupId]?.hosts ?? []);
|
||||||
|
hosts.add(alias);
|
||||||
|
config.groups[groupId] = { label: groupLabel, hosts: [...hosts] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [command = "list", ...arguments_] = process.argv.slice(2);
|
||||||
|
if (command === "list") {
|
||||||
|
const config = loadVaultOrEmpty();
|
||||||
|
const entries = Object.entries(config.hosts);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
console.log("No pi-ssh hosts configured.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [id, host] of entries) console.log(`${id}\t${host.label ?? id}\t${host.user}@${host.hostName}:${host.port}\t${host.auth.type}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "rotate-key") {
|
||||||
|
rotateVaultKey();
|
||||||
|
console.log("Rotated pi-ssh vault key.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "remove") {
|
||||||
|
const id = arguments_[0];
|
||||||
|
if (!id) throw new Error("usage: ssh_config.sh remove <host-id>");
|
||||||
|
const config = loadVaultOrEmpty();
|
||||||
|
if (!config.hosts[id]) throw new Error(`unknown pi-ssh host: ${id}`);
|
||||||
|
delete config.hosts[id];
|
||||||
|
for (const group of Object.values(config.groups ?? {})) group.hosts = group.hosts.filter((host) => host !== id);
|
||||||
|
saveVault(config);
|
||||||
|
console.log(`Removed ${id}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command !== "import" && command !== "update") {
|
||||||
|
throw new Error("usage: ssh_config.sh [list|import [alias...]|update <alias>|remove <id>|rotate-key]");
|
||||||
|
}
|
||||||
|
const config = loadVaultOrEmpty();
|
||||||
|
const aliases = command === "update"
|
||||||
|
? [arguments_[0] || (() => { throw new Error("usage: ssh_config.sh update <alias>"); })()]
|
||||||
|
: await chooseAliases(arguments_);
|
||||||
|
for (const alias of aliases) await importHost(config, alias);
|
||||||
|
saveVault(config);
|
||||||
|
console.log(`Saved ${aliases.length} host(s) to the encrypted pi-ssh vault.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`pi-ssh config: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { PiSshConfig, SshHostConfig } from "./config.ts";
|
||||||
|
|
||||||
|
export interface HostSelection {
|
||||||
|
hostId: string;
|
||||||
|
remotePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SSH_CONNECT_TOOL_METADATA = {
|
||||||
|
name: "ssh_connect",
|
||||||
|
label: "ssh_connect",
|
||||||
|
description: "Establish a persistent SSH2 connection to an explicitly imported host as a sequential state transition. Use this as a separate step when the user names a remote server as part of a concrete task, and wait for success before calling dependent ssh_* tools; the connection request is reviewed before any network connection is opened.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
hostId: { type: "string", description: "Imported pi-ssh host ID explicitly named by the user" },
|
||||||
|
remotePath: { type: "string", description: "Optional remote cwd; must be absolute, ~, or start with ~/" },
|
||||||
|
},
|
||||||
|
required: ["hostId"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
executionMode: "sequential",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function parseConnectInput(input: Record<string, unknown>): HostSelection {
|
||||||
|
const hostId = typeof input.hostId === "string" ? input.hostId.trim() : "";
|
||||||
|
if (!hostId) throw new Error("SSH host id is required");
|
||||||
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(hostId)) throw new Error(`invalid pi-ssh host id: ${hostId}`);
|
||||||
|
|
||||||
|
if (input.remotePath === undefined) return { hostId };
|
||||||
|
if (typeof input.remotePath !== "string") throw new Error("remote path must be a string");
|
||||||
|
const remotePath = input.remotePath.trim();
|
||||||
|
if (!remotePath || !(remotePath === "~" || remotePath.startsWith("~/") || remotePath.startsWith("/"))) {
|
||||||
|
throw new Error("remote path must be absolute or start with ~/");
|
||||||
|
}
|
||||||
|
return { hostId, remotePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfiguredHost(config: PiSshConfig, hostId: string): SshHostConfig {
|
||||||
|
const host = config.hosts[hostId];
|
||||||
|
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 <alias>`);
|
||||||
|
}
|
||||||
|
const shown = hostIds.slice(0, 10);
|
||||||
|
const remaining = hostIds.length - shown.length;
|
||||||
|
const available = `${shown.join(", ")}${remaining > 0 ? `, … (+${remaining} more)` : ""}`;
|
||||||
|
throw new Error(`unknown pi-ssh host '${hostId}'; available imported host IDs: ${available}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { homedir } from "node:os";
|
||||||
|
import { isAbsolute, join, resolve } from "node:path";
|
||||||
|
|
||||||
|
export interface PasswordAuthConfig {
|
||||||
|
type: "password";
|
||||||
|
password: string;
|
||||||
|
method?: "auto" | "password" | "keyboard-interactive";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrivateKeyAuthConfig {
|
||||||
|
type: "private-key";
|
||||||
|
identityFile: string;
|
||||||
|
passphrase?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SshAuthConfig = PasswordAuthConfig | PrivateKeyAuthConfig;
|
||||||
|
|
||||||
|
export interface SshHostConfig {
|
||||||
|
label?: string;
|
||||||
|
sourceAlias?: string;
|
||||||
|
hostName: string;
|
||||||
|
user: string;
|
||||||
|
port: number;
|
||||||
|
defaultCwd?: string;
|
||||||
|
auth: SshAuthConfig;
|
||||||
|
hostKey: {
|
||||||
|
algorithm: string;
|
||||||
|
fingerprint: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SshGroupConfig {
|
||||||
|
label: string;
|
||||||
|
hosts: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PiSshConfig {
|
||||||
|
version: 1;
|
||||||
|
hosts: Record<string, SshHostConfig>;
|
||||||
|
groups?: Record<string, SshGroupConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VaultPaths {
|
||||||
|
directory: string;
|
||||||
|
encryptedConfig: string;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||||
|
|
||||||
|
export function resolveVaultPaths(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
home = homedir(),
|
||||||
|
): VaultPaths {
|
||||||
|
const base = platform === "win32"
|
||||||
|
? env.APPDATA || join(home, "AppData", "Roaming")
|
||||||
|
: env.XDG_CONFIG_HOME || join(home, ".config");
|
||||||
|
const directory = join(base, "my-pi", "pi-ssh");
|
||||||
|
return {
|
||||||
|
directory,
|
||||||
|
encryptedConfig: join(directory, "hosts.enc"),
|
||||||
|
key: join(directory, "vault.key"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandUserPath(value: string, home = homedir()): string {
|
||||||
|
if (value === "~") return home;
|
||||||
|
if (value.startsWith("~/")) return join(home, value.slice(2));
|
||||||
|
return isAbsolute(value) ? value : resolve(home, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown, name: string): Record<string, unknown> {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
|
throw new Error(`${name} must be an object`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonEmptyString(value: unknown, name: string): string {
|
||||||
|
if (typeof value !== "string" || value.trim().length === 0) {
|
||||||
|
throw new Error(`${name} must be a non-empty string`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(value: unknown, name: string): string | undefined {
|
||||||
|
return value === undefined ? undefined : nonEmptyString(value, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAuth(value: unknown, name: string): SshAuthConfig {
|
||||||
|
const auth = record(value, name);
|
||||||
|
if (auth.type === "password") {
|
||||||
|
const method = auth.method;
|
||||||
|
if (method !== undefined && method !== "auto" && method !== "password" && method !== "keyboard-interactive") {
|
||||||
|
throw new Error(`${name}.method must be auto, password, or keyboard-interactive`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "password",
|
||||||
|
password: nonEmptyString(auth.password, `${name}.password`),
|
||||||
|
...(method === undefined ? {} : { method }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (auth.type === "private-key") {
|
||||||
|
return {
|
||||||
|
type: "private-key",
|
||||||
|
identityFile: nonEmptyString(auth.identityFile, `${name}.identityFile`),
|
||||||
|
...(auth.passphrase === undefined ? {} : { passphrase: nonEmptyString(auth.passphrase, `${name}.passphrase`) }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`${name}.type must be password or private-key`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateHost(value: unknown, name: string): SshHostConfig {
|
||||||
|
const host = record(value, name);
|
||||||
|
const port = host.port;
|
||||||
|
if (!Number.isInteger(port) || (port as number) < 1 || (port as number) > 65535) {
|
||||||
|
throw new Error(`${name}.port must be an integer from 1 to 65535`);
|
||||||
|
}
|
||||||
|
const hostKey = record(host.hostKey, `${name}.hostKey`);
|
||||||
|
const fingerprint = nonEmptyString(hostKey.fingerprint, `${name}.hostKey.fingerprint`);
|
||||||
|
if (!fingerprint.startsWith("SHA256:")) {
|
||||||
|
throw new Error(`${name}.hostKey.fingerprint must use SHA256 format`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...(host.label === undefined ? {} : { label: optionalString(host.label, `${name}.label`) }),
|
||||||
|
...(host.sourceAlias === undefined ? {} : { sourceAlias: optionalString(host.sourceAlias, `${name}.sourceAlias`) }),
|
||||||
|
hostName: nonEmptyString(host.hostName, `${name}.hostName`),
|
||||||
|
user: nonEmptyString(host.user, `${name}.user`),
|
||||||
|
port: port as number,
|
||||||
|
...(host.defaultCwd === undefined ? {} : { defaultCwd: optionalString(host.defaultCwd, `${name}.defaultCwd`) }),
|
||||||
|
auth: validateAuth(host.auth, `${name}.auth`),
|
||||||
|
hostKey: {
|
||||||
|
algorithm: nonEmptyString(hostKey.algorithm, `${name}.hostKey.algorithm`),
|
||||||
|
fingerprint,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePiSshConfig(value: unknown): PiSshConfig {
|
||||||
|
const root = record(value, "config");
|
||||||
|
if (root.version !== 1) throw new Error("config.version must be 1");
|
||||||
|
const hostsValue = record(root.hosts, "config.hosts");
|
||||||
|
const hosts: Record<string, SshHostConfig> = {};
|
||||||
|
for (const [id, host] of Object.entries(hostsValue)) {
|
||||||
|
if (!HOST_ID_PATTERN.test(id)) throw new Error(`invalid host id: ${id}`);
|
||||||
|
hosts[id] = validateHost(host, `config.hosts.${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let groups: Record<string, SshGroupConfig> | undefined;
|
||||||
|
if (root.groups !== undefined) {
|
||||||
|
groups = {};
|
||||||
|
for (const [id, value] of Object.entries(record(root.groups, "config.groups"))) {
|
||||||
|
if (!HOST_ID_PATTERN.test(id)) throw new Error(`invalid group id: ${id}`);
|
||||||
|
const group = record(value, `config.groups.${id}`);
|
||||||
|
if (!Array.isArray(group.hosts) || group.hosts.some((host) => typeof host !== "string" || !hosts[host])) {
|
||||||
|
throw new Error(`config.groups.${id}.hosts must reference configured hosts`);
|
||||||
|
}
|
||||||
|
groups[id] = {
|
||||||
|
label: nonEmptyString(group.label, `config.groups.${id}.label`),
|
||||||
|
hosts: [...new Set(group.hosts as string[])],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { version: 1, hosts, ...(groups === undefined ? {} : { groups }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyPiSshConfig(): PiSshConfig {
|
||||||
|
return { version: 1, hosts: {} };
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
export type EffectiveSshConfig = Map<string, string[]>;
|
||||||
|
|
||||||
|
export function parseSshG(text: string): EffectiveSshConfig {
|
||||||
|
const values: EffectiveSshConfig = new Map();
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
const separator = line.indexOf(" ");
|
||||||
|
if (separator < 1) continue;
|
||||||
|
const key = line.slice(0, separator).trim().toLowerCase();
|
||||||
|
const value = line.slice(separator + 1).trim();
|
||||||
|
if (!key || !value) continue;
|
||||||
|
const current = values.get(key) ?? [];
|
||||||
|
current.push(value);
|
||||||
|
values.set(key, current);
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveOpenSshAlias(alias: string): EffectiveSshConfig {
|
||||||
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(alias)) throw new Error(`invalid SSH alias: ${alias}`);
|
||||||
|
const output = execFileSync("ssh", ["-G", "--", alias], {
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
return parseSshG(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectiveValue(config: EffectiveSshConfig, key: string): string | undefined {
|
||||||
|
return config.get(key.toLowerCase())?.at(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectiveValues(config: EffectiveSshConfig, key: string): string[] {
|
||||||
|
return config.get(key.toLowerCase()) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDirectSshAliases(configPath = join(homedir(), ".ssh", "config")): string[] {
|
||||||
|
if (!existsSync(configPath)) return [];
|
||||||
|
const aliases: string[] = [];
|
||||||
|
const text = readFileSync(configPath, "utf8");
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
const match = /^\s*Host\s+(.+?)\s*$/i.exec(line);
|
||||||
|
if (!match) continue;
|
||||||
|
for (const alias of match[1].split(/\s+/)) {
|
||||||
|
if (!alias || alias.includes("*") || alias.includes("?") || alias.startsWith("!")) continue;
|
||||||
|
if (!aliases.includes(alias)) aliases.push(alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aliases;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { BashOperations } from "@earendil-works/pi-coding-agent";
|
||||||
|
import type { RemoteTransport } from "./ssh2-transport.ts";
|
||||||
|
|
||||||
|
export interface RemoteBashConnection {
|
||||||
|
remoteCwd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapt Pi's Bash output machinery to the active remote workspace.
|
||||||
|
*
|
||||||
|
* The cwd supplied by Pi's Bash factory is deliberately ignored: it belongs
|
||||||
|
* to the factory's filesystem namespace and must never leak into SSH command
|
||||||
|
* execution. The connection's mutable remoteCwd is the sole shell base.
|
||||||
|
*/
|
||||||
|
export function createRemoteBashOps(
|
||||||
|
connection: RemoteBashConnection,
|
||||||
|
transport: RemoteTransport,
|
||||||
|
): BashOperations {
|
||||||
|
return {
|
||||||
|
exec: (command, _factoryCwd, { onData, signal, timeout }) => {
|
||||||
|
if (!connection.remoteCwd.startsWith("/")) {
|
||||||
|
throw new Error(`ssh_bash requires an absolute remote cwd, received '${connection.remoteCwd}'`);
|
||||||
|
}
|
||||||
|
return transport.exec(command, connection.remoteCwd, { onData, signal, timeout });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { posix as posixPath } from "node:path";
|
||||||
|
|
||||||
|
export const SSH_CD_EXECUTION_MODE = "sequential" as const;
|
||||||
|
|
||||||
|
export interface RemoteWorkspaceConnection {
|
||||||
|
remoteCwd: string;
|
||||||
|
remoteHome: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemotePathMappingConnection extends RemoteWorkspaceConnection {
|
||||||
|
localCwd: string;
|
||||||
|
localHome: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteCwdChange {
|
||||||
|
previousCwd: string;
|
||||||
|
remoteCwd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRemoteCwd(path: string, currentCwd: string, remoteHome: string): string {
|
||||||
|
if (typeof path !== "string" || path.length === 0) {
|
||||||
|
throw new Error("path must be a non-empty string");
|
||||||
|
}
|
||||||
|
if (/[\0\r\n]/u.test(path)) {
|
||||||
|
throw new Error("path must not contain NUL or newline characters");
|
||||||
|
}
|
||||||
|
if (!currentCwd.startsWith("/") || !remoteHome.startsWith("/")) {
|
||||||
|
throw new Error("the active SSH connection has an invalid remote workspace");
|
||||||
|
}
|
||||||
|
if (path === "~") return posixPath.normalize(remoteHome);
|
||||||
|
if (path.startsWith("~/")) return posixPath.normalize(posixPath.join(remoteHome, path.slice(2)));
|
||||||
|
if (path.startsWith("~")) throw new Error("path supports only '~' or '~/' home expansion");
|
||||||
|
if (path.startsWith("/")) return posixPath.normalize(path);
|
||||||
|
return posixPath.normalize(posixPath.join(currentCwd, path));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeRemoteCwd(
|
||||||
|
connection: RemoteWorkspaceConnection,
|
||||||
|
path: string,
|
||||||
|
verifyDirectory: (requestedCwd: string) => Promise<string>,
|
||||||
|
): Promise<RemoteCwdChange> {
|
||||||
|
const previousCwd = connection.remoteCwd;
|
||||||
|
const requestedCwd = resolveRemoteCwd(path, previousCwd, connection.remoteHome);
|
||||||
|
const remoteCwd = await verifyDirectory(requestedCwd);
|
||||||
|
connection.remoteCwd = remoteCwd;
|
||||||
|
return { previousCwd, remoteCwd };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapLocalPathToRemote(path: string, connection: RemotePathMappingConnection): string {
|
||||||
|
if (path === connection.localCwd) return connection.remoteCwd;
|
||||||
|
if (path.startsWith(`${connection.localCwd}/`)) {
|
||||||
|
return `${connection.remoteCwd}${path.slice(connection.localCwd.length)}`;
|
||||||
|
}
|
||||||
|
if (path === connection.localHome) return connection.remoteHome;
|
||||||
|
if (path.startsWith(`${connection.localHome}/`)) {
|
||||||
|
return `${connection.remoteHome}${path.slice(connection.localHome.length)}`;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { posix as posixPath } from "node:path";
|
||||||
|
import type { RemoteTransport } from "./ssh2-transport.ts";
|
||||||
|
|
||||||
|
const PROBE_TIMEOUT_SECONDS = 20;
|
||||||
|
const MAX_PROBE_OUTPUT_BYTES = 64 * 1024;
|
||||||
|
|
||||||
|
export type RemotePathProbe = "home" | "cwd";
|
||||||
|
|
||||||
|
function probeCommand(kind: RemotePathProbe, token: string): { command: string; start: string; end: string } {
|
||||||
|
const start = `__PI_SSH_PROBE_${token}_START__`;
|
||||||
|
const end = `__PI_SSH_PROBE_${token}_END__`;
|
||||||
|
const assign = kind === "home"
|
||||||
|
? "pi_ssh_probe_value=$HOME"
|
||||||
|
: "pi_ssh_probe_value=$(pwd -P) || exit $?";
|
||||||
|
return {
|
||||||
|
command: `${assign}\nprintf '%s%s%s' '${start}' "$pi_ssh_probe_value" '${end}'`,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRemotePathProbe(output: Buffer, start: string, end: string, kind: RemotePathProbe): string {
|
||||||
|
if (output.length > MAX_PROBE_OUTPUT_BYTES) {
|
||||||
|
throw new Error(`remote ${kind} probe output exceeded ${MAX_PROBE_OUTPUT_BYTES} bytes`);
|
||||||
|
}
|
||||||
|
const text = output.toString("utf8");
|
||||||
|
const startIndex = text.indexOf(start);
|
||||||
|
const endIndex = startIndex < 0 ? -1 : text.indexOf(end, startIndex + start.length);
|
||||||
|
if (startIndex < 0 || endIndex < 0 || text.indexOf(start, startIndex + start.length) >= 0) {
|
||||||
|
throw new Error(`remote ${kind} probe returned an invalid framed response`);
|
||||||
|
}
|
||||||
|
const value = text.slice(startIndex + start.length, endIndex);
|
||||||
|
if (!value.startsWith("/") || /[\0\r\n]/u.test(value)) {
|
||||||
|
throw new Error(`remote ${kind} probe did not return one absolute POSIX path`);
|
||||||
|
}
|
||||||
|
return posixPath.normalize(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeRemotePath(
|
||||||
|
transport: RemoteTransport,
|
||||||
|
kind: RemotePathProbe,
|
||||||
|
cwd = ".",
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<string> {
|
||||||
|
const token = randomBytes(12).toString("hex");
|
||||||
|
const probe = probeCommand(kind, token);
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let captured = 0;
|
||||||
|
let overflow = false;
|
||||||
|
const result = await transport.exec(probe.command, cwd, {
|
||||||
|
signal,
|
||||||
|
timeout: PROBE_TIMEOUT_SECONDS,
|
||||||
|
onData(data) {
|
||||||
|
if (captured + data.length > MAX_PROBE_OUTPUT_BYTES) {
|
||||||
|
overflow = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(data);
|
||||||
|
captured += data.length;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const output = Buffer.concat(chunks);
|
||||||
|
if (overflow) throw new Error(`remote ${kind} probe output exceeded ${MAX_PROBE_OUTPUT_BYTES} bytes`);
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
const detail = output.toString("utf8").replace(/\s+/gu, " ").trim().slice(0, 300);
|
||||||
|
throw new Error(`remote ${kind} probe failed${result.exitCode === null ? "" : ` with exit code ${result.exitCode}`}${detail ? `: ${detail}` : ""}`);
|
||||||
|
}
|
||||||
|
return parseRemotePathProbe(output, probe.start, probe.end, kind);
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import { posix as posixPath } from "node:path";
|
||||||
|
import type { RemoteTransport } from "./ssh2-transport.ts";
|
||||||
|
|
||||||
|
export interface RemoteFindInput {
|
||||||
|
pattern: string;
|
||||||
|
path?: string;
|
||||||
|
caseSensitive?: boolean;
|
||||||
|
includeHidden?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteGrepInput {
|
||||||
|
pattern: string;
|
||||||
|
path?: string;
|
||||||
|
literal?: boolean;
|
||||||
|
caseSensitive?: boolean;
|
||||||
|
include?: string;
|
||||||
|
includeHidden?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteSearchResult {
|
||||||
|
text: string;
|
||||||
|
backend: string;
|
||||||
|
matchCount: number;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARKER = "__PI_SSH_SEARCH_BACKEND__:";
|
||||||
|
const DEFAULT_LIMIT = 50;
|
||||||
|
const MAX_LIMIT = 200;
|
||||||
|
const MAX_LINE_CHARS = 800;
|
||||||
|
const MAX_CAPTURE_CHARS = 512_000;
|
||||||
|
const SEARCH_TIMEOUT_SECONDS = 30;
|
||||||
|
const MAX_DIAGNOSTIC_CHARS = 16_000;
|
||||||
|
|
||||||
|
function shellQuote(value: string): string {
|
||||||
|
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateField(value: unknown, name: string, optional = false): string | undefined {
|
||||||
|
if (value === undefined && optional) return undefined;
|
||||||
|
if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
|
||||||
|
if (/[\0\r\n]/u.test(value)) throw new Error(`${name} must not contain NUL or newline characters`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLimit(value: unknown): number {
|
||||||
|
if (value === undefined) return DEFAULT_LIMIT;
|
||||||
|
if (!Number.isInteger(value) || (value as number) < 1 || (value as number) > MAX_LIMIT) {
|
||||||
|
throw new Error(`limit must be an integer from 1 to ${MAX_LIMIT}`);
|
||||||
|
}
|
||||||
|
return value as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRemoteSearchPath(path: string | undefined, remoteCwd: string, remoteHome: string): string {
|
||||||
|
const value = path ?? ".";
|
||||||
|
validateField(value, "path");
|
||||||
|
if (value === "~") return remoteHome;
|
||||||
|
if (value.startsWith("~/")) return posixPath.normalize(posixPath.join(remoteHome, value.slice(2)));
|
||||||
|
if (value.startsWith("~")) throw new Error("path supports only ~ or ~/... remote HOME expansion");
|
||||||
|
if (value.startsWith("/")) return posixPath.normalize(value);
|
||||||
|
return posixPath.normalize(posixPath.join(remoteCwd, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_HELPER = [
|
||||||
|
`pi_ssh_accept_status() {`,
|
||||||
|
` local actual="$1" accepted`,
|
||||||
|
` shift`,
|
||||||
|
` for accepted in "$@"; do`,
|
||||||
|
` if [ "$actual" -eq "$accepted" ]; then return 0; fi`,
|
||||||
|
` done`,
|
||||||
|
` return "$actual"`,
|
||||||
|
`}`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const GREP_RECORD_HELPER = [
|
||||||
|
`pi_ssh_limit_colon_records() {`,
|
||||||
|
` local path match line content count=0`,
|
||||||
|
` while IFS= read -r -d '' path && IFS= read -r match; do`,
|
||||||
|
' line="${match%%:*}"',
|
||||||
|
' content="${match#*:}"',
|
||||||
|
' printf \'%s\\0%s\\0%s\\0\' "${path:0:' + MAX_LINE_CHARS + '}" "$line" "${content:0:' + MAX_LINE_CHARS + '}"',
|
||||||
|
` count=$((count + 1))`,
|
||||||
|
` if [ "$count" -ge "$PI_SSH_TAKE" ]; then return 0; fi`,
|
||||||
|
` done`,
|
||||||
|
`}`,
|
||||||
|
`pi_ssh_limit_git_records() {`,
|
||||||
|
` local path line content count=0`,
|
||||||
|
` while IFS= read -r -d '' path && IFS= read -r -d '' line && IFS= read -r content; do`,
|
||||||
|
' printf \'%s\\0%s\\0%s\\0\' "${path:0:' + MAX_LINE_CHARS + '}" "$line" "${content:0:' + MAX_LINE_CHARS + '}"',
|
||||||
|
` count=$((count + 1))`,
|
||||||
|
` if [ "$count" -ge "$PI_SSH_TAKE" ]; then return 0; fi`,
|
||||||
|
` done`,
|
||||||
|
`}`,
|
||||||
|
] .join("\n");
|
||||||
|
|
||||||
|
function findPipeline(input: RemoteFindInput, root: string, limit: number): string {
|
||||||
|
const pattern = validateField(input.pattern, "pattern") as string;
|
||||||
|
const fdCase = input.caseSensitive ? "--case-sensitive" : "--ignore-case";
|
||||||
|
const fdHidden = input.includeHidden ? "--hidden" : "";
|
||||||
|
const grepCase = input.caseSensitive ? "" : "-i";
|
||||||
|
const hiddenFilter = input.includeHidden ? "cat" : "grep -Ev '(^|/)\\.'";
|
||||||
|
const take = limit + 1;
|
||||||
|
return [
|
||||||
|
STATUS_HELPER,
|
||||||
|
`if command -v fd >/dev/null 2>&1; then`,
|
||||||
|
` printf '${MARKER}fd\\n'`,
|
||||||
|
` fd --type f --color never --fixed-strings ${fdCase} ${fdHidden} --exclude .git --exclude node_modules -- ${shellQuote(pattern)} ${shellQuote(root)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
|
||||||
|
' statuses=("${PIPESTATUS[@]}")',
|
||||||
|
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
|
||||||
|
`elif command -v fdfind >/dev/null 2>&1; then`,
|
||||||
|
` printf '${MARKER}fdfind\\n'`,
|
||||||
|
` fdfind --type f --color never --fixed-strings ${fdCase} ${fdHidden} --exclude .git --exclude node_modules -- ${shellQuote(pattern)} ${shellQuote(root)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
|
||||||
|
' statuses=("${PIPESTATUS[@]}")',
|
||||||
|
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
|
||||||
|
`elif command -v git >/dev/null 2>&1 && git -C ${shellQuote(root)} rev-parse --is-inside-work-tree >/dev/null 2>&1; then`,
|
||||||
|
` printf '${MARKER}git-ls-files\\n'`,
|
||||||
|
` git -C ${shellQuote(root)} ls-files -co --exclude-standard | ${hiddenFilter} | grep -F ${grepCase} -- ${shellQuote(pattern)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
|
||||||
|
' statuses=("${PIPESTATUS[@]}")',
|
||||||
|
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
|
||||||
|
' pi_ssh_accept_status "${statuses[1]}" 0 1 141 || exit $?',
|
||||||
|
' pi_ssh_accept_status "${statuses[2]}" 0 1 141 || exit $?',
|
||||||
|
`else`,
|
||||||
|
` printf '${MARKER}find\\n'`,
|
||||||
|
` find ${shellQuote(root)} -type f ! -path '*/.git/*' ! -path '*/node_modules/*' | ${hiddenFilter} | grep -F ${grepCase} -- ${shellQuote(pattern)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
|
||||||
|
' statuses=("${PIPESTATUS[@]}")',
|
||||||
|
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
|
||||||
|
' pi_ssh_accept_status "${statuses[1]}" 0 1 141 || exit $?',
|
||||||
|
' pi_ssh_accept_status "${statuses[2]}" 0 1 141 || exit $?',
|
||||||
|
`fi`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function grepPipeline(input: RemoteGrepInput, root: string, limit: number): string {
|
||||||
|
const pattern = validateField(input.pattern, "pattern") as string;
|
||||||
|
const include = validateField(input.include, "include", true);
|
||||||
|
if (include?.includes("/")) throw new Error("include must be a basename glob without /");
|
||||||
|
const rgMode = input.literal === false ? "" : "-F";
|
||||||
|
const grepMode = input.literal === false ? "-E" : "-F";
|
||||||
|
const insensitive = input.caseSensitive ? "" : "-i";
|
||||||
|
const rgHidden = input.includeHidden
|
||||||
|
? "--hidden"
|
||||||
|
: "--glob '!.*' --glob '!**/.*' --glob '!**/.*/**'";
|
||||||
|
const rgGlob = include ? `-g ${shellQuote(include)}` : "";
|
||||||
|
const gitPathspecs = [
|
||||||
|
...(include ? [`:(glob)**/${include}`] : []),
|
||||||
|
...(input.includeHidden ? [] : [":(exclude,glob)**/.*", ":(exclude,glob)**/.*/**"]),
|
||||||
|
];
|
||||||
|
const gitPath = gitPathspecs.length > 0 ? `-- ${gitPathspecs.map(shellQuote).join(" ")}` : "";
|
||||||
|
const findName = include ? `-name ${shellQuote(include)}` : "";
|
||||||
|
const includeHidden = input.includeHidden ? 1 : 0;
|
||||||
|
const take = limit + 1;
|
||||||
|
return [
|
||||||
|
STATUS_HELPER,
|
||||||
|
GREP_RECORD_HELPER,
|
||||||
|
`PI_SSH_TAKE=${take}`,
|
||||||
|
`PI_SSH_ROOT=${shellQuote(root)}`,
|
||||||
|
`PI_SSH_INCLUDE_HIDDEN=${includeHidden}`,
|
||||||
|
`if command -v rg >/dev/null 2>&1; then`,
|
||||||
|
` printf '${MARKER}ripgrep\\n'`,
|
||||||
|
` rg --null --line-number --no-heading --color never --with-filename --max-columns ${MAX_LINE_CHARS} --max-columns-preview ${rgMode} ${insensitive} ${rgHidden} ${rgGlob} --glob '!.git/**' --glob '!node_modules/**' -- ${shellQuote(pattern)} ${shellQuote(root)} | pi_ssh_limit_colon_records`,
|
||||||
|
' statuses=("${PIPESTATUS[@]}")',
|
||||||
|
' pi_ssh_accept_status "${statuses[0]}" 0 1 141 || exit $?',
|
||||||
|
' pi_ssh_accept_status "${statuses[1]}" 0 || exit $?',
|
||||||
|
`elif command -v git >/dev/null 2>&1 && git -C ${shellQuote(root)} rev-parse --is-inside-work-tree >/dev/null 2>&1; then`,
|
||||||
|
` printf '${MARKER}git-grep\\n'`,
|
||||||
|
` git -C ${shellQuote(root)} grep --untracked --exclude-standard -z -n -I ${grepMode} ${insensitive} -e ${shellQuote(pattern)} ${gitPath} | pi_ssh_limit_git_records`,
|
||||||
|
' statuses=("${PIPESTATUS[@]}")',
|
||||||
|
' pi_ssh_accept_status "${statuses[0]}" 0 1 141 || exit $?',
|
||||||
|
' pi_ssh_accept_status "${statuses[1]}" 0 || exit $?',
|
||||||
|
`else`,
|
||||||
|
` printf '${MARKER}grep\\n'`,
|
||||||
|
` find ${shellQuote(root)} -type f ! -path '*/.git/*' ! -path '*/node_modules/*' ${findName} -print0 | (`,
|
||||||
|
` while IFS= read -r -d '' file; do`,
|
||||||
|
` if [ "$PI_SSH_INCLUDE_HIDDEN" -eq 0 ]; then`,
|
||||||
|
` if [ -d "$PI_SSH_ROOT" ]; then`,
|
||||||
|
' relative=${file#"$PI_SSH_ROOT"/}',
|
||||||
|
` else`,
|
||||||
|
' relative=${file##*/}',
|
||||||
|
` fi`,
|
||||||
|
' case "$relative" in .*|*/.*) continue ;; esac',
|
||||||
|
` fi`,
|
||||||
|
` grep -n -I ${grepMode} ${insensitive} -- ${shellQuote(pattern)} "$file" | while IFS= read -r match; do`,
|
||||||
|
` printf '%s\\0%s\\n' "$file" "$match"`,
|
||||||
|
` done`,
|
||||||
|
' grep_statuses=("${PIPESTATUS[@]}")',
|
||||||
|
' pi_ssh_accept_status "${grep_statuses[0]}" 0 1 141 || exit $?',
|
||||||
|
' pi_ssh_accept_status "${grep_statuses[1]}" 0 1 141 || exit $?',
|
||||||
|
` done`,
|
||||||
|
` ) | pi_ssh_limit_colon_records`,
|
||||||
|
' statuses=("${PIPESTATUS[@]}")',
|
||||||
|
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
|
||||||
|
' pi_ssh_accept_status "${statuses[1]}" 0 141 || exit $?',
|
||||||
|
' pi_ssh_accept_status "${statuses[2]}" 0 || exit $?',
|
||||||
|
`fi`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRemoteFindCommand(input: RemoteFindInput, root: string): { command: string; limit: number } {
|
||||||
|
const limit = normalizeLimit(input.limit);
|
||||||
|
validateField(root, "resolved path");
|
||||||
|
return { command: findPipeline(input, root, limit), limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRemoteGrepCommand(input: RemoteGrepInput, root: string): { command: string; limit: number } {
|
||||||
|
const limit = normalizeLimit(input.limit);
|
||||||
|
validateField(root, "resolved path");
|
||||||
|
return { command: grepPipeline(input, root, limit), limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefixGitPath(path: string, root: string): string {
|
||||||
|
return path.startsWith("/") || path.startsWith("../") ? path : posixPath.join(root, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleField(value: string): string {
|
||||||
|
return value.replace(/\r/gu, "\\r").replace(/\n/gu, "\\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDiagnostic(raw: Buffer): string {
|
||||||
|
return raw
|
||||||
|
.toString("utf8")
|
||||||
|
.replace(/\0/gu, " ")
|
||||||
|
.replace(new RegExp(`${MARKER}[^\\n]*`, "gu"), "")
|
||||||
|
.replace(/\s+/gu, " ")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRemoteSearchOutput(
|
||||||
|
raw: string | Buffer,
|
||||||
|
root: string,
|
||||||
|
limit: number,
|
||||||
|
kind: "find" | "grep",
|
||||||
|
): RemoteSearchResult {
|
||||||
|
const output = Buffer.isBuffer(raw) ? raw.toString("utf8") : raw;
|
||||||
|
const markerIndex = output.indexOf(MARKER);
|
||||||
|
const markerEnd = markerIndex < 0 ? -1 : output.indexOf("\n", markerIndex);
|
||||||
|
if (markerIndex < 0 || markerEnd < 0) throw new Error(`remote ${kind} did not report a search backend`);
|
||||||
|
const backend = output.slice(markerIndex + MARKER.length, markerEnd).trim() || "unknown";
|
||||||
|
const payload = output.slice(markerEnd + 1);
|
||||||
|
let sourceRows: string[];
|
||||||
|
if (kind === "grep") {
|
||||||
|
const fields = payload.length === 0 ? [] : payload.split("\0");
|
||||||
|
if (fields.at(-1) === "") fields.pop();
|
||||||
|
if (fields.length % 3 !== 0) throw new Error("remote grep returned a malformed or truncated NUL-delimited result");
|
||||||
|
sourceRows = [];
|
||||||
|
for (let index = 0; index < fields.length; index += 3) {
|
||||||
|
const path = backend.startsWith("git-") ? prefixGitPath(fields[index], root) : fields[index];
|
||||||
|
sourceRows.push(`${visibleField(path)}:${visibleField(fields[index + 1])}:${visibleField(fields[index + 2])}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sourceRows = payload.replace(/\r\n?/gu, "\n").split("\n").filter((line) => line.length > 0);
|
||||||
|
if (backend.startsWith("git-")) sourceRows = sourceRows.map((line) => prefixGitPath(line, root));
|
||||||
|
}
|
||||||
|
const truncated = sourceRows.length > limit;
|
||||||
|
const rows = sourceRows.slice(0, limit).map((line) =>
|
||||||
|
line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS - 1)}…` : line,
|
||||||
|
);
|
||||||
|
const header = `Remote ${kind}: ${rows.length} result${rows.length === 1 ? "" : "s"} (backend: ${backend}, root: ${root}, truncated: ${truncated ? "yes" : "no"})`;
|
||||||
|
return {
|
||||||
|
text: rows.length > 0 ? `${header}\n\n${rows.join("\n")}` : `${header}\n\nNo matches found.`,
|
||||||
|
backend,
|
||||||
|
matchCount: rows.length,
|
||||||
|
truncated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRemoteSearch(
|
||||||
|
transport: RemoteTransport,
|
||||||
|
command: string,
|
||||||
|
executionCwd: string,
|
||||||
|
root: string,
|
||||||
|
limit: number,
|
||||||
|
kind: "find" | "grep",
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<RemoteSearchResult> {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
const stderrChunks: Buffer[] = [];
|
||||||
|
let captured = 0;
|
||||||
|
let stderrCaptured = 0;
|
||||||
|
const capture = (target: Buffer[], data: Buffer, stderr = false) => {
|
||||||
|
const current = stderr ? stderrCaptured : captured;
|
||||||
|
const maximum = stderr ? MAX_DIAGNOSTIC_CHARS : MAX_CAPTURE_CHARS;
|
||||||
|
if (current >= maximum) return;
|
||||||
|
const remaining = maximum - current;
|
||||||
|
const chunk = data.length > remaining ? data.subarray(0, remaining) : data;
|
||||||
|
target.push(chunk);
|
||||||
|
if (stderr) stderrCaptured += chunk.length;
|
||||||
|
else captured += chunk.length;
|
||||||
|
};
|
||||||
|
let result: { exitCode: number | null };
|
||||||
|
try {
|
||||||
|
result = await transport.exec(command, executionCwd, {
|
||||||
|
signal,
|
||||||
|
timeout: SEARCH_TIMEOUT_SECONDS,
|
||||||
|
onData(data) { capture(chunks, data); },
|
||||||
|
onStderr(data) { capture(stderrChunks, data, true); },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!signal?.aborted && /timed out/iu.test(error instanceof Error ? error.message : String(error))) {
|
||||||
|
throw new Error(`remote ${kind} timed out after ${SEARCH_TIMEOUT_SECONDS}s (root: ${root}); narrow the remote path with ssh_find before retrying`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const output = Buffer.concat(chunks);
|
||||||
|
const stderr = Buffer.concat(stderrChunks);
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
const detail = normalizeDiagnostic(stderr.length > 0 ? stderr : output);
|
||||||
|
throw new Error(`remote ${kind} failed${result.exitCode === null ? "" : ` with exit code ${result.exitCode}`}${detail ? `: ${detail}` : ""}`);
|
||||||
|
}
|
||||||
|
const formatted = formatRemoteSearchOutput(output, root, limit, kind);
|
||||||
|
const warning = normalizeDiagnostic(stderr);
|
||||||
|
if (warning) formatted.text += `\n\nRemote warning: ${warning}`;
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runRemoteFind(
|
||||||
|
transport: RemoteTransport,
|
||||||
|
input: RemoteFindInput,
|
||||||
|
remoteCwd: string,
|
||||||
|
remoteHome: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<RemoteSearchResult> {
|
||||||
|
const root = resolveRemoteSearchPath(input.path, remoteCwd, remoteHome);
|
||||||
|
const built = buildRemoteFindCommand(input, root);
|
||||||
|
return runRemoteSearch(transport, built.command, remoteCwd, root, built.limit, "find", signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runRemoteGrep(
|
||||||
|
transport: RemoteTransport,
|
||||||
|
input: RemoteGrepInput,
|
||||||
|
remoteCwd: string,
|
||||||
|
remoteHome: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<RemoteSearchResult> {
|
||||||
|
const root = resolveRemoteSearchPath(input.path, remoteCwd, remoteHome);
|
||||||
|
const built = buildRemoteGrepCommand(input, root);
|
||||||
|
return runRemoteSearch(transport, built.command, remoteCwd, root, built.limit, "grep", signal);
|
||||||
|
}
|
||||||
Vendored
+48
@@ -0,0 +1,48 @@
|
|||||||
|
declare module "ssh2" {
|
||||||
|
import type { EventEmitter } from "node:events";
|
||||||
|
import type { Stats } from "node:fs";
|
||||||
|
|
||||||
|
export interface ConnectConfig {
|
||||||
|
host: string;
|
||||||
|
port?: number;
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
privateKey?: Buffer | string;
|
||||||
|
passphrase?: string;
|
||||||
|
tryKeyboard?: boolean;
|
||||||
|
readyTimeout?: number;
|
||||||
|
keepaliveInterval?: number;
|
||||||
|
keepaliveCountMax?: number;
|
||||||
|
hostVerifier?: (key: Buffer) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientChannel extends EventEmitter {
|
||||||
|
stderr: EventEmitter;
|
||||||
|
close(): void;
|
||||||
|
signal(signal: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SFTPWrapper {
|
||||||
|
readFile(path: string, callback: (error: Error | undefined, data: Buffer) => void): void;
|
||||||
|
writeFile(path: string, data: Buffer, callback: (error?: Error) => void): void;
|
||||||
|
open(path: string, flags: string, callback: (error: Error | undefined, handle: Buffer) => void): void;
|
||||||
|
close(handle: Buffer, callback: (error?: Error) => void): void;
|
||||||
|
mkdir(path: string, callback: (error?: Error) => void): void;
|
||||||
|
stat(path: string, callback: (error: Error | undefined, stats: Stats) => void): void;
|
||||||
|
rename(oldPath: string, newPath: string, callback: (error?: Error) => void): void;
|
||||||
|
unlink(path: string, callback: (error?: Error) => void): void;
|
||||||
|
end(): void;
|
||||||
|
ext_openssh_rename?(oldPath: string, newPath: string, callback: (error?: Error) => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Client extends EventEmitter {
|
||||||
|
connect(config: ConnectConfig): this;
|
||||||
|
exec(
|
||||||
|
command: string,
|
||||||
|
callback: (error: Error | undefined, channel: ClientChannel) => void,
|
||||||
|
): void;
|
||||||
|
sftp(callback: (error: Error | undefined, sftp: SFTPWrapper) => void): void;
|
||||||
|
end(): this;
|
||||||
|
destroy(): this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
/// <reference path="./ssh2-shim.d.ts" />
|
||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { posix as posixPath } from "node:path";
|
||||||
|
import { Client, type ClientChannel, type ConnectConfig, type SFTPWrapper } from "ssh2";
|
||||||
|
import { expandUserPath, type SshHostConfig } from "./config.ts";
|
||||||
|
|
||||||
|
export interface RemoteExecOptions {
|
||||||
|
onData: (data: Buffer) => void;
|
||||||
|
onStderr?: (data: Buffer) => void;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteTransport {
|
||||||
|
connect(signal?: AbortSignal): Promise<void>;
|
||||||
|
dispose(): Promise<void>;
|
||||||
|
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }>;
|
||||||
|
capture(command: string, cwd?: string, timeout?: number): Promise<{ exitCode: number | null; output: Buffer }>;
|
||||||
|
readFile(remotePath: string): Promise<Buffer>;
|
||||||
|
ensureReadable(remotePath: string): Promise<void>;
|
||||||
|
ensureReadableWritable(remotePath: string): Promise<void>;
|
||||||
|
detectImageMimeType(remotePath: string): Promise<string | null>;
|
||||||
|
mkdir(remoteDir: string): Promise<void>;
|
||||||
|
writeFile(remotePath: string, content: Buffer): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_SECONDS = 300;
|
||||||
|
|
||||||
|
class CommandQueue {
|
||||||
|
private tail: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
enqueue<T>(task: () => Promise<T>): Promise<T> {
|
||||||
|
const run = this.tail.then(task, task);
|
||||||
|
this.tail = run.then(() => undefined, () => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SemaphoreWaiter {
|
||||||
|
resolve: (release: () => void) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onAbort?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AsyncSemaphore {
|
||||||
|
private active = 0;
|
||||||
|
private readonly waiters: SemaphoreWaiter[] = [];
|
||||||
|
private readonly limit: number;
|
||||||
|
|
||||||
|
constructor(limit: number) {
|
||||||
|
if (!Number.isInteger(limit) || limit < 1) throw new Error("semaphore limit must be a positive integer");
|
||||||
|
this.limit = limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
acquire(signal?: AbortSignal): Promise<() => void> {
|
||||||
|
if (signal?.aborted) return Promise.reject(new Error("SSH command aborted"));
|
||||||
|
if (this.active < this.limit) {
|
||||||
|
this.active += 1;
|
||||||
|
return Promise.resolve(this.createRelease());
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const waiter: SemaphoreWaiter = { resolve, reject, signal };
|
||||||
|
waiter.onAbort = () => {
|
||||||
|
const index = this.waiters.indexOf(waiter);
|
||||||
|
if (index >= 0) this.waiters.splice(index, 1);
|
||||||
|
reject(new Error("SSH command aborted"));
|
||||||
|
};
|
||||||
|
signal?.addEventListener("abort", waiter.onAbort, { once: true });
|
||||||
|
this.waiters.push(waiter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private createRelease(): () => void {
|
||||||
|
let released = false;
|
||||||
|
return () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
this.active -= 1;
|
||||||
|
this.dispatch();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatch(): void {
|
||||||
|
while (this.active < this.limit) {
|
||||||
|
const waiter = this.waiters.shift();
|
||||||
|
if (!waiter) return;
|
||||||
|
if (waiter.onAbort) waiter.signal?.removeEventListener("abort", waiter.onAbort);
|
||||||
|
if (waiter.signal?.aborted) {
|
||||||
|
waiter.reject(new Error("SSH command aborted"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.active += 1;
|
||||||
|
waiter.resolve(this.createRelease());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shellQuote(value: string): string {
|
||||||
|
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyAlgorithm(key: Buffer): string {
|
||||||
|
if (key.length < 4) return "unknown";
|
||||||
|
const length = key.readUInt32BE(0);
|
||||||
|
if (length < 1 || length > key.length - 4) return "unknown";
|
||||||
|
return key.subarray(4, 4 + length).toString("ascii");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fingerprintHostKey(key: Buffer): { algorithm: string; fingerprint: string } {
|
||||||
|
return {
|
||||||
|
algorithm: keyAlgorithm(key),
|
||||||
|
fingerprint: `SHA256:${createHash("sha256").update(key).digest("base64").replace(/=+$/, "")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConnectConfig(host: SshHostConfig): ConnectConfig {
|
||||||
|
const config: ConnectConfig = {
|
||||||
|
host: host.hostName,
|
||||||
|
port: host.port,
|
||||||
|
username: host.user,
|
||||||
|
readyTimeout: 20_000,
|
||||||
|
keepaliveInterval: 15_000,
|
||||||
|
keepaliveCountMax: 3,
|
||||||
|
hostVerifier: (key) => fingerprintHostKey(key).fingerprint === host.hostKey.fingerprint,
|
||||||
|
};
|
||||||
|
if (host.auth.type === "password") {
|
||||||
|
config.password = host.auth.password;
|
||||||
|
config.tryKeyboard = host.auth.method !== "password";
|
||||||
|
} else {
|
||||||
|
config.privateKey = readFileSync(expandUserPath(host.auth.identityFile));
|
||||||
|
if (host.auth.passphrase) config.passphrase = host.auth.passphrase;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Ssh2Transport implements RemoteTransport {
|
||||||
|
private readonly client: Client;
|
||||||
|
private readonly sftpQueue = new CommandQueue();
|
||||||
|
private readonly execSemaphore = new AsyncSemaphore(4);
|
||||||
|
private connected = false;
|
||||||
|
private disposed = false;
|
||||||
|
private disconnectError: Error | null = null;
|
||||||
|
private sftpClient: SFTPWrapper | null = null;
|
||||||
|
private readonly host: SshHostConfig;
|
||||||
|
|
||||||
|
constructor(host: SshHostConfig, client: Client = new Client()) {
|
||||||
|
this.host = host;
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(signal?: AbortSignal): Promise<void> {
|
||||||
|
if (this.connected) return;
|
||||||
|
if (this.disposed) throw new Error("SSH2 transport is disposed");
|
||||||
|
if (signal?.aborted) throw new Error("SSH connection aborted");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const cleanup = () => signal?.removeEventListener("abort", onAbort);
|
||||||
|
const succeed = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
this.connected = true;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const fail = (error: unknown) => {
|
||||||
|
const normalized = error instanceof Error ? error : new Error(String(error));
|
||||||
|
this.disconnectError = normalized;
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
reject(normalized);
|
||||||
|
};
|
||||||
|
const onAbort = () => {
|
||||||
|
try {
|
||||||
|
this.client.destroy();
|
||||||
|
} catch {
|
||||||
|
// client may already be closed
|
||||||
|
}
|
||||||
|
fail(new Error("SSH connection aborted"));
|
||||||
|
};
|
||||||
|
this.client.once("ready", succeed);
|
||||||
|
this.client.on("error", fail);
|
||||||
|
this.client.on("close", () => {
|
||||||
|
this.connected = false;
|
||||||
|
this.sftpClient = null;
|
||||||
|
if (!this.disposed && !this.disconnectError) this.disconnectError = new Error("SSH2 connection closed unexpectedly");
|
||||||
|
});
|
||||||
|
if (this.host.auth.type === "password" && this.host.auth.method !== "password") {
|
||||||
|
this.client.on("keyboard-interactive", (_name, _instructions, _language, prompts, finish) => {
|
||||||
|
finish(prompts.map(() => this.host.auth.type === "password" ? this.host.auth.password : ""));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
try {
|
||||||
|
this.client.connect(buildConnectConfig(this.host));
|
||||||
|
} catch (error) {
|
||||||
|
fail(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
this.disposed = true;
|
||||||
|
this.connected = false;
|
||||||
|
this.sftpClient = null;
|
||||||
|
this.client.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertConnected(): void {
|
||||||
|
if (!this.connected) throw this.disconnectError ?? new Error("SSH2 connection is not active");
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
|
||||||
|
const release = await this.execSemaphore.acquire(options.signal);
|
||||||
|
try {
|
||||||
|
return await this.execUnqueued(command, cwd, options);
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async execUnqueued(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
|
||||||
|
this.assertConnected();
|
||||||
|
const remoteCommand = `cd -- ${shellQuote(cwd)} && bash -lc ${shellQuote(command)} </dev/null`;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let channel: ClientChannel | undefined;
|
||||||
|
let settled = false;
|
||||||
|
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||||
|
const effectiveTimeout = options.timeout ?? DEFAULT_TIMEOUT_SECONDS;
|
||||||
|
const cleanup = () => {
|
||||||
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||||
|
if (options.signal) options.signal.removeEventListener("abort", onAbort);
|
||||||
|
};
|
||||||
|
const fail = (error: Error) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
const onAbort = () => {
|
||||||
|
try {
|
||||||
|
channel?.signal("KILL");
|
||||||
|
channel?.close();
|
||||||
|
} catch {
|
||||||
|
// channel may already be closed
|
||||||
|
}
|
||||||
|
fail(new Error("SSH command aborted"));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.signal?.aborted) {
|
||||||
|
fail(new Error("SSH command aborted"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
if (effectiveTimeout > 0) {
|
||||||
|
timeoutHandle = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
channel?.signal("KILL");
|
||||||
|
channel?.close();
|
||||||
|
} catch {
|
||||||
|
// channel may already be closed
|
||||||
|
}
|
||||||
|
fail(new Error(`SSH command timed out after ${effectiveTimeout}s`));
|
||||||
|
}, effectiveTimeout * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client.exec(remoteCommand, (error, stream) => {
|
||||||
|
if (error) {
|
||||||
|
fail(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
channel = stream;
|
||||||
|
stream.on("data", (data: Buffer | string) => options.onData(Buffer.isBuffer(data) ? data : Buffer.from(data)));
|
||||||
|
stream.stderr.on("data", (data: Buffer | string) => {
|
||||||
|
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||||
|
(options.onStderr ?? options.onData)(chunk);
|
||||||
|
});
|
||||||
|
stream.once("error", fail);
|
||||||
|
stream.once("close", (code: number | undefined) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
resolve({ exitCode: typeof code === "number" ? code : null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async capture(command: string, cwd = ".", timeout = 30): Promise<{ exitCode: number | null; output: Buffer }> {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
const result = await this.exec(command, cwd, { timeout, onData: (data) => chunks.push(data) });
|
||||||
|
return { exitCode: result.exitCode, output: Buffer.concat(chunks) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sftp(): Promise<SFTPWrapper> {
|
||||||
|
this.assertConnected();
|
||||||
|
if (this.sftpClient) return this.sftpClient;
|
||||||
|
this.sftpClient = await new Promise<SFTPWrapper>((resolve, reject) => {
|
||||||
|
this.client.sftp((error, sftp) => error ? reject(error) : resolve(sftp));
|
||||||
|
});
|
||||||
|
return this.sftpClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFile(remotePath: string): Promise<Buffer> {
|
||||||
|
return this.sftpQueue.enqueue(async () => {
|
||||||
|
const sftp = await this.sftp();
|
||||||
|
return new Promise<Buffer>((resolve, reject) => {
|
||||||
|
sftp.readFile(remotePath, (error, data) => error ? reject(error) : resolve(data));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureOpen(remotePath: string, flags: "r" | "r+"): Promise<void> {
|
||||||
|
return this.sftpQueue.enqueue(async () => {
|
||||||
|
const sftp = await this.sftp();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
sftp.open(remotePath, flags, (error, handle) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sftp.close(handle, (closeError) => closeError ? reject(closeError) : resolve());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureReadable(remotePath: string): Promise<void> {
|
||||||
|
return this.ensureOpen(remotePath, "r");
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureReadableWritable(remotePath: string): Promise<void> {
|
||||||
|
return this.ensureOpen(remotePath, "r+");
|
||||||
|
}
|
||||||
|
|
||||||
|
async detectImageMimeType(remotePath: string): Promise<string | null> {
|
||||||
|
const content = await this.readFile(remotePath);
|
||||||
|
if (content.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) return "image/jpeg";
|
||||||
|
if (content.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
|
||||||
|
const prefix = content.subarray(0, 6).toString("ascii");
|
||||||
|
if (prefix === "GIF87a" || prefix === "GIF89a") return "image/gif";
|
||||||
|
if (content.subarray(0, 4).toString("ascii") === "RIFF" && content.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async mkdir(remoteDir: string): Promise<void> {
|
||||||
|
return this.sftpQueue.enqueue(() => this.mkdirUnqueued(remoteDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mkdirUnqueued(remoteDir: string): Promise<void> {
|
||||||
|
const sftp = await this.sftp();
|
||||||
|
const normalized = posixPath.normalize(remoteDir);
|
||||||
|
const segments = normalized.split("/").filter(Boolean);
|
||||||
|
let current = normalized.startsWith("/") ? "/" : "";
|
||||||
|
for (const segment of segments) {
|
||||||
|
current = current === "/" ? `/${segment}` : current ? `${current}/${segment}` : segment;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
sftp.mkdir(current, (error) => {
|
||||||
|
if (!error) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sftp.stat(current, (statError) => statError ? reject(error) : resolve());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(remotePath: string, content: Buffer): Promise<void> {
|
||||||
|
return this.sftpQueue.enqueue(async () => {
|
||||||
|
const sftp = await this.sftp();
|
||||||
|
await this.mkdirUnqueued(posixPath.dirname(remotePath));
|
||||||
|
const temporary = `${remotePath}.pi-ssh-${randomBytes(8).toString("hex")}.tmp`;
|
||||||
|
const write = (path: string, data: Buffer) => new Promise<void>((resolve, reject) => {
|
||||||
|
sftp.writeFile(path, data, (error) => error ? reject(error) : resolve());
|
||||||
|
});
|
||||||
|
const standardRename = (from: string, to: string) => new Promise<void>((resolve, reject) => {
|
||||||
|
sftp.rename(from, to, (error) => error ? reject(error) : resolve());
|
||||||
|
});
|
||||||
|
const atomicRename = typeof sftp.ext_openssh_rename === "function"
|
||||||
|
? (from: string, to: string) => new Promise<void>((resolve, reject) => {
|
||||||
|
sftp.ext_openssh_rename?.(from, to, (error) => error ? reject(error) : resolve());
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
try {
|
||||||
|
await write(temporary, content);
|
||||||
|
if (atomicRename) {
|
||||||
|
await atomicRename(temporary, remotePath);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await standardRename(temporary, remotePath);
|
||||||
|
} catch {
|
||||||
|
// SFTP v3 rename commonly refuses to replace an existing target.
|
||||||
|
// Fall back to a direct write without deleting the existing file first.
|
||||||
|
await write(remotePath, content);
|
||||||
|
await new Promise<void>((resolve) => sftp.unlink(temporary, () => resolve()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await new Promise<void>((resolve) => sftp.unlink(temporary, () => resolve()));
|
||||||
|
throw new Error(`remote write failed: ${errorMessage(error)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeHostKey(
|
||||||
|
hostName: string,
|
||||||
|
port: number,
|
||||||
|
user: string,
|
||||||
|
client: Client = new Client(),
|
||||||
|
timeoutMs = 15_000,
|
||||||
|
): Promise<{ algorithm: string; fingerprint: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let observed: { algorithm: string; fingerprint: string } | undefined;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
client.destroy();
|
||||||
|
reject(new Error("timed out while obtaining SSH host key"));
|
||||||
|
}, timeoutMs);
|
||||||
|
client.on("error", (error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
client.destroy();
|
||||||
|
observed ? resolve(observed) : reject(error);
|
||||||
|
});
|
||||||
|
client.on("close", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (observed) resolve(observed);
|
||||||
|
});
|
||||||
|
client.connect({
|
||||||
|
host: hostName,
|
||||||
|
port,
|
||||||
|
username: user,
|
||||||
|
readyTimeout: Math.min(timeoutMs, 12_000),
|
||||||
|
hostVerifier: (key) => {
|
||||||
|
observed = fingerprintHostKey(key);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||||
|
import {
|
||||||
|
chmodSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
renameSync,
|
||||||
|
statSync,
|
||||||
|
unlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { basename, join } from "node:path";
|
||||||
|
import {
|
||||||
|
emptyPiSshConfig,
|
||||||
|
resolveVaultPaths,
|
||||||
|
validatePiSshConfig,
|
||||||
|
type PiSshConfig,
|
||||||
|
type VaultPaths,
|
||||||
|
} from "./config.ts";
|
||||||
|
|
||||||
|
const FORMAT = "my-pi-ssh-v1";
|
||||||
|
const KEY_BYTES = 32;
|
||||||
|
const IV_BYTES = 12;
|
||||||
|
|
||||||
|
interface EncryptedEnvelope {
|
||||||
|
format: typeof FORMAT;
|
||||||
|
iv: string;
|
||||||
|
tag: string;
|
||||||
|
ciphertext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertOwnerOnly(path: string): void {
|
||||||
|
if (process.platform === "win32") return;
|
||||||
|
const mode = statSync(path).mode & 0o777;
|
||||||
|
if ((mode & 0o077) !== 0) {
|
||||||
|
throw new Error(`${basename(path)} permissions must be 600 or stricter`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDirectory(paths: VaultPaths): void {
|
||||||
|
mkdirSync(paths.directory, { recursive: true, mode: 0o700 });
|
||||||
|
if (process.platform !== "win32") chmodSync(paths.directory, 0o700);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKey(text: string): Buffer {
|
||||||
|
const normalized = text.trim();
|
||||||
|
const key = Buffer.from(normalized, "base64");
|
||||||
|
if (key.length !== KEY_BYTES || key.toString("base64") !== normalized) {
|
||||||
|
throw new Error("vault.key is invalid");
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readKey(paths: VaultPaths): Buffer {
|
||||||
|
if (!existsSync(paths.key)) throw new Error("pi-ssh vault key is missing; run ssh_config.sh import");
|
||||||
|
assertOwnerOnly(paths.key);
|
||||||
|
return parseKey(readFileSync(paths.key, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateKey(paths: VaultPaths): Buffer {
|
||||||
|
ensureDirectory(paths);
|
||||||
|
if (existsSync(paths.key)) return readKey(paths);
|
||||||
|
const key = randomBytes(KEY_BYTES);
|
||||||
|
writeFileSync(paths.key, `${key.toString("base64")}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||||||
|
if (process.platform !== "win32") chmodSync(paths.key, 0o600);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvelope(text: string): EncryptedEnvelope {
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error("pi-ssh encrypted configuration is invalid JSON");
|
||||||
|
}
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
|
throw new Error("pi-ssh encrypted configuration is invalid");
|
||||||
|
}
|
||||||
|
const envelope = value as Record<string, unknown>;
|
||||||
|
for (const field of ["iv", "tag", "ciphertext"] as const) {
|
||||||
|
if (typeof envelope[field] !== "string" || envelope[field].length === 0) {
|
||||||
|
throw new Error(`pi-ssh encrypted configuration is missing ${field}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (envelope.format !== FORMAT) throw new Error("unsupported pi-ssh encrypted configuration format");
|
||||||
|
return envelope as unknown as EncryptedEnvelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptConfig(config: PiSshConfig, key: Buffer): string {
|
||||||
|
if (key.length !== KEY_BYTES) throw new Error("pi-ssh vault key must be 32 bytes");
|
||||||
|
const validated = validatePiSshConfig(config);
|
||||||
|
const iv = randomBytes(IV_BYTES);
|
||||||
|
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||||
|
cipher.setAAD(Buffer.from(FORMAT, "utf8"));
|
||||||
|
const plaintext = Buffer.from(JSON.stringify(validated), "utf8");
|
||||||
|
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||||
|
const envelope: EncryptedEnvelope = {
|
||||||
|
format: FORMAT,
|
||||||
|
iv: iv.toString("base64"),
|
||||||
|
tag: cipher.getAuthTag().toString("base64"),
|
||||||
|
ciphertext: ciphertext.toString("base64"),
|
||||||
|
};
|
||||||
|
return `${JSON.stringify(envelope, null, 2)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptConfig(encrypted: string, key: Buffer): PiSshConfig {
|
||||||
|
if (key.length !== KEY_BYTES) throw new Error("pi-ssh vault key must be 32 bytes");
|
||||||
|
const envelope = parseEnvelope(encrypted);
|
||||||
|
try {
|
||||||
|
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(envelope.iv, "base64"));
|
||||||
|
decipher.setAAD(Buffer.from(FORMAT, "utf8"));
|
||||||
|
decipher.setAuthTag(Buffer.from(envelope.tag, "base64"));
|
||||||
|
const plaintext = Buffer.concat([
|
||||||
|
decipher.update(Buffer.from(envelope.ciphertext, "base64")),
|
||||||
|
decipher.final(),
|
||||||
|
]).toString("utf8");
|
||||||
|
return validatePiSshConfig(JSON.parse(plaintext));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) throw new Error("decrypted pi-ssh configuration is invalid JSON");
|
||||||
|
throw new Error("pi-ssh configuration could not be decrypted or failed integrity verification");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadVault(paths: VaultPaths = resolveVaultPaths()): PiSshConfig {
|
||||||
|
if (!existsSync(paths.encryptedConfig)) {
|
||||||
|
throw new Error("pi-ssh is not configured; run ssh_config.sh import");
|
||||||
|
}
|
||||||
|
assertOwnerOnly(paths.encryptedConfig);
|
||||||
|
const key = readKey(paths);
|
||||||
|
return decryptConfig(readFileSync(paths.encryptedConfig, "utf8"), key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadVaultOrEmpty(paths: VaultPaths = resolveVaultPaths()): PiSshConfig {
|
||||||
|
return existsSync(paths.encryptedConfig) ? loadVault(paths) : emptyPiSshConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveVault(config: PiSshConfig, paths: VaultPaths = resolveVaultPaths()): void {
|
||||||
|
const key = getOrCreateKey(paths);
|
||||||
|
const encrypted = encryptConfig(config, key);
|
||||||
|
const temp = join(paths.directory, `.hosts.enc.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
||||||
|
try {
|
||||||
|
writeFileSync(temp, encrypted, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||||||
|
if (process.platform !== "win32") chmodSync(temp, 0o600);
|
||||||
|
renameSync(temp, paths.encryptedConfig);
|
||||||
|
if (process.platform !== "win32") chmodSync(paths.encryptedConfig, 0o600);
|
||||||
|
} finally {
|
||||||
|
if (existsSync(temp)) unlinkSync(temp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rotateVaultKey(paths: VaultPaths = resolveVaultPaths()): void {
|
||||||
|
const config = loadVault(paths);
|
||||||
|
const newKey = randomBytes(KEY_BYTES);
|
||||||
|
const suffix = `${process.pid}.${randomBytes(6).toString("hex")}`;
|
||||||
|
const keyTemp = join(paths.directory, `.vault.key.${suffix}.tmp`);
|
||||||
|
const configTemp = join(paths.directory, `.hosts.enc.${suffix}.tmp`);
|
||||||
|
const keyBackup = join(paths.directory, `.vault.key.${suffix}.backup`);
|
||||||
|
const configBackup = join(paths.directory, `.hosts.enc.${suffix}.backup`);
|
||||||
|
let committed = false;
|
||||||
|
try {
|
||||||
|
writeFileSync(keyTemp, `${newKey.toString("base64")}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||||||
|
writeFileSync(configTemp, encryptConfig(config, newKey), { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||||||
|
renameSync(paths.key, keyBackup);
|
||||||
|
renameSync(paths.encryptedConfig, configBackup);
|
||||||
|
renameSync(keyTemp, paths.key);
|
||||||
|
renameSync(configTemp, paths.encryptedConfig);
|
||||||
|
committed = true;
|
||||||
|
} finally {
|
||||||
|
if (!committed) {
|
||||||
|
if (existsSync(keyBackup)) {
|
||||||
|
if (existsSync(paths.key)) unlinkSync(paths.key);
|
||||||
|
renameSync(keyBackup, paths.key);
|
||||||
|
}
|
||||||
|
if (existsSync(configBackup)) {
|
||||||
|
if (existsSync(paths.encryptedConfig)) unlinkSync(paths.encryptedConfig);
|
||||||
|
renameSync(configBackup, paths.encryptedConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const path of [keyTemp, configTemp, keyBackup, configBackup]) {
|
||||||
|
if (existsSync(path)) unlinkSync(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { getConfiguredHost, parseConnectInput, SSH_CONNECT_TOOL_METADATA } from "../src/agent-connection.ts";
|
||||||
|
|
||||||
|
test("defines the reviewed agent-controlled SSH connection tool", () => {
|
||||||
|
assert.equal(SSH_CONNECT_TOOL_METADATA.name, "ssh_connect");
|
||||||
|
assert.equal(SSH_CONNECT_TOOL_METADATA.executionMode, "sequential");
|
||||||
|
assert.match(SSH_CONNECT_TOOL_METADATA.description, /separate step/);
|
||||||
|
assert.match(SSH_CONNECT_TOOL_METADATA.description, /wait for success/);
|
||||||
|
assert.deepEqual(SSH_CONNECT_TOOL_METADATA.parameters.required, ["hostId"]);
|
||||||
|
assert.ok(SSH_CONNECT_TOOL_METADATA.parameters.properties.remotePath);
|
||||||
|
assert.deepEqual(parseConnectInput({ hostId: " packaging-server " }), { hostId: "packaging-server" });
|
||||||
|
assert.deepEqual(parseConnectInput({ hostId: "packaging-server", remotePath: "~/api" }), {
|
||||||
|
hostId: "packaging-server",
|
||||||
|
remotePath: "~/api",
|
||||||
|
});
|
||||||
|
assert.throws(() => parseConnectInput({ hostId: "user@host" }), /invalid pi-ssh host id/);
|
||||||
|
assert.throws(() => parseConnectInput({ hostId: "packaging-server", remotePath: "relative" }), /remote path/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown hosts report bounded imported host ID alternatives", () => {
|
||||||
|
const host = {
|
||||||
|
hostName: "example.test",
|
||||||
|
user: "builder",
|
||||||
|
port: 22,
|
||||||
|
auth: { type: "password" as const, password: "secret" },
|
||||||
|
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:fixture" },
|
||||||
|
};
|
||||||
|
assert.equal(getConfiguredHost({ version: 1, hosts: { "packaging-server": host } }, "packaging-server"), host);
|
||||||
|
assert.throws(
|
||||||
|
() => getConfiguredHost({ version: 1, hosts: { "packaging-server": host, "build-server": host } }, "connect-packaging-server"),
|
||||||
|
/available imported host IDs: build-server, packaging-server/,
|
||||||
|
);
|
||||||
|
const manyHosts = Object.fromEntries(Array.from({ length: 12 }, (_, index) => [`host-${String(index).padStart(2, "0")}`, host]));
|
||||||
|
assert.throws(
|
||||||
|
() => getConfiguredHost({ version: 1, hosts: manyHosts }, "missing"),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.match(String(error), /host-00, host-01, host-02/);
|
||||||
|
assert.match(String(error), /… \(\+2 more\)/);
|
||||||
|
assert.doesNotMatch(String(error), /example\.test|secret/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.throws(() => getConfiguredHost({ version: 1, hosts: {} }, "missing"), /no hosts are imported/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removes manual and implicit SSH connection surfaces", async () => {
|
||||||
|
const source = await readFile(new URL("../index.ts", import.meta.url), "utf8");
|
||||||
|
assert.match(source, /\.\.\.SSH_CONNECT_TOOL_METADATA/);
|
||||||
|
assert.doesNotMatch(source, /registerCommand\(["']ssh["']/);
|
||||||
|
assert.doesNotMatch(source, /registerFlag\(["']ssh["']/);
|
||||||
|
assert.doesNotMatch(source, /getFlag\(["']ssh["']/);
|
||||||
|
assert.doesNotMatch(source, /appendEntry\(["']pi-ssh-config["']/);
|
||||||
|
assert.doesNotMatch(source, /pi\.on\(["']user_bash["']/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
import { validatePiSshConfig } from "../src/config.ts";
|
||||||
|
import { effectiveValue, effectiveValues, parseSshG } from "../src/import.ts";
|
||||||
|
import { fingerprintHostKey } from "../src/ssh2-transport.ts";
|
||||||
|
|
||||||
|
test("parses the effective ssh -G output including repeated identity files", () => {
|
||||||
|
const parsed = parseSshG([
|
||||||
|
"host packaging-server",
|
||||||
|
"hostname 192.0.2.20",
|
||||||
|
"user builder",
|
||||||
|
"port 2222",
|
||||||
|
"identityfile ~/.ssh/first",
|
||||||
|
"identityfile ~/.ssh/second",
|
||||||
|
"proxyjump none",
|
||||||
|
].join("\n"));
|
||||||
|
assert.equal(effectiveValue(parsed, "hostname"), "192.0.2.20");
|
||||||
|
assert.equal(effectiveValue(parsed, "port"), "2222");
|
||||||
|
assert.deepEqual(effectiveValues(parsed, "identityfile"), ["~/.ssh/first", "~/.ssh/second"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validates password and private-key host definitions", () => {
|
||||||
|
const password = validatePiSshConfig({
|
||||||
|
version: 1,
|
||||||
|
hosts: {
|
||||||
|
build: {
|
||||||
|
hostName: "build.example.test",
|
||||||
|
user: "builder",
|
||||||
|
port: 22,
|
||||||
|
auth: { type: "password", password: "secret" },
|
||||||
|
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:value" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
groups: { builders: { label: "Builders", hosts: ["build"] } },
|
||||||
|
});
|
||||||
|
assert.equal(password.hosts.build.auth.type, "password");
|
||||||
|
|
||||||
|
assert.throws(() => validatePiSshConfig({
|
||||||
|
version: 1,
|
||||||
|
hosts: {
|
||||||
|
build: {
|
||||||
|
hostName: "build.example.test",
|
||||||
|
user: "builder",
|
||||||
|
port: 22,
|
||||||
|
auth: { type: "password", password: "" },
|
||||||
|
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:value" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}), /password/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats SSH host keys as pinned SHA256 fingerprints", () => {
|
||||||
|
const algorithm = Buffer.from("ssh-ed25519", "ascii");
|
||||||
|
const length = Buffer.alloc(4);
|
||||||
|
length.writeUInt32BE(algorithm.length);
|
||||||
|
const key = Buffer.concat([length, algorithm, Buffer.from("public-key-fixture")]);
|
||||||
|
const expected = createHash("sha256").update(key).digest("base64").replace(/=+$/, "");
|
||||||
|
assert.deepEqual(fingerprintHostKey(key), {
|
||||||
|
algorithm: "ssh-ed25519",
|
||||||
|
fingerprint: `SHA256:${expected}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configuration import validates remote HOME and cwd with framed probes", async () => {
|
||||||
|
const source = await readFile(new URL("../scripts/ssh-config.mjs", import.meta.url), "utf8");
|
||||||
|
assert.match(source, /probeRemotePath\(transport, "home"\)/);
|
||||||
|
assert.match(source, /probeRemotePath\(transport, "cwd"\)/);
|
||||||
|
assert.doesNotMatch(source, /transport\.capture\(/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { resolveVaultPaths, type PiSshConfig } from "../src/config.ts";
|
||||||
|
import { saveVault } from "../src/vault.ts";
|
||||||
|
|
||||||
|
const fixture: PiSshConfig = {
|
||||||
|
version: 1,
|
||||||
|
hosts: {
|
||||||
|
packaging: {
|
||||||
|
label: "Packaging",
|
||||||
|
hostName: "192.0.2.15",
|
||||||
|
user: "builder",
|
||||||
|
port: 22,
|
||||||
|
auth: { type: "password", password: "never-print-this" },
|
||||||
|
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:fixture" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, HOME: home, XDG_CONFIG_HOME: configHome },
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.match(result.stdout, /packaging\s+Packaging\s+builder@192\.0\.2\.15:22\s+password/);
|
||||||
|
assert.doesNotMatch(`${result.stdout}${result.stderr}`, /never-print-this/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const extensionSource = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
test("does not discover or inject remote project instruction files", () => {
|
||||||
|
assert.doesNotMatch(extensionSource, /AGENTS\.md|CLAUDE\.md/);
|
||||||
|
assert.doesNotMatch(extensionSource, /loadRemoteContext|Remote Project Context/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import type { PermissionsService } from "@gotgenes/pi-permission-system";
|
||||||
|
import {
|
||||||
|
formatSshPermissionInput,
|
||||||
|
installSshPermissionIntegration,
|
||||||
|
type SshPermissionConnection,
|
||||||
|
} from "../permission-integration.ts";
|
||||||
|
|
||||||
|
function makeService() {
|
||||||
|
const formatters = new Map<string, (input: Record<string, unknown>) => string | undefined>();
|
||||||
|
const extractors = new Map<string, (input: Record<string, unknown>) => string | undefined>();
|
||||||
|
const service = {
|
||||||
|
registerToolInputFormatter(name: string, formatter: (input: Record<string, unknown>) => string | undefined) {
|
||||||
|
formatters.set(name, formatter);
|
||||||
|
return () => formatters.delete(name);
|
||||||
|
},
|
||||||
|
registerToolAccessExtractor(name: string, extractor: (input: Record<string, unknown>) => string | undefined) {
|
||||||
|
extractors.set(name, extractor);
|
||||||
|
return () => extractors.delete(name);
|
||||||
|
},
|
||||||
|
} as unknown as PermissionsService;
|
||||||
|
return { service, formatters, extractors };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePi() {
|
||||||
|
const hooks = new Map<string, Array<() => void>>();
|
||||||
|
const eventHooks = new Map<string, Array<() => void>>();
|
||||||
|
const add = (map: Map<string, Array<() => void>>, name: string, handler: () => void) => {
|
||||||
|
map.set(name, [...(map.get(name) ?? []), handler]);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
api: {
|
||||||
|
on: (name: string, handler: () => void) => add(hooks, name, handler),
|
||||||
|
events: { on: (name: string, handler: () => void) => add(eventHooks, name, handler) },
|
||||||
|
},
|
||||||
|
emit: (name: string) => hooks.get(name)?.forEach((handler) => handler()),
|
||||||
|
emitEvent: (name: string) => eventHooks.get(name)?.forEach((handler) => handler()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection: SshPermissionConnection = {
|
||||||
|
remote: "packaging-server",
|
||||||
|
port: 2222,
|
||||||
|
remoteCwd: "/srv/build",
|
||||||
|
remoteHome: "/home/builder",
|
||||||
|
};
|
||||||
|
|
||||||
|
test("formats reviewed connection requests without exposing credentials", () => {
|
||||||
|
assert.equal(
|
||||||
|
formatSshPermissionInput("ssh_connect", { hostId: "packaging-server", remotePath: "/srv/build" }, connection),
|
||||||
|
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; establish a persistent SSH2 connection",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
formatSshPermissionInput("ssh_connect", { hostId: "unknown" }, null),
|
||||||
|
"requested imported SSH host 'unknown'; establish a persistent SSH2 connection",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats the SSH target and bounded operation details", () => {
|
||||||
|
assert.equal(
|
||||||
|
formatSshPermissionInput("ssh_read", { path: "src/main.ts", offset: 5, limit: 20 }, connection),
|
||||||
|
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; read remote path '/srv/build/src/main.ts' (requested 'src/main.ts'), offset 5, limit 20",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
formatSshPermissionInput("ssh_write", { path: "dist/a.txt", content: "one\ntwo" }, connection),
|
||||||
|
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; write remote path '/srv/build/dist/a.txt' (requested 'dist/a.txt') (2 lines, 7 characters)",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
formatSshPermissionInput("ssh_grep", { pattern: "TODO", path: "src", include: "*.ts", limit: 25 }, connection),
|
||||||
|
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; search remote file contents under '/srv/build/src' (requested 'src'), for 'TODO', limit 25, file glob '*.ts'",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
formatSshPermissionInput("ssh_cd", { path: "../release" }, connection),
|
||||||
|
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; change the active remote cwd to '/srv/release' (requested '../release')",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
formatSshPermissionInput("ssh_read", { path: "~/logs/app.log" }, connection),
|
||||||
|
/remote path '\/home\/builder\/logs\/app\.log' \(requested '~\/logs\/app\.log'\)/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("registers previews and disables local path extraction for remote path tools", () => {
|
||||||
|
const { service, formatters, extractors } = makeService();
|
||||||
|
const pi = makePi();
|
||||||
|
const dispose = installSshPermissionIntegration(
|
||||||
|
pi.api as never,
|
||||||
|
() => connection,
|
||||||
|
{
|
||||||
|
getPermissionsService: () => service,
|
||||||
|
permissionsReadyChannel: "permissions:ready",
|
||||||
|
getConnectTarget: () => connection,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual([...formatters.keys()], ["ssh_connect", "ssh_cd", "ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep", "ssh_bash"]);
|
||||||
|
assert.deepEqual([...extractors.keys()], ["ssh_cd", "ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"]);
|
||||||
|
assert.equal(extractors.get("ssh_read")?.({ path: "/remote/secret" }), undefined);
|
||||||
|
assert.equal(extractors.get("ssh_grep")?.({ path: "/remote/src" }), undefined);
|
||||||
|
assert.equal(extractors.get("ssh_cd")?.({ path: "/remote/release" }), undefined);
|
||||||
|
assert.match(formatters.get("ssh_bash")?.({ command: "git push" }) ?? "", /packaging-server:2222/);
|
||||||
|
assert.match(formatters.get("ssh_connect")?.({ hostId: "packaging-server" }) ?? "", /establish a persistent SSH2 connection/);
|
||||||
|
|
||||||
|
dispose();
|
||||||
|
assert.equal(formatters.size, 0);
|
||||||
|
assert.equal(extractors.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("registers when the permission service becomes ready and cleans up on shutdown", () => {
|
||||||
|
const { service, formatters, extractors } = makeService();
|
||||||
|
const pi = makePi();
|
||||||
|
let published: PermissionsService | undefined;
|
||||||
|
installSshPermissionIntegration(pi.api as never, () => connection, {
|
||||||
|
getPermissionsService: () => published,
|
||||||
|
permissionsReadyChannel: "permissions:ready",
|
||||||
|
});
|
||||||
|
assert.equal(formatters.size, 0);
|
||||||
|
|
||||||
|
published = service;
|
||||||
|
pi.emitEvent("permissions:ready");
|
||||||
|
assert.equal(formatters.size, 8);
|
||||||
|
assert.equal(extractors.size, 6);
|
||||||
|
|
||||||
|
pi.emit("session_shutdown");
|
||||||
|
assert.equal(formatters.size, 0);
|
||||||
|
assert.equal(extractors.size, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { createRemoteBashOps } from "../src/remote-bash.ts";
|
||||||
|
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
|
||||||
|
|
||||||
|
class CapturingTransport implements RemoteTransport {
|
||||||
|
calls: Array<{ command: string; cwd: string }> = [];
|
||||||
|
connect(): Promise<void> { return Promise.resolve(); }
|
||||||
|
dispose(): Promise<void> { return Promise.resolve(); }
|
||||||
|
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
|
||||||
|
this.calls.push({ command, cwd });
|
||||||
|
options.onData(Buffer.from("ok\n"));
|
||||||
|
return Promise.resolve({ exitCode: 0 });
|
||||||
|
}
|
||||||
|
capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); }
|
||||||
|
readFile(): Promise<Buffer> { throw new Error("not used"); }
|
||||||
|
ensureReadable(): Promise<void> { throw new Error("not used"); }
|
||||||
|
ensureReadableWritable(): Promise<void> { throw new Error("not used"); }
|
||||||
|
detectImageMimeType(): Promise<string | null> { throw new Error("not used"); }
|
||||||
|
mkdir(): Promise<void> { throw new Error("not used"); }
|
||||||
|
writeFile(): Promise<void> { throw new Error("not used"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("ssh_bash always executes from the active remote cwd", async () => {
|
||||||
|
const connection = { remoteCwd: "/srv/project" };
|
||||||
|
const transport = new CapturingTransport();
|
||||||
|
const operations = createRemoteBashOps(connection, transport);
|
||||||
|
|
||||||
|
await operations.exec("pwd", "/Users/local/project", { onData() {} });
|
||||||
|
assert.deepEqual(transport.calls, [{ command: "pwd", cwd: "/srv/project" }]);
|
||||||
|
|
||||||
|
connection.remoteCwd = "/opt/next-project";
|
||||||
|
await operations.exec("npm test", "/another/local/path", { onData() {} });
|
||||||
|
assert.deepEqual(transport.calls[1], { command: "npm test", cwd: "/opt/next-project" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ssh_bash rejects an invalid non-absolute remote cwd", () => {
|
||||||
|
const operations = createRemoteBashOps({ remoteCwd: "relative/path" }, new CapturingTransport());
|
||||||
|
assert.throws(
|
||||||
|
() => operations.exec("pwd", "/Users/local/project", { onData() {} }),
|
||||||
|
/requires an absolute remote cwd/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { createRemoteBashOps } from "../src/remote-bash.ts";
|
||||||
|
import {
|
||||||
|
changeRemoteCwd,
|
||||||
|
mapLocalPathToRemote,
|
||||||
|
resolveRemoteCwd,
|
||||||
|
SSH_CD_EXECUTION_MODE,
|
||||||
|
} from "../src/remote-cwd.ts";
|
||||||
|
import { resolveRemoteSearchPath } from "../src/remote-search.ts";
|
||||||
|
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
|
||||||
|
|
||||||
|
class CapturingTransport implements RemoteTransport {
|
||||||
|
calls: Array<{ command: string; cwd: string }> = [];
|
||||||
|
connect(): Promise<void> { return Promise.resolve(); }
|
||||||
|
dispose(): Promise<void> { return Promise.resolve(); }
|
||||||
|
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
|
||||||
|
this.calls.push({ command, cwd });
|
||||||
|
options.onData(Buffer.from("ok\n"));
|
||||||
|
return Promise.resolve({ exitCode: 0 });
|
||||||
|
}
|
||||||
|
capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); }
|
||||||
|
readFile(): Promise<Buffer> { throw new Error("not used"); }
|
||||||
|
ensureReadable(): Promise<void> { throw new Error("not used"); }
|
||||||
|
ensureReadableWritable(): Promise<void> { throw new Error("not used"); }
|
||||||
|
detectImageMimeType(): Promise<string | null> { throw new Error("not used"); }
|
||||||
|
mkdir(): Promise<void> { throw new Error("not used"); }
|
||||||
|
writeFile(): Promise<void> { throw new Error("not used"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("resolves explicit remote workspace changes", () => {
|
||||||
|
assert.equal(resolveRemoteCwd("services/api", "/srv/project", "/home/build"), "/srv/project/services/api");
|
||||||
|
assert.equal(resolveRemoteCwd("../shared", "/srv/project", "/home/build"), "/srv/shared");
|
||||||
|
assert.equal(resolveRemoteCwd("/opt/app/../release", "/srv/project", "/home/build"), "/opt/release");
|
||||||
|
assert.equal(resolveRemoteCwd("~", "/srv/project", "/home/build"), "/home/build");
|
||||||
|
assert.equal(resolveRemoteCwd("~/jobs/app", "/srv/project", "/home/build"), "/home/build/jobs/app");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid remote workspace paths", () => {
|
||||||
|
assert.throws(() => resolveRemoteCwd("", "/srv/project", "/home/build"), /non-empty/);
|
||||||
|
assert.throws(() => resolveRemoteCwd("bad\npath", "/srv/project", "/home/build"), /NUL or newline/);
|
||||||
|
assert.throws(() => resolveRemoteCwd("~other/project", "/srv/project", "/home/build"), /only '~' or '~\/'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ssh_cd is a sequential workspace transition", () => {
|
||||||
|
assert.equal(SSH_CD_EXECUTION_MODE, "sequential");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a validated workspace change drives subsequent remote tools", async () => {
|
||||||
|
const connection = {
|
||||||
|
remoteCwd: "/srv/project",
|
||||||
|
remoteHome: "/home/build",
|
||||||
|
localCwd: "/Users/local/project",
|
||||||
|
localHome: "/Users/local",
|
||||||
|
};
|
||||||
|
const verified: string[] = [];
|
||||||
|
|
||||||
|
const changed = await changeRemoteCwd(connection, "../release", async (requestedCwd) => {
|
||||||
|
verified.push(requestedCwd);
|
||||||
|
return requestedCwd;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(verified, ["/srv/release"]);
|
||||||
|
assert.deepEqual(changed, { previousCwd: "/srv/project", remoteCwd: "/srv/release" });
|
||||||
|
assert.equal(connection.remoteCwd, "/srv/release");
|
||||||
|
assert.equal(mapLocalPathToRemote("/Users/local/project/logs/build.log", connection), "/srv/release/logs/build.log");
|
||||||
|
assert.equal(resolveRemoteSearchPath(undefined, connection.remoteCwd, connection.remoteHome), "/srv/release");
|
||||||
|
|
||||||
|
const transport = new CapturingTransport();
|
||||||
|
const operations = createRemoteBashOps(connection, transport);
|
||||||
|
await operations.exec("npm test", "/Users/local/project", { onData() {} });
|
||||||
|
assert.deepEqual(transport.calls, [{ command: "npm test", cwd: "/srv/release" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed workspace validation leaves the previous cwd active", async () => {
|
||||||
|
const connection = { remoteCwd: "/srv/project", remoteHome: "/home/build" };
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
changeRemoteCwd(connection, "missing", async () => {
|
||||||
|
throw new Error("not a directory");
|
||||||
|
}),
|
||||||
|
/not a directory/,
|
||||||
|
);
|
||||||
|
assert.equal(connection.remoteCwd, "/srv/project");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("successive workspace changes resolve from the latest confirmed cwd", async () => {
|
||||||
|
const connection = { remoteCwd: "/srv/project", remoteHome: "/home/build" };
|
||||||
|
const verify = async (requestedCwd: string) => requestedCwd;
|
||||||
|
|
||||||
|
await changeRemoteCwd(connection, "services/api", verify);
|
||||||
|
await changeRemoteCwd(connection, "../worker", verify);
|
||||||
|
|
||||||
|
assert.equal(connection.remoteCwd, "/srv/project/services/worker");
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { parseRemotePathProbe, probeRemotePath } from "../src/remote-probe.ts";
|
||||||
|
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
|
||||||
|
|
||||||
|
class ProbeTransport implements RemoteTransport {
|
||||||
|
command = "";
|
||||||
|
cwd = "";
|
||||||
|
connect(): Promise<void> { return Promise.resolve(); }
|
||||||
|
dispose(): Promise<void> { return Promise.resolve(); }
|
||||||
|
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
|
||||||
|
this.command = command;
|
||||||
|
this.cwd = cwd;
|
||||||
|
const start = command.match(/'(__PI_SSH_PROBE_[a-f0-9]+_START__)'/)?.[1];
|
||||||
|
const end = command.match(/'(__PI_SSH_PROBE_[a-f0-9]+_END__)'/)?.[1];
|
||||||
|
if (!start || !end) throw new Error("probe markers missing");
|
||||||
|
options.onData(Buffer.from(`login banner\n${start}/srv/project${end}\nlogout banner\n`));
|
||||||
|
return Promise.resolve({ exitCode: 0 });
|
||||||
|
}
|
||||||
|
capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); }
|
||||||
|
readFile(): Promise<Buffer> { throw new Error("not used"); }
|
||||||
|
ensureReadable(): Promise<void> { throw new Error("not used"); }
|
||||||
|
ensureReadableWritable(): Promise<void> { throw new Error("not used"); }
|
||||||
|
detectImageMimeType(): Promise<string | null> { throw new Error("not used"); }
|
||||||
|
mkdir(): Promise<void> { throw new Error("not used"); }
|
||||||
|
writeFile(): Promise<void> { throw new Error("not used"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("extracts one framed absolute path while ignoring shell startup output", async () => {
|
||||||
|
const transport = new ProbeTransport();
|
||||||
|
assert.equal(await probeRemotePath(transport, "cwd", "/srv"), "/srv/project");
|
||||||
|
assert.equal(transport.cwd, "/srv");
|
||||||
|
assert.match(transport.command, /pwd -P/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects unframed, relative, multiline, and oversized path probes", () => {
|
||||||
|
assert.throws(() => parseRemotePathProbe(Buffer.from("/srv"), "START", "END", "cwd"), /invalid framed/);
|
||||||
|
assert.throws(() => parseRemotePathProbe(Buffer.from("STARTrelativeEND"), "START", "END", "cwd"), /absolute POSIX path/);
|
||||||
|
assert.throws(() => parseRemotePathProbe(Buffer.from("START/srv\notherEND"), "START", "END", "cwd"), /absolute POSIX path/);
|
||||||
|
assert.throws(
|
||||||
|
() => parseRemotePathProbe(Buffer.alloc(64 * 1024 + 1), "START", "END", "cwd"),
|
||||||
|
/exceeded 65536 bytes/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forwards cancellation to the probe exec call", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
const transport = new ProbeTransport();
|
||||||
|
const original = transport.exec.bind(transport);
|
||||||
|
transport.exec = (command, cwd, options) => {
|
||||||
|
assert.equal(options.signal, controller.signal);
|
||||||
|
if (options.signal?.aborted) return Promise.reject(new Error("SSH command aborted"));
|
||||||
|
return original(command, cwd, options);
|
||||||
|
};
|
||||||
|
await assert.rejects(probeRemotePath(transport, "home", ".", controller.signal), /SSH command aborted/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { execFileSync, spawnSync } from "node:child_process";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
buildRemoteFindCommand,
|
||||||
|
buildRemoteGrepCommand,
|
||||||
|
formatRemoteSearchOutput,
|
||||||
|
resolveRemoteSearchPath,
|
||||||
|
runRemoteFind,
|
||||||
|
runRemoteGrep,
|
||||||
|
} from "../src/remote-search.ts";
|
||||||
|
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
|
||||||
|
|
||||||
|
function grepProtocol(backend: string, rows: Array<[string, number, string]> = []): Buffer {
|
||||||
|
const fields = rows.flatMap(([path, line, content]) => [path, String(line), content]);
|
||||||
|
return Buffer.from(`__PI_SSH_SEARCH_BACKEND__:${backend}\n${fields.length > 0 ? `${fields.join("\0")}\0` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
class SearchTransport implements RemoteTransport {
|
||||||
|
command = "";
|
||||||
|
cwd = "";
|
||||||
|
private readonly output: Buffer;
|
||||||
|
private readonly stderr: Buffer;
|
||||||
|
private readonly exitCode: number | null;
|
||||||
|
constructor(output: string | Buffer, exitCode: number | null = 0, stderr = "") {
|
||||||
|
this.output = Buffer.isBuffer(output) ? output : Buffer.from(output);
|
||||||
|
this.stderr = Buffer.from(stderr);
|
||||||
|
this.exitCode = exitCode;
|
||||||
|
}
|
||||||
|
connect(): Promise<void> { return Promise.resolve(); }
|
||||||
|
dispose(): Promise<void> { return Promise.resolve(); }
|
||||||
|
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
|
||||||
|
this.command = command;
|
||||||
|
this.cwd = cwd;
|
||||||
|
options.onData(this.output);
|
||||||
|
if (this.stderr.length > 0) (options.onStderr ?? options.onData)(this.stderr);
|
||||||
|
return Promise.resolve({ exitCode: this.exitCode });
|
||||||
|
}
|
||||||
|
capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); }
|
||||||
|
readFile(): Promise<Buffer> { throw new Error("not used"); }
|
||||||
|
ensureReadable(): Promise<void> { throw new Error("not used"); }
|
||||||
|
ensureReadableWritable(): Promise<void> { throw new Error("not used"); }
|
||||||
|
detectImageMimeType(): Promise<string | null> { throw new Error("not used"); }
|
||||||
|
mkdir(): Promise<void> { throw new Error("not used"); }
|
||||||
|
writeFile(): Promise<void> { throw new Error("not used"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolPath(name: string): string {
|
||||||
|
return execFileSync("/bin/sh", ["-c", `command -v ${name}`], { encoding: "utf8" }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolBin(root: string, names: string[]): string {
|
||||||
|
const bin = join(root, `bin-${names.join("-")}`);
|
||||||
|
mkdirSync(bin);
|
||||||
|
for (const name of names) symlinkSync(toolPath(name), join(bin, name));
|
||||||
|
return bin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function execute(command: string, cwd: string, path: string) {
|
||||||
|
return spawnSync("/bin/bash", ["-c", command], { cwd, env: { ...process.env, PATH: path } });
|
||||||
|
}
|
||||||
|
|
||||||
|
test("resolves remote search roots without using local filesystem semantics", () => {
|
||||||
|
assert.equal(resolveRemoteSearchPath(undefined, "/srv/app", "/home/build"), "/srv/app");
|
||||||
|
assert.equal(resolveRemoteSearchPath("src", "/srv/app", "/home/build"), "/srv/app/src");
|
||||||
|
assert.equal(resolveRemoteSearchPath("~/logs", "/srv/app", "/home/build"), "/home/build/logs");
|
||||||
|
assert.equal(resolveRemoteSearchPath("/var/log", "/srv/app", "/home/build"), "/var/log");
|
||||||
|
assert.throws(() => resolveRemoteSearchPath("~other/project", "/srv/app", "/home/build"), /only ~ or ~\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("builds bounded capability-adaptive commands with unified grep semantics", () => {
|
||||||
|
const find = buildRemoteFindCommand({ pattern: "it's-app", limit: 12 }, "/srv/app dir");
|
||||||
|
assert.match(find.command, /command -v fd/);
|
||||||
|
assert.match(find.command, /git-ls-files/);
|
||||||
|
assert.match(find.command, /head -n 13/);
|
||||||
|
assert.match(find.command, /'it'"'"'s-app'/);
|
||||||
|
assert.match(find.command, /'\/srv\/app dir'/);
|
||||||
|
|
||||||
|
const grep = buildRemoteGrepCommand({ pattern: "needle+", literal: false, include: "*.ts", includeHidden: true, limit: 20 }, "/srv/app");
|
||||||
|
assert.match(grep.command, /rg --null/);
|
||||||
|
assert.match(grep.command, /git .* grep .* -z .* -E/);
|
||||||
|
assert.match(grep.command, /grep -n -I -E/);
|
||||||
|
assert.match(grep.command, /PI_SSH_TAKE=21/);
|
||||||
|
assert.match(grep.command, /--hidden/);
|
||||||
|
assert.throws(() => buildRemoteGrepCommand({ pattern: "x", include: "sub\/*.ts" }, "/srv/app"), /basename glob without \//);
|
||||||
|
assert.throws(() => buildRemoteGrepCommand({ pattern: "bad\npattern" }, "/srv/app"), /newline/);
|
||||||
|
assert.throws(() => buildRemoteFindCommand({ pattern: "x", limit: 201 }, "/srv/app"), /limit/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes NUL-delimited git paths, escaped newlines, truncation, and backend", () => {
|
||||||
|
const result = formatRemoteSearchOutput(
|
||||||
|
grepProtocol("git-grep", [["src/a.ts", 2, "TODO"], ["src/line\nb.ts", 3, "TODO"], ["src/c.ts", 4, "TODO"]]),
|
||||||
|
"/srv/app",
|
||||||
|
2,
|
||||||
|
"grep",
|
||||||
|
);
|
||||||
|
assert.equal(result.backend, "git-grep");
|
||||||
|
assert.equal(result.matchCount, 2);
|
||||||
|
assert.equal(result.truncated, true);
|
||||||
|
assert.match(result.text, /\/srv\/app\/src\/a\.ts:2:TODO/);
|
||||||
|
assert.match(result.text, /src\/line\\nb\.ts:3:TODO/);
|
||||||
|
assert.doesNotMatch(result.text, /src\/c\.ts/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("executes remote find from the active workspace while searching a separate root", async () => {
|
||||||
|
const transport = new SearchTransport("__PI_SSH_SEARCH_BACKEND__:fd\n/data/search/a.ts\n");
|
||||||
|
const result = await runRemoteFind(transport, { pattern: "a", path: "/data/search", limit: 5 }, "/srv/app", "/home/build");
|
||||||
|
assert.equal(transport.cwd, "/srv/app");
|
||||||
|
assert.match(transport.command, /command -v fd/);
|
||||||
|
assert.match(transport.command, /\/data\/search/);
|
||||||
|
assert.equal(result.matchCount, 1);
|
||||||
|
assert.match(result.text, /backend: fd/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searches a single remote file without treating the file as execution cwd", async () => {
|
||||||
|
const transport = new SearchTransport(grepProtocol("ripgrep", [["/var/log/application.log", 7, "ERROR"]]));
|
||||||
|
const result = await runRemoteGrep(transport, { pattern: "ERROR", path: "/var/log/application.log" }, "/srv/app", "/home/build");
|
||||||
|
assert.equal(transport.cwd, "/srv/app");
|
||||||
|
assert.match(transport.command, /\/var\/log\/application\.log/);
|
||||||
|
assert.equal(result.matchCount, 1);
|
||||||
|
assert.match(result.text, /application\.log:7:ERROR/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps successful stderr warnings out of match rows and uses stderr for failures", async () => {
|
||||||
|
const warningTransport = new SearchTransport(grepProtocol("ripgrep"), 0, "warning: skipped socket\n");
|
||||||
|
const warningResult = await runRemoteGrep(warningTransport, { pattern: "ABSENT" }, "/srv/app", "/home/build");
|
||||||
|
assert.equal(warningResult.matchCount, 0);
|
||||||
|
assert.match(warningResult.text, /No matches found/);
|
||||||
|
assert.match(warningResult.text, /Remote warning: warning: skipped socket/);
|
||||||
|
|
||||||
|
const failed = new SearchTransport(grepProtocol("git-grep"), 128, "fatal: invalid regular expression\n");
|
||||||
|
await assert.rejects(
|
||||||
|
runRemoteGrep(failed, { pattern: "[", literal: false }, "/srv/app", "/home/build"),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.match(String(error), /remote grep failed with exit code 128: fatal: invalid regular expression/);
|
||||||
|
assert.doesNotMatch(String(error), /__PI_SSH_SEARCH_BACKEND__/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports actionable bounded-search timeout guidance", async () => {
|
||||||
|
const transport = new SearchTransport("");
|
||||||
|
transport.exec = () => Promise.reject(new Error("SSH command timed out after 30s"));
|
||||||
|
await assert.rejects(
|
||||||
|
runRemoteGrep(transport, { pattern: "TODO", path: "/home/build" }, "/srv/app", "/home/build"),
|
||||||
|
/remote grep timed out after 30s \(root: \/home\/build\); narrow the remote path with ssh_find/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forces git-grep and fallback no-match paths and preserves real errors", () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-"));
|
||||||
|
try {
|
||||||
|
mkdirSync(join(root, "src"));
|
||||||
|
writeFileSync(join(root, "src", "a.ts"), "const value = 'TODO';\n", "utf8");
|
||||||
|
execFileSync(toolPath("git"), ["init", "-q", root]);
|
||||||
|
execFileSync(toolPath("git"), ["-C", root, "add", "src/a.ts"]);
|
||||||
|
const gitPath = toolBin(root, ["git"]);
|
||||||
|
const fallbackPath = toolBin(root, ["find", "grep"]);
|
||||||
|
|
||||||
|
const grep = buildRemoteGrepCommand({ pattern: "TODO", include: "*.ts", limit: 5 }, root);
|
||||||
|
const gitProcess = execute(grep.command, root, gitPath);
|
||||||
|
assert.equal(gitProcess.status, 0, gitProcess.stderr.toString());
|
||||||
|
const gitResult = formatRemoteSearchOutput(gitProcess.stdout, root, grep.limit, "grep");
|
||||||
|
assert.equal(gitResult.backend, "git-grep");
|
||||||
|
assert.equal(gitResult.matchCount, 1);
|
||||||
|
|
||||||
|
const fallbackProcess = execute(grep.command, root, fallbackPath);
|
||||||
|
assert.equal(fallbackProcess.status, 0, fallbackProcess.stderr.toString());
|
||||||
|
const fallbackResult = formatRemoteSearchOutput(fallbackProcess.stdout, root, grep.limit, "grep");
|
||||||
|
assert.equal(fallbackResult.backend, "grep");
|
||||||
|
assert.equal(fallbackResult.matchCount, 1);
|
||||||
|
|
||||||
|
const noMatch = buildRemoteGrepCommand({ pattern: "ABSENT", include: "*.ts" }, root);
|
||||||
|
for (const path of [gitPath, fallbackPath]) {
|
||||||
|
const process = execute(noMatch.command, root, path);
|
||||||
|
assert.equal(process.status, 0, process.stderr.toString());
|
||||||
|
assert.equal(formatRemoteSearchOutput(process.stdout, root, noMatch.limit, "grep").matchCount, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidRegex = buildRemoteGrepCommand({ pattern: "[", literal: false }, root);
|
||||||
|
const invalidProcess = execute(invalidRegex.command, root, fallbackPath);
|
||||||
|
assert.notEqual(invalidProcess.status, 0);
|
||||||
|
assert.match(invalidProcess.stderr.toString(), /regular expression|bracket/i);
|
||||||
|
|
||||||
|
const missing = buildRemoteFindCommand({ pattern: "anything" }, join(root, "missing"));
|
||||||
|
const missingProcess = execute(missing.command, root, fallbackPath);
|
||||||
|
assert.notEqual(missingProcess.status, 0);
|
||||||
|
assert.match(missingProcess.stderr.toString(), /find|No such file|not found/i);
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rg, git-grep, and fallback agree on hidden files, basename globs, and portable ERE", (context) => {
|
||||||
|
let rg: string;
|
||||||
|
try { rg = toolPath("rg"); } catch { context.skip("rg is unavailable"); return; }
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-matrix-"));
|
||||||
|
try {
|
||||||
|
mkdirSync(join(root, "sub"));
|
||||||
|
mkdirSync(join(root, ".secret"));
|
||||||
|
for (const path of ["visible.ts", "sub/nested.ts", ".hidden.ts", ".secret/deep.ts"]) {
|
||||||
|
writeFileSync(join(root, path), "needle\nneedlee\n", "utf8");
|
||||||
|
}
|
||||||
|
execFileSync(toolPath("git"), ["init", "-q", root]);
|
||||||
|
execFileSync(toolPath("git"), ["-C", root, "add", "."]);
|
||||||
|
const paths = [toolBin(root, ["rg"]), toolBin(root, ["git"]), toolBin(root, ["find", "grep"])];
|
||||||
|
assert.equal(toolPath("rg"), rg);
|
||||||
|
|
||||||
|
const runCounts = (input: Parameters<typeof buildRemoteGrepCommand>[0]) => paths.map((path) => {
|
||||||
|
const built = buildRemoteGrepCommand(input, root);
|
||||||
|
const process = execute(built.command, root, path);
|
||||||
|
assert.equal(process.status, 0, process.stderr.toString());
|
||||||
|
return formatRemoteSearchOutput(process.stdout, root, built.limit, "grep").matchCount;
|
||||||
|
});
|
||||||
|
assert.deepEqual(runCounts({ pattern: "needle", include: "*.ts" }), [4, 4, 4]);
|
||||||
|
assert.deepEqual(runCounts({ pattern: "needle", include: "*.ts", includeHidden: true }), [8, 8, 8]);
|
||||||
|
assert.deepEqual(runCounts({ pattern: "needle+", literal: false, include: "*.ts" }), [4, 4, 4]);
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fallback NUL protocol keeps newline filenames as one bounded result", () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-newline-"));
|
||||||
|
try {
|
||||||
|
const filename = "line\nbreak.ts";
|
||||||
|
writeFileSync(join(root, filename), "needle\n", "utf8");
|
||||||
|
const fallbackPath = toolBin(root, ["find", "grep"]);
|
||||||
|
const built = buildRemoteGrepCommand({ pattern: "needle", include: "*.ts" }, root);
|
||||||
|
const process = execute(built.command, root, fallbackPath);
|
||||||
|
assert.equal(process.status, 0, process.stderr.toString());
|
||||||
|
const result = formatRemoteSearchOutput(process.stdout, root, built.limit, "grep");
|
||||||
|
assert.equal(result.matchCount, 1);
|
||||||
|
assert.match(result.text, /line\\nbreak\.ts:1:needle/);
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("propagates fd and ripgrep failures while preserving ripgrep no-match", () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-backends-"));
|
||||||
|
try {
|
||||||
|
const bin = join(root, "bin");
|
||||||
|
mkdirSync(bin);
|
||||||
|
const fd = join(bin, "fd");
|
||||||
|
const rg = join(bin, "rg");
|
||||||
|
writeFileSync(fd, "#!/bin/sh\necho 'fd exploded' >&2\nexit 3\n", { mode: 0o755 });
|
||||||
|
writeFileSync(rg, "#!/bin/sh\necho 'rg exploded' >&2\nexit 2\n", { mode: 0o755 });
|
||||||
|
const environment = { ...process.env, PATH: `${bin}:/usr/bin:/bin` };
|
||||||
|
|
||||||
|
const find = buildRemoteFindCommand({ pattern: "anything" }, root);
|
||||||
|
const failedFind = spawnSync("/bin/bash", ["-c", find.command], { cwd: root, env: environment });
|
||||||
|
assert.equal(failedFind.status, 3);
|
||||||
|
assert.match(failedFind.stderr.toString(), /fd exploded/);
|
||||||
|
|
||||||
|
const grep = buildRemoteGrepCommand({ pattern: "anything" }, root);
|
||||||
|
const failedGrep = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, env: environment });
|
||||||
|
assert.equal(failedGrep.status, 2);
|
||||||
|
assert.match(failedGrep.stderr.toString(), /rg exploded/);
|
||||||
|
|
||||||
|
writeFileSync(rg, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||||
|
const noMatch = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, env: environment });
|
||||||
|
assert.equal(noMatch.status, 0);
|
||||||
|
assert.equal(formatRemoteSearchOutput(noMatch.stdout, root, grep.limit, "grep").matchCount, 0);
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import type { Client, ClientChannel, ConnectConfig, SFTPWrapper } from "ssh2";
|
||||||
|
import type { SshHostConfig } from "../src/config.ts";
|
||||||
|
import { fingerprintHostKey, probeHostKey, Ssh2Transport } from "../src/ssh2-transport.ts";
|
||||||
|
|
||||||
|
function fixtureKey(label = "fixture"): Buffer {
|
||||||
|
const algorithm = Buffer.from("ssh-ed25519");
|
||||||
|
const length = Buffer.alloc(4);
|
||||||
|
length.writeUInt32BE(algorithm.length);
|
||||||
|
return Buffer.concat([length, algorithm, Buffer.from(label)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeChannel extends EventEmitter {
|
||||||
|
stderr = new EventEmitter();
|
||||||
|
closeCalls = 0;
|
||||||
|
signals: string[] = [];
|
||||||
|
close(): void { this.closeCalls += 1; }
|
||||||
|
signal(value: string): void { this.signals.push(value); }
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConnectAction = (config: ConnectConfig, client: FakeClient) => void;
|
||||||
|
type ExecAction = (command: string, callback: (error: Error | undefined, channel: ClientChannel) => void) => void;
|
||||||
|
|
||||||
|
class FakeClient extends EventEmitter {
|
||||||
|
connectConfig?: ConnectConfig;
|
||||||
|
command?: string;
|
||||||
|
channel?: FakeChannel;
|
||||||
|
sftpCalls = 0;
|
||||||
|
ended = false;
|
||||||
|
destroyed = false;
|
||||||
|
sftpValue?: SFTPWrapper;
|
||||||
|
connectAction?: ConnectAction;
|
||||||
|
execAction?: ExecAction;
|
||||||
|
|
||||||
|
connect(config: ConnectConfig): this {
|
||||||
|
this.connectConfig = config;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (this.connectAction) this.connectAction(config, this);
|
||||||
|
else this.emit("ready");
|
||||||
|
});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
exec(command: string, callback: (error: Error | undefined, channel: ClientChannel) => void): void {
|
||||||
|
this.command = command;
|
||||||
|
if (this.execAction) {
|
||||||
|
this.execAction(command, callback);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const channel = new FakeChannel();
|
||||||
|
this.channel = channel;
|
||||||
|
callback(undefined, channel as ClientChannel);
|
||||||
|
queueMicrotask(() => {
|
||||||
|
channel.emit("data", Buffer.from("stdout\n"));
|
||||||
|
channel.stderr.emit("data", Buffer.from("stderr\n"));
|
||||||
|
channel.emit("close", 7);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sftp(callback: (error: Error | undefined, sftp: SFTPWrapper) => void): void {
|
||||||
|
this.sftpCalls += 1;
|
||||||
|
if (!this.sftpValue) callback(new Error("SFTP unavailable"), undefined as unknown as SFTPWrapper);
|
||||||
|
else callback(undefined, this.sftpValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
end(): this {
|
||||||
|
this.ended = true;
|
||||||
|
this.emit("close");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): this {
|
||||||
|
this.destroyed = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeSftp {
|
||||||
|
readonly files = new Map<string, Buffer>();
|
||||||
|
readonly directories = new Set<string>(["/"]);
|
||||||
|
readonly openCalls: Array<{ path: string; flags: string }> = [];
|
||||||
|
readonly writes: string[] = [];
|
||||||
|
readonly renames: Array<{ from: string; to: string; atomic: boolean }> = [];
|
||||||
|
readonly unlinks: string[] = [];
|
||||||
|
failStandardRename = false;
|
||||||
|
ext_openssh_rename?: (from: string, to: string, callback: (error?: Error) => void) => void;
|
||||||
|
|
||||||
|
constructor(atomicRename = true) {
|
||||||
|
if (atomicRename) {
|
||||||
|
this.ext_openssh_rename = (from, to, callback) => {
|
||||||
|
this.renames.push({ from, to, atomic: true });
|
||||||
|
const value = this.files.get(from);
|
||||||
|
if (!value) { callback(new Error("source missing")); return; }
|
||||||
|
this.files.set(to, value);
|
||||||
|
this.files.delete(from);
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readFile(path: string, callback: (error: Error | undefined, data: Buffer) => void): void {
|
||||||
|
const value = this.files.get(path);
|
||||||
|
if (!value) callback(new Error(`missing file: ${path}`), Buffer.alloc(0));
|
||||||
|
else callback(undefined, Buffer.from(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFile(path: string, data: Buffer, callback: (error?: Error) => void): void {
|
||||||
|
this.writes.push(path);
|
||||||
|
this.files.set(path, Buffer.from(data));
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
open(path: string, flags: string, callback: (error: Error | undefined, handle: Buffer) => void): void {
|
||||||
|
this.openCalls.push({ path, flags });
|
||||||
|
if (!this.files.has(path)) callback(new Error(`cannot open: ${path}`), Buffer.alloc(0));
|
||||||
|
else callback(undefined, Buffer.from(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
close(_handle: Buffer, callback: (error?: Error) => void): void { callback(); }
|
||||||
|
|
||||||
|
mkdir(path: string, callback: (error?: Error) => void): void {
|
||||||
|
if (this.directories.has(path)) { callback(new Error("already exists")); return; }
|
||||||
|
this.directories.add(path);
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
stat(path: string, callback: (error: Error | undefined, stats: object) => void): void {
|
||||||
|
if (this.directories.has(path)) callback(undefined, {});
|
||||||
|
else callback(new Error(`missing directory: ${path}`), {});
|
||||||
|
}
|
||||||
|
|
||||||
|
rename(from: string, to: string, callback: (error?: Error) => void): void {
|
||||||
|
this.renames.push({ from, to, atomic: false });
|
||||||
|
if (this.failStandardRename) { callback(new Error("replace unsupported")); return; }
|
||||||
|
const value = this.files.get(from);
|
||||||
|
if (!value) { callback(new Error("source missing")); return; }
|
||||||
|
this.files.set(to, value);
|
||||||
|
this.files.delete(from);
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
unlink(path: string, callback: (error?: Error) => void): void {
|
||||||
|
this.unlinks.push(path);
|
||||||
|
this.files.delete(path);
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function passwordHost(method: "auto" | "password" | "keyboard-interactive" = "auto"): SshHostConfig {
|
||||||
|
return {
|
||||||
|
hostName: "host.example.test",
|
||||||
|
user: "builder",
|
||||||
|
port: 2222,
|
||||||
|
auth: { type: "password", password: "secret", method },
|
||||||
|
hostKey: fingerprintHostKey(fixtureKey()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectedTransport(
|
||||||
|
sftp?: FakeSftp,
|
||||||
|
host: SshHostConfig = passwordHost(),
|
||||||
|
): Promise<{ fake: FakeClient; transport: Ssh2Transport }> {
|
||||||
|
const fake = new FakeClient();
|
||||||
|
if (sftp) fake.sftpValue = sftp as unknown as SFTPWrapper;
|
||||||
|
const transport = new Ssh2Transport(host, fake as unknown as Client);
|
||||||
|
await transport.connect();
|
||||||
|
return { fake, transport };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("connects with password auth, pins the host key, and streams exec output", async () => {
|
||||||
|
const { fake, transport } = await connectedTransport();
|
||||||
|
assert.equal(fake.connectConfig?.host, "host.example.test");
|
||||||
|
assert.equal(fake.connectConfig?.username, "builder");
|
||||||
|
assert.equal(fake.connectConfig?.password, "secret");
|
||||||
|
assert.equal(fake.connectConfig?.tryKeyboard, true);
|
||||||
|
assert.equal(fake.connectConfig?.hostVerifier?.(fixtureKey()), true);
|
||||||
|
assert.equal(fake.connectConfig?.hostVerifier?.(fixtureKey("wrong")), false);
|
||||||
|
|
||||||
|
const output: Buffer[] = [];
|
||||||
|
const stderr: Buffer[] = [];
|
||||||
|
const result = await transport.exec("printf ok", "/srv/build", {
|
||||||
|
onData: (data) => output.push(data),
|
||||||
|
onStderr: (data) => stderr.push(data),
|
||||||
|
timeout: 5,
|
||||||
|
});
|
||||||
|
assert.equal(result.exitCode, 7);
|
||||||
|
assert.equal(Buffer.concat(output).toString("utf8"), "stdout\n");
|
||||||
|
assert.equal(Buffer.concat(stderr).toString("utf8"), "stderr\n");
|
||||||
|
assert.match(fake.command ?? "", /^cd -- '\/srv\/build' && bash -lc 'printf ok' <\/dev\/null$/);
|
||||||
|
await transport.dispose();
|
||||||
|
assert.equal(fake.ended, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supports password-only and keyboard-interactive authentication", async () => {
|
||||||
|
const passwordOnly = await connectedTransport(undefined, passwordHost("password"));
|
||||||
|
assert.equal(passwordOnly.fake.connectConfig?.tryKeyboard, false);
|
||||||
|
await passwordOnly.transport.dispose();
|
||||||
|
|
||||||
|
const interactive = await connectedTransport(undefined, passwordHost("keyboard-interactive"));
|
||||||
|
let answers: string[] = [];
|
||||||
|
interactive.fake.emit(
|
||||||
|
"keyboard-interactive",
|
||||||
|
"name",
|
||||||
|
"instructions",
|
||||||
|
"",
|
||||||
|
[{ prompt: "Password:" }, { prompt: "Again:" }],
|
||||||
|
(values: string[]) => { answers = values; },
|
||||||
|
);
|
||||||
|
assert.deepEqual(answers, ["secret", "secret"]);
|
||||||
|
await interactive.transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads private-key authentication and forwards the passphrase", async () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "pi-ssh-key-"));
|
||||||
|
try {
|
||||||
|
const identityFile = join(root, "id_test");
|
||||||
|
writeFileSync(identityFile, "PRIVATE KEY FIXTURE", { mode: 0o600 });
|
||||||
|
const host: SshHostConfig = {
|
||||||
|
...passwordHost(),
|
||||||
|
auth: { type: "private-key", identityFile, passphrase: "key-secret" },
|
||||||
|
};
|
||||||
|
const { fake, transport } = await connectedTransport(undefined, host);
|
||||||
|
assert.equal(Buffer.from(fake.connectConfig?.privateKey ?? "").toString("utf8"), "PRIVATE KEY FIXTURE");
|
||||||
|
assert.equal(fake.connectConfig?.passphrase, "key-secret");
|
||||||
|
assert.equal(fake.connectConfig?.password, undefined);
|
||||||
|
assert.equal(fake.connectConfig?.tryKeyboard, undefined);
|
||||||
|
await transport.dispose();
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reads files, checks access modes, detects images, and reuses SFTP", async () => {
|
||||||
|
const sftp = new FakeSftp();
|
||||||
|
sftp.files.set("/srv/app/a.txt", Buffer.from("hello"));
|
||||||
|
sftp.files.set("/srv/app/image.png", Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
||||||
|
const { fake, transport } = await connectedTransport(sftp);
|
||||||
|
assert.equal((await transport.readFile("/srv/app/a.txt")).toString("utf8"), "hello");
|
||||||
|
await transport.ensureReadable("/srv/app/a.txt");
|
||||||
|
await transport.ensureReadableWritable("/srv/app/a.txt");
|
||||||
|
assert.deepEqual(sftp.openCalls, [
|
||||||
|
{ path: "/srv/app/a.txt", flags: "r" },
|
||||||
|
{ path: "/srv/app/a.txt", flags: "r+" },
|
||||||
|
]);
|
||||||
|
assert.equal(await transport.detectImageMimeType("/srv/app/image.png"), "image/png");
|
||||||
|
assert.equal(await transport.detectImageMimeType("/srv/app/a.txt"), null);
|
||||||
|
assert.equal(fake.sftpCalls, 1);
|
||||||
|
await transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates parent directories and atomically renames remote writes", async () => {
|
||||||
|
const sftp = new FakeSftp(true);
|
||||||
|
const { transport } = await connectedTransport(sftp);
|
||||||
|
await transport.writeFile("/srv/app/output.txt", Buffer.from("new content"));
|
||||||
|
assert.equal(sftp.directories.has("/srv"), true);
|
||||||
|
assert.equal(sftp.directories.has("/srv/app"), true);
|
||||||
|
assert.equal(sftp.files.get("/srv/app/output.txt")?.toString("utf8"), "new content");
|
||||||
|
assert.equal(sftp.renames.length, 1);
|
||||||
|
assert.equal(sftp.renames[0]?.atomic, true);
|
||||||
|
assert.equal([...sftp.files.keys()].some((path) => path.includes(".pi-ssh-")), false);
|
||||||
|
await transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to direct overwrite when SFTP v3 rename cannot replace", async () => {
|
||||||
|
const sftp = new FakeSftp(false);
|
||||||
|
sftp.directories.add("/srv");
|
||||||
|
sftp.directories.add("/srv/app");
|
||||||
|
sftp.files.set("/srv/app/output.txt", Buffer.from("old"));
|
||||||
|
sftp.failStandardRename = true;
|
||||||
|
const { transport } = await connectedTransport(sftp);
|
||||||
|
await transport.writeFile("/srv/app/output.txt", Buffer.from("replacement"));
|
||||||
|
assert.equal(sftp.files.get("/srv/app/output.txt")?.toString("utf8"), "replacement");
|
||||||
|
assert.equal(sftp.renames.length, 1);
|
||||||
|
assert.equal(sftp.renames[0]?.atomic, false);
|
||||||
|
assert.equal(sftp.unlinks.length, 1);
|
||||||
|
assert.equal([...sftp.files.keys()].some((path) => path.includes(".pi-ssh-")), false);
|
||||||
|
await transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("aborts an in-progress SSH connection attempt", async () => {
|
||||||
|
const fake = new FakeClient();
|
||||||
|
fake.connectAction = () => {};
|
||||||
|
const transport = new Ssh2Transport(passwordHost(), fake as unknown as Client);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const connecting = transport.connect(controller.signal);
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
controller.abort();
|
||||||
|
await assert.rejects(connecting, /SSH connection aborted/);
|
||||||
|
assert.equal(fake.destroyed, true);
|
||||||
|
await transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows four independent exec channels and bounds additional commands", async () => {
|
||||||
|
const fake = new FakeClient();
|
||||||
|
const channels: FakeChannel[] = [];
|
||||||
|
fake.execAction = (_command, callback) => {
|
||||||
|
const channel = new FakeChannel();
|
||||||
|
channels.push(channel);
|
||||||
|
callback(undefined, channel as ClientChannel);
|
||||||
|
};
|
||||||
|
const transport = new Ssh2Transport(passwordHost(), fake as unknown as Client);
|
||||||
|
await transport.connect();
|
||||||
|
const commands = Array.from({ length: 5 }, (_, index) =>
|
||||||
|
transport.exec(`command-${index}`, "/srv", { onData() {}, timeout: 0 }),
|
||||||
|
);
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(channels.length, 4);
|
||||||
|
channels[0]?.emit("close", 0);
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(channels.length, 5);
|
||||||
|
for (const channel of channels.slice(1)) channel.emit("close", 0);
|
||||||
|
await Promise.all(commands);
|
||||||
|
await transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancels an exec while it is waiting for a concurrency slot", async () => {
|
||||||
|
const fake = new FakeClient();
|
||||||
|
const channels: FakeChannel[] = [];
|
||||||
|
fake.execAction = (_command, callback) => {
|
||||||
|
const channel = new FakeChannel();
|
||||||
|
channels.push(channel);
|
||||||
|
callback(undefined, channel as ClientChannel);
|
||||||
|
};
|
||||||
|
const transport = new Ssh2Transport(passwordHost(), fake as unknown as Client);
|
||||||
|
await transport.connect();
|
||||||
|
const active = Array.from({ length: 4 }, () => transport.exec("sleep", "/srv", { onData() {}, timeout: 0 }));
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
const controller = new AbortController();
|
||||||
|
const queued = transport.exec("queued", "/srv", { onData() {}, timeout: 0, signal: controller.signal });
|
||||||
|
controller.abort();
|
||||||
|
await assert.rejects(queued, /SSH command aborted/);
|
||||||
|
assert.equal(channels.length, 4);
|
||||||
|
for (const channel of channels) channel.emit("close", 0);
|
||||||
|
await Promise.all(active);
|
||||||
|
await transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("aborts and times out commands by closing the active channel", async () => {
|
||||||
|
const abortClient = new FakeClient();
|
||||||
|
const abortChannel = new FakeChannel();
|
||||||
|
abortClient.execAction = (_command, callback) => {
|
||||||
|
abortClient.channel = abortChannel;
|
||||||
|
callback(undefined, abortChannel as ClientChannel);
|
||||||
|
};
|
||||||
|
const abortTransport = new Ssh2Transport(passwordHost(), abortClient as unknown as Client);
|
||||||
|
await abortTransport.connect();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const aborted = abortTransport.exec("sleep 10", "/srv", { onData() {}, signal: controller.signal, timeout: 0 });
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
controller.abort();
|
||||||
|
await assert.rejects(aborted, /SSH command aborted/);
|
||||||
|
assert.deepEqual(abortChannel.signals, ["KILL"]);
|
||||||
|
assert.equal(abortChannel.closeCalls, 1);
|
||||||
|
await abortTransport.dispose();
|
||||||
|
|
||||||
|
const timeoutClient = new FakeClient();
|
||||||
|
const timeoutChannel = new FakeChannel();
|
||||||
|
timeoutClient.execAction = (_command, callback) => {
|
||||||
|
timeoutClient.channel = timeoutChannel;
|
||||||
|
callback(undefined, timeoutChannel as ClientChannel);
|
||||||
|
};
|
||||||
|
const timeoutTransport = new Ssh2Transport(passwordHost(), timeoutClient as unknown as Client);
|
||||||
|
await timeoutTransport.connect();
|
||||||
|
await assert.rejects(
|
||||||
|
timeoutTransport.exec("sleep 10", "/srv", { onData() {}, timeout: 0.01 }),
|
||||||
|
/timed out after 0.01s/,
|
||||||
|
);
|
||||||
|
assert.deepEqual(timeoutChannel.signals, ["KILL"]);
|
||||||
|
assert.equal(timeoutChannel.closeCalls, 1);
|
||||||
|
await timeoutTransport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails closed after the SSH connection closes", async () => {
|
||||||
|
const sftp = new FakeSftp();
|
||||||
|
sftp.files.set("/srv/a.txt", Buffer.from("a"));
|
||||||
|
const { fake, transport } = await connectedTransport(sftp);
|
||||||
|
fake.emit("close");
|
||||||
|
await assert.rejects(transport.readFile("/srv/a.txt"), /SSH2 connection closed unexpectedly/);
|
||||||
|
await transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("probes and fingerprints a host key without authenticating", async () => {
|
||||||
|
const fake = new FakeClient();
|
||||||
|
const key = fixtureKey("probe");
|
||||||
|
fake.connectAction = (config, client) => {
|
||||||
|
assert.equal(config.host, "probe.example.test");
|
||||||
|
assert.equal(config.port, 2200);
|
||||||
|
assert.equal(config.username, "probe-user");
|
||||||
|
assert.equal(config.hostVerifier?.(key), false);
|
||||||
|
client.emit("close");
|
||||||
|
};
|
||||||
|
const result = await probeHostKey("probe.example.test", 2200, "probe-user", fake as unknown as Client);
|
||||||
|
assert.deepEqual(result, fingerprintHostKey(key));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects host-key probing errors before a key is observed", async () => {
|
||||||
|
const fake = new FakeClient();
|
||||||
|
fake.connectAction = (_config, client) => client.emit("error", new Error("network unavailable"));
|
||||||
|
await assert.rejects(
|
||||||
|
probeHostKey("probe.example.test", 22, "probe-user", fake as unknown as Client),
|
||||||
|
/network unavailable/,
|
||||||
|
);
|
||||||
|
assert.equal(fake.destroyed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("times out host-key probing when the server never responds", async () => {
|
||||||
|
const fake = new FakeClient();
|
||||||
|
fake.connectAction = () => {};
|
||||||
|
await assert.rejects(
|
||||||
|
probeHostKey("probe.example.test", 22, "probe-user", fake as unknown as Client, 5),
|
||||||
|
/timed out while obtaining SSH host key/,
|
||||||
|
);
|
||||||
|
assert.equal(fake.destroyed, true);
|
||||||
|
assert.equal(fake.connectConfig?.readyTimeout, 5);
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import type { PiSshConfig, VaultPaths } from "../src/config.ts";
|
||||||
|
import { decryptConfig, encryptConfig, loadVault, rotateVaultKey, saveVault } from "../src/vault.ts";
|
||||||
|
|
||||||
|
function fixture(): PiSshConfig {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
hosts: {
|
||||||
|
packaging: {
|
||||||
|
label: "Packaging",
|
||||||
|
hostName: "192.0.2.10",
|
||||||
|
user: "builder",
|
||||||
|
port: 22,
|
||||||
|
auth: { type: "password", password: "server-secret", method: "auto" },
|
||||||
|
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:fixture" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function paths(): VaultPaths {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), "pi-ssh-vault-"));
|
||||||
|
return {
|
||||||
|
directory,
|
||||||
|
encryptedConfig: join(directory, "hosts.enc"),
|
||||||
|
key: join(directory, "vault.key"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("encrypts and authenticates the complete configuration", () => {
|
||||||
|
const key = randomBytes(32);
|
||||||
|
const encrypted = encryptConfig(fixture(), key);
|
||||||
|
assert.doesNotMatch(encrypted, /server-secret|192\.0\.2\.10|builder/);
|
||||||
|
assert.deepEqual(decryptConfig(encrypted, key), fixture());
|
||||||
|
|
||||||
|
const envelope = JSON.parse(encrypted);
|
||||||
|
envelope.ciphertext = `${envelope.ciphertext.slice(0, -2)}AA`;
|
||||||
|
assert.throws(() => decryptConfig(JSON.stringify(envelope), key), /integrity verification/);
|
||||||
|
assert.throws(() => decryptConfig(encrypted, randomBytes(32)), /integrity verification/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writes an owner-only key and encrypted config without plaintext", () => {
|
||||||
|
const target = paths();
|
||||||
|
saveVault(fixture(), target);
|
||||||
|
assert.deepEqual(loadVault(target), fixture());
|
||||||
|
assert.doesNotMatch(readFileSync(target.encryptedConfig, "utf8"), /server-secret/);
|
||||||
|
assert.equal(Buffer.from(readFileSync(target.key, "utf8").trim(), "base64").length, 32);
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
assert.equal(statSync(target.directory).mode & 0o777, 0o700);
|
||||||
|
assert.equal(statSync(target.encryptedConfig).mode & 0o777, 0o600);
|
||||||
|
assert.equal(statSync(target.key).mode & 0o777, 0o600);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails closed when vault files are missing or too broadly readable", () => {
|
||||||
|
const target = paths();
|
||||||
|
assert.throws(() => loadVault(target), /not configured/);
|
||||||
|
mkdirSync(target.directory, { recursive: true });
|
||||||
|
writeFileSync(target.key, `${randomBytes(32).toString("base64")}\n`, { mode: 0o600 });
|
||||||
|
assert.throws(() => loadVault(target), /not configured/);
|
||||||
|
saveVault(fixture(), target);
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
chmodSync(target.encryptedConfig, 0o644);
|
||||||
|
assert.throws(() => loadVault(target), /permissions/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rotates the adjacent key while preserving the encrypted configuration", () => {
|
||||||
|
const target = paths();
|
||||||
|
saveVault(fixture(), target);
|
||||||
|
const oldKey = readFileSync(target.key, "utf8");
|
||||||
|
rotateVaultKey(target);
|
||||||
|
assert.notEqual(readFileSync(target.key, "utf8"), oldKey);
|
||||||
|
assert.deepEqual(loadVault(target), fixture());
|
||||||
|
});
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
- Added checked-in authoritative groups for every tool exposed by the standard my-pi bundle, eliminating first-use model generation and user caching unless unrecognized third-party tools are present.
|
- Added checked-in authoritative groups for every tool exposed by the standard my-pi bundle, eliminating first-use model generation and user caching unless unrecognized third-party tools are present.
|
||||||
- Replaced the Hermes and third-party Hippo assignments with the five tools exposed by the official Hippo Pi extension, grouped into recall and management workflows.
|
- Replaced the Hermes and third-party Hippo assignments with the five tools exposed by the official Hippo Pi extension, grouped into recall and management workflows.
|
||||||
- Added the enabled bundle's `Agent`, `get_subagent_result`, and `steer_subagent` tools to a checked-in `subagents` orchestration group.
|
- Added the enabled bundle's `Agent`, `get_subagent_result`, and `steer_subagent` tools to a checked-in `subagents` orchestration group.
|
||||||
|
- Added authoritative `ssh-connection`, `ssh-remote-files`, `ssh-remote-search`, and `ssh-remote-shell` groups for the maintained `pi-ssh` tools, keeping reviewed connection, bounded discovery, structured file operations, and reviewed shell execution separate; the connection workflow now requires sequential `ssh_connect` to succeed before dependent tools.
|
||||||
|
- Added sequential `ssh_cd` to the authoritative remote-shell workflow and direct the model to wait for a successful workspace change before dependent remote calls, avoiding both the unknown-tools fallback and cwd races.
|
||||||
|
- Added the authoritative `user-interaction` group for the maintained `ask_user_question` TUI tool.
|
||||||
|
|
||||||
## [0.3.6] - 2026-04-24
|
## [0.3.6] - 2026-04-24
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ Run `/tool-search-rebuild` to remove model enrichment. Standard bundle tools imm
|
|||||||
| Group | Bundle tools |
|
| Group | Bundle tools |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `filesystem-navigation` | `ls`, `multi_grep` |
|
| `filesystem-navigation` | `ls`, `multi_grep` |
|
||||||
|
| `ssh-connection` | `ssh_connect` |
|
||||||
|
| `ssh-remote-files` | `ssh_read`, `ssh_write`, `ssh_edit` |
|
||||||
|
| `ssh-remote-search` | `ssh_find`, `ssh_grep` |
|
||||||
|
| `ssh-remote-shell` | `ssh_cd`, `ssh_bash` |
|
||||||
| `code-intelligence` | CodeGraph and all LSP tools |
|
| `code-intelligence` | CodeGraph and all LSP tools |
|
||||||
| `web-tavily` | Tavily search/fetch |
|
| `web-tavily` | Tavily search/fetch |
|
||||||
| `web-exa` | Exa search/advanced/fetch |
|
| `web-exa` | Exa search/advanced/fetch |
|
||||||
@@ -38,6 +42,7 @@ Run `/tool-search-rebuild` to remove model enrichment. Standard bundle tools imm
|
|||||||
| `memory-recall` | Official Hippo recall/context/status and condensed-output recovery |
|
| `memory-recall` | Official Hippo recall/context/status and condensed-output recovery |
|
||||||
| `memory-management` | Official Hippo remember/outcome tools |
|
| `memory-management` | Official Hippo remember/outcome tools |
|
||||||
| `subagents` | Launch agents, collect background results, and steer running agents |
|
| `subagents` | Launch agents, collect background results, and steer running agents |
|
||||||
|
| `user-interaction` | `ask_user_question` model-initiated TUI questions |
|
||||||
| `mcp-management` | Shared MCP adapter management |
|
| `mcp-management` | Shared MCP adapter management |
|
||||||
|
|
||||||
Pinned tools are removed from these dynamic groups at runtime. For example, the default-pinned `codegraph_explore` and `lsp_diagnostics` do not consume the `code-intelligence` group budget.
|
Pinned tools are removed from these dynamic groups at runtime. For example, the default-pinned `codegraph_explore` and `lsp_diagnostics` do not consume the `code-intelligence` group budget.
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
9. `turn_start` notices tool or grouping-configuration changes by hash, resets stale dynamic groups, and reconstructs the checked-in/hybrid catalog.
|
9. `turn_start` notices tool or grouping-configuration changes by hash, resets stale dynamic groups, and reconstructs the checked-in/hybrid catalog.
|
||||||
|
|
||||||
The standard bundle assigns the official Hippo Pi extension's five `hippo_*` tools to recall and management groups, and assigns `Agent`, `get_subagent_result`, and `steer_subagent` to the `subagents` orchestration group. `context_tree_query` remains in `memory-recall` because it recovers condensed tool output; retired Hermes names and third-party `tff-memory_*` names are not seeded.
|
The standard bundle assigns the official Hippo Pi extension's five `hippo_*` tools to recall and management groups, and assigns `Agent`, `get_subagent_result`, and `steer_subagent` to the `subagents` orchestration group. `context_tree_query` remains in `memory-recall` because it recovers condensed tool output; retired Hermes names and third-party `tff-memory_*` names are not seeded.
|
||||||
|
The maintained `pi-ssh` tools are also split by workflow: sequential reviewed agent-controlled connection lives in `ssh-connection`, structured `ssh_read`/`ssh_write`/`ssh_edit` file operations live in `ssh-remote-files`, bounded `ssh_find`/`ssh_grep` discovery lives in `ssh-remote-search`, and sequential reviewed workspace changes plus reviewed command execution live in `ssh-remote-shell`. The workflows tell the model to call `ssh_connect` or `ssh_cd` separately and wait for success before issuing remote calls that depend on the new connection or cwd.
|
||||||
|
The maintained `ask_user_question` tool has its own `user-interaction` group so the model can load the complete interactive schema only when a missing decision or preference must come from the user.
|
||||||
|
|
||||||
A purely additive first load receives Pi's native dynamic-tool result propagation. A replacement that evicts one group while adding another is intentionally non-additive; current Pi detects the removal and uses its safe next-request fallback instead of attaching an invalid additive-only hint.
|
A purely additive first load receives Pi's native dynamic-tool result propagation. A replacement that evicts one group while adding another is intentionally non-additive; current Pi detects the removal and uses its safe next-request fallback instead of attaching an invalid additive-only hint.
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,42 @@ export const BUNDLE_GROUP_DEFINITIONS: BundleGroupDefinition[] = [
|
|||||||
keywords: ["files", "directory", "list", "multi grep", "目录", "文件", "多模式搜索"],
|
keywords: ["files", "directory", "list", "multi grep", "目录", "文件", "多模式搜索"],
|
||||||
tools: ["ls", "multi_grep"],
|
tools: ["ls", "multi_grep"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "ssh-connection",
|
||||||
|
title: "SSH connection",
|
||||||
|
summary: "Connect sequentially to an explicitly imported SSH host through the reviewed agent tool flow.",
|
||||||
|
useWhen: ["The user names a remote server as part of a concrete task and no matching SSH2 connection is active; call ssh_connect separately and wait for success before dependent remote tools"],
|
||||||
|
avoidWhen: ["The task is local, the server was not explicitly named, or the host has not been imported"],
|
||||||
|
keywords: ["ssh", "connect", "server", "host", "remote", "连接服务器", "远程主机", "SSH"],
|
||||||
|
tools: ["ssh_connect"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ssh-remote-files",
|
||||||
|
title: "SSH remote files",
|
||||||
|
summary: "Read, write, and edit files in the currently connected remote SSH workspace.",
|
||||||
|
useWhen: ["You need explicit file operations on an active SSH target rather than the local workspace"],
|
||||||
|
avoidWhen: ["A remote shell command is required, or the files are local"],
|
||||||
|
keywords: ["ssh", "remote", "read", "write", "edit", "远程", "文件", "SSH"],
|
||||||
|
tools: ["ssh_read", "ssh_write", "ssh_edit"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ssh-remote-search",
|
||||||
|
title: "SSH remote search",
|
||||||
|
summary: "Run bounded adaptive filename and content searches on the active SSH target.",
|
||||||
|
useWhen: ["You need to locate remote files or literals before reading or operating on them"],
|
||||||
|
avoidWhen: ["The search belongs to the local workspace, or a known remote path can be read directly"],
|
||||||
|
keywords: ["ssh", "remote", "find", "grep", "search", "远程搜索", "文件查找", "SSH"],
|
||||||
|
tools: ["ssh_find", "ssh_grep"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ssh-remote-shell",
|
||||||
|
title: "SSH remote workspace shell",
|
||||||
|
summary: "Change the active remote cwd as a separate reviewed step, or run a reviewed shell command in that workspace.",
|
||||||
|
useWhen: ["You need to change the active remote workspace or run builds, tests, Git, or other shell operations there; call ssh_cd separately and wait for success before dependent remote calls"],
|
||||||
|
avoidWhen: ["Structured remote file tools are sufficient, or the operation should run locally"],
|
||||||
|
keywords: ["ssh", "remote", "bash", "cd", "cwd", "workspace", "build", "test", "git", "远程命令", "工作目录", "SSH"],
|
||||||
|
tools: ["ssh_cd", "ssh_bash"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "code-intelligence",
|
id: "code-intelligence",
|
||||||
title: "Code intelligence",
|
title: "Code intelligence",
|
||||||
@@ -130,6 +166,15 @@ export const BUNDLE_GROUP_DEFINITIONS: BundleGroupDefinition[] = [
|
|||||||
keywords: ["subagent", "agent", "delegate", "background", "steer", "子代理", "委派", "后台"],
|
keywords: ["subagent", "agent", "delegate", "background", "steer", "子代理", "委派", "后台"],
|
||||||
tools: ["Agent", "get_subagent_result", "steer_subagent"],
|
tools: ["Agent", "get_subagent_result", "steer_subagent"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "user-interaction",
|
||||||
|
title: "Interactive user questions",
|
||||||
|
summary: "Pause the agent and collect one or more structured decisions from the user in the TUI.",
|
||||||
|
useWhen: ["A missing requirement, preference, confirmation, or choice must come directly from the user before continuing"],
|
||||||
|
avoidWhen: ["The user's direct messages already provide the answer, or the task can proceed without another decision"],
|
||||||
|
keywords: ["ask", "question", "clarify", "choice", "preference", "提问", "澄清", "选择", "确认"],
|
||||||
|
tools: ["ask_user_question"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "mcp-management",
|
id: "mcp-management",
|
||||||
title: "MCP management",
|
title: "MCP management",
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ test("bundle seed names are unique and cover the declared my-pi tools", () => {
|
|||||||
assert.equal(result.catalog.tools.find((card) => card.name === "Agent")?.primaryGroup, "subagents");
|
assert.equal(result.catalog.tools.find((card) => card.name === "Agent")?.primaryGroup, "subagents");
|
||||||
assert.equal(result.catalog.tools.find((card) => card.name === "get_subagent_result")?.primaryGroup, "subagents");
|
assert.equal(result.catalog.tools.find((card) => card.name === "get_subagent_result")?.primaryGroup, "subagents");
|
||||||
assert.equal(result.catalog.tools.find((card) => card.name === "steer_subagent")?.primaryGroup, "subagents");
|
assert.equal(result.catalog.tools.find((card) => card.name === "steer_subagent")?.primaryGroup, "subagents");
|
||||||
|
assert.equal(result.catalog.tools.find((card) => card.name === "ssh_find")?.primaryGroup, "ssh-remote-search");
|
||||||
|
assert.equal(result.catalog.tools.find((card) => card.name === "ssh_grep")?.primaryGroup, "ssh-remote-search");
|
||||||
|
assert.equal(result.catalog.tools.find((card) => card.name === "ask_user_question")?.primaryGroup, "user-interaction");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("user overrides take priority and unknown tools retain deterministic fallback groups", () => {
|
test("user overrides take priority and unknown tools retain deterministic fallback groups", () => {
|
||||||
|
|||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
exec node "$SCRIPT_DIR/pi-ssh/scripts/ssh-config-launcher.mjs" "$@"
|
||||||
@@ -49,6 +49,10 @@ test("all package extensions load together without global registration conflicts
|
|||||||
files: string[];
|
files: string[];
|
||||||
pi: { extensions: string[] };
|
pi: { extensions: string[] };
|
||||||
};
|
};
|
||||||
|
assert.ok(
|
||||||
|
packageJson.pi.extensions.includes("./extensions/plugin-init.ts"),
|
||||||
|
"the bundle must expose the project plugin initializer command",
|
||||||
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
packageJson.pi.extensions.includes("./hippo-memory-pi/index.ts"),
|
packageJson.pi.extensions.includes("./hippo-memory-pi/index.ts"),
|
||||||
"the bundle must load the vendored official Hippo Memory extension",
|
"the bundle must load the vendored official Hippo Memory extension",
|
||||||
@@ -61,6 +65,36 @@ test("all package extensions load together without global registration conflicts
|
|||||||
packageJson.pi.extensions.includes("./pi-extension-codex-fast-mode/index.ts"),
|
packageJson.pi.extensions.includes("./pi-extension-codex-fast-mode/index.ts"),
|
||||||
"the bundle must load the locally maintained Codex Fast mode extension",
|
"the bundle must load the locally maintained Codex Fast mode extension",
|
||||||
);
|
);
|
||||||
|
assert.ok(packageJson.pi.extensions.includes("./pi-ssh/index.ts"), "the bundle must load the locally maintained SSH extension");
|
||||||
|
assert.equal(packageJson.dependencies["pi-ssh"], "file:./pi-ssh");
|
||||||
|
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.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");
|
||||||
|
const permissionConfig = JSON.parse(
|
||||||
|
await readFile(join(repositoryRoot, "config", "pi-permission-system.json"), "utf8"),
|
||||||
|
) as { authorizerChain: string[]; permission: Record<string, unknown> };
|
||||||
|
assert.equal(permissionConfig.permission.ssh_connect, "ask", "SSH connection must enter the permission gate");
|
||||||
|
assert.equal(permissionConfig.permission.ssh_cd, "ask", "remote workspace changes must enter the permission gate");
|
||||||
|
assert.deepEqual(permissionConfig.authorizerChain, ["auto-review"], "SSH connection asks must reach AutoReview");
|
||||||
|
assert.ok(
|
||||||
|
packageJson.pi.extensions.indexOf("./extensions/permission-system.ts") <
|
||||||
|
packageJson.pi.extensions.indexOf("./pi-ssh/index.ts"),
|
||||||
|
"the permission service must load before pi-ssh registers its permission bridge",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
packageJson.pi.extensions.indexOf("./pi-ssh/index.ts") <
|
||||||
|
packageJson.pi.extensions.indexOf("./pi-tool-search/extensions/index.ts"),
|
||||||
|
"pi-ssh tools must register before Tool Search builds its catalog",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
packageJson.pi.extensions.indexOf("./pi-ask-user/index.ts") <
|
||||||
|
packageJson.pi.extensions.indexOf("./pi-tool-search/extensions/index.ts"),
|
||||||
|
"ask_user_question must register before Tool Search builds its catalog",
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
packageJson.dependencies["@firstpick/pi-extension-codex-fast-mode"],
|
packageJson.dependencies["@firstpick/pi-extension-codex-fast-mode"],
|
||||||
"file:./pi-extension-codex-fast-mode",
|
"file:./pi-extension-codex-fast-mode",
|
||||||
@@ -69,6 +103,15 @@ test("all package extensions load together without global registration conflicts
|
|||||||
packageJson.files.includes("pi-extension-codex-fast-mode"),
|
packageJson.files.includes("pi-extension-codex-fast-mode"),
|
||||||
"the packed bundle must include the locally maintained Codex Fast mode source",
|
"the packed bundle must include the locally maintained Codex Fast mode source",
|
||||||
);
|
);
|
||||||
|
assert.ok(
|
||||||
|
packageJson.pi.extensions.includes("./pi-notify/src/index.ts"),
|
||||||
|
"the bundle must load the locally maintained Kitty notification extension",
|
||||||
|
);
|
||||||
|
assert.equal(packageJson.dependencies["@smoose/pi-notify"], "file:./pi-notify");
|
||||||
|
assert.ok(
|
||||||
|
packageJson.files.includes("pi-notify"),
|
||||||
|
"the packed bundle must include the locally maintained notification source",
|
||||||
|
);
|
||||||
assert.equal(packageJson.dependencies["@ogulcancelik/pi-minimal-footer"], "file:./pi-minimal-footer");
|
assert.equal(packageJson.dependencies["@ogulcancelik/pi-minimal-footer"], "file:./pi-minimal-footer");
|
||||||
assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], "file:./pi-subagents");
|
assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], "file:./pi-subagents");
|
||||||
assert.ok(packageJson.pi.extensions.includes("./extensions/subagents.ts"));
|
assert.ok(packageJson.pi.extensions.includes("./extensions/subagents.ts"));
|
||||||
@@ -108,4 +151,8 @@ test("all package extensions load together without global registration conflicts
|
|||||||
|
|
||||||
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||||
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Failed to load extension|conflicts with/u);
|
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Failed to load extension|conflicts with/u);
|
||||||
|
const deployedPermissionConfig = JSON.parse(
|
||||||
|
await readFile(join(home, ".pi-agent", "extensions", "pi-permission-system", "config.json"), "utf8"),
|
||||||
|
);
|
||||||
|
assert.deepEqual(deployedPermissionConfig, permissionConfig, "the deployed permission config must match the bundle source");
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user