mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
Merge origin/main into worktree-1
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 控制的持久连接,并以 SFTP 与有界自适应搜索提供独立的 `ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 与 `ssh_bash` 工具;运行时不调用 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`)。
|
||||||
@@ -49,15 +53,15 @@
|
|||||||
- 初始导入快照:`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`、`@smoose/pi-notify`、`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`、`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` 读取、短文件和行锚点的完整性。
|
||||||
@@ -79,6 +83,7 @@
|
|||||||
- Kitty 通知只在 TUI 模式的 `agent_settled` 后发送;自动重试、自动 compaction 和 follow-up 期间不得提前通知或重置总耗时。默认 `o=unfocused`、`a=focus`,每个 Pi Session 使用独立稳定 ID,标题和正文必须 Base64 编码。
|
- 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 序列。
|
- `/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_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 内容不能自行授权。
|
||||||
@@ -170,6 +175,24 @@
|
|||||||
- 默认只在 TUI `agent_settled` 后通知,非 TUI 输出完整性、自动 retry/compaction/follow-up 去重、总耗时、短任务阈值和多窗口点击聚焦都属于回归边界。
|
- 默认只在 TUI `agent_settled` 后通知,非 TUI 输出完整性、自动 retry/compaction/follow-up 去重、总耗时、短任务阈值和多窗口点击聚焦都属于回归边界。
|
||||||
- 根包必须通过 `file:./pi-notify` 提供依赖并直接加载源码,不得同时加载 npm `@smoose/pi-notify` 实现。
|
- 根包必须通过 `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;不得注册 `/ssh`、`--ssh`、session resume 自动重连或把用户 `!` 命令切换到远端。用户未在直接请求中明确服务器和具体远端任务时不得推断、替换或连接主机;新连接替换旧连接,会话结束自动断开。
|
||||||
|
- `ssh_find` / `ssh_grep` 必须在已授权工具调用内部按 `fd/fdfind → git ls-files → find` 与 `rg → git grep → find+grep` 顺序检测服务器现有能力,不安装或上传远端二进制;输入必须 shell-safe,结果数量/单行/总捕获必须有界并显式报告 backend 与 truncated。RTK 不得处理这两个搜索工具的结果。
|
||||||
|
- `ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 的远端路径不能按本机路径执行 `path` / `external_directory` gate;必须通过公共 `PermissionsService` 注册显式 extractor 关闭默认 `input.path` 推断,并保持五个工具的默认策略为 `ask`。
|
||||||
|
- `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-tool-search` 开发约定
|
## `pi-tool-search` 开发约定
|
||||||
|
|
||||||
@@ -184,7 +207,7 @@
|
|||||||
|
|
||||||
组合包依赖或加载入口变化时,至少验证根 `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/`。
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
- 本仓库维护的 `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-ssh`:通过纯 Node `ssh2` 持久连接、SFTP 与自适应有界搜索提供 `ssh_connect`、`ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep`、`ssh_bash`;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接。
|
||||||
- `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,并按当前激活工具追加简短的工具与搜索路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。
|
- `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,并按当前激活工具追加简短的工具与搜索路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。
|
||||||
|
|
||||||
## 安装、升级与卸载
|
## 安装、升级与卸载
|
||||||
@@ -137,7 +138,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 +189,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_find` / `ssh_grep`:在每次获批调用内按服务器现有能力选择 `fd/fdfind → git ls-files → find` 或 `rg → git grep → find+grep`,不安装远端软件,并以最大 200 条结果、单行截断和 `truncated` 标记约束返回。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;也可一次无交互配置:
|
||||||
|
|||||||
@@ -7,11 +7,23 @@
|
|||||||
"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_read": "ask",
|
||||||
|
"ssh_write": "ask",
|
||||||
|
"ssh_edit": "ask",
|
||||||
|
"ssh_find": "ask",
|
||||||
|
"ssh_grep": "ask",
|
||||||
"external_directory": {
|
"external_directory": {
|
||||||
"*": "ask"
|
"*": "ask"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,6 +44,18 @@ 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. Connect before calling other ssh_* tools; never infer or substitute a different host.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
+99
@@ -17,12 +17,15 @@
|
|||||||
"@smoose/pi-notify": "file:./pi-notify",
|
"@smoose/pi-notify": "file:./pi-notify",
|
||||||
"@tavily/pi-extension": "0.1.2",
|
"@tavily/pi-extension": "0.1.2",
|
||||||
"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-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"
|
||||||
@@ -3796,6 +3799,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",
|
||||||
@@ -3869,6 +3881,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"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",
|
||||||
@@ -3964,6 +3985,15 @@
|
|||||||
"ieee754": "^1.1.13"
|
"ieee754": "^1.1.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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",
|
||||||
@@ -4141,6 +4171,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/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -5003,6 +5047,15 @@
|
|||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"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",
|
||||||
@@ -5429,6 +5482,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",
|
||||||
@@ -5714,6 +5774,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
|
||||||
@@ -6397,6 +6461,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",
|
||||||
@@ -6628,6 +6709,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",
|
||||||
@@ -7097,6 +7184,18 @@
|
|||||||
"node": ">=22.19.0"
|
"node": ">=22.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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-tool-search": {
|
"pi-tool-search": {
|
||||||
"version": "0.3.6",
|
"version": "0.3.6",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"pi-rtk-optimizer",
|
"pi-rtk-optimizer",
|
||||||
"pi-minimal-footer",
|
"pi-minimal-footer",
|
||||||
"pi-notify",
|
"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 +37,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"
|
||||||
],
|
],
|
||||||
@@ -59,6 +61,7 @@
|
|||||||
"./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",
|
||||||
|
"./pi-ssh/index.ts",
|
||||||
"./extensions/tool-routing.ts",
|
"./extensions/tool-routing.ts",
|
||||||
"./pi-tool-search/extensions/index.ts"
|
"./pi-tool-search/extensions/index.ts"
|
||||||
],
|
],
|
||||||
@@ -77,12 +80,15 @@
|
|||||||
"@smoose/pi-notify": "file:./pi-notify",
|
"@smoose/pi-notify": "file:./pi-notify",
|
||||||
"@tavily/pi-extension": "0.1.2",
|
"@tavily/pi-extension": "0.1.2",
|
||||||
"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-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"
|
||||||
|
|||||||
@@ -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,23 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 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,164 @@
|
|||||||
|
# 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_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.
|
||||||
|
|
||||||
|
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 `~/`. After the reviewed connection succeeds, the model uses the other `ssh_*` tools to complete the requested work. Only imported host IDs are accepted; arbitrary `user@host` targets are rejected. 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 and permission-reviewed.
|
||||||
|
- One persistent `ssh2` client is used for the active host.
|
||||||
|
- Each `ssh_bash` call opens an exec channel and runs under `bash -lc` in the selected remote cwd.
|
||||||
|
- 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.
|
||||||
|
- RTK treats only `ssh_bash` as a Bash output-compaction alias; it does not rewrite remote commands or process remote search/read results.
|
||||||
|
|
||||||
|
### 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:
|
||||||
|
|
||||||
|
```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. A genuine no-match result succeeds with zero rows; invalid regexes, missing roots, and backend failures remain errors even though output passes through bounded `head`/`cut` stages. 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. Regex mode (`literal: false`) follows the selected backend's regex dialect.
|
||||||
|
|
||||||
|
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_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/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,121 @@
|
|||||||
|
# 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_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 model tool governed by `pi-permission-system`; 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. Connection loss fails closed and no operation is automatically replayed.
|
||||||
|
|
||||||
|
### Shell
|
||||||
|
|
||||||
|
`ssh_bash` opens exec channels. Commands run through `bash -lc` after changing to the selected remote cwd. Output streams through the normal Pi Bash operations callback. Abort or timeout closes the channel without reconnecting or replaying.
|
||||||
|
|
||||||
|
### 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 -exec grep`; literal case-insensitive semantics by default.
|
||||||
|
- each backend emits at most `limit + 1` lines so truncation is explicit; public `limit` is 1–200; each returned line is capped.
|
||||||
|
- relative paths resolve against remote cwd and `~/` against remote home.
|
||||||
|
- user strings are single-quoted as shell arguments and NUL/newline input is rejected.
|
||||||
|
- 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_read`, `ssh_write`, `ssh_edit`, `ssh_find`, and `ssh_grep` default to `ask`.
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
## 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 and `ssh_*` tools are remote after a connection becomes active. It does not include remote file content.
|
||||||
+349
@@ -0,0 +1,349 @@
|
|||||||
|
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 BashOperations,
|
||||||
|
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 {
|
||||||
|
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 { PiSshConfig, SshHostConfig } from "./src/config.ts";
|
||||||
|
import {
|
||||||
|
runRemoteFind,
|
||||||
|
runRemoteGrep,
|
||||||
|
type RemoteFindInput,
|
||||||
|
type RemoteGrepInput,
|
||||||
|
} from "./src/remote-search.ts";
|
||||||
|
|
||||||
|
|
||||||
|
interface SshConnection {
|
||||||
|
hostId: string;
|
||||||
|
remote: string;
|
||||||
|
port: number;
|
||||||
|
remoteCwd: string;
|
||||||
|
remoteHome: string;
|
||||||
|
localCwd: string;
|
||||||
|
localHome: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function mapLocalPathToRemote(path: string, connection: SshConnection): 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 createRemoteBashOps(transport: RemoteTransport): BashOperations {
|
||||||
|
return {
|
||||||
|
exec: (command, cwd, { onData, signal, timeout }) => transport.exec(command, cwd, { onData, signal, timeout }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfiguredHost(config: PiSshConfig, hostId: string): SshHostConfig {
|
||||||
|
const host = config.hosts[hostId];
|
||||||
|
if (!host) throw new Error(`unknown pi-ssh host '${hostId}'; run ssh_config.sh import ${hostId}`);
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 captureChecked(transport: RemoteTransport, command: string, cwd = "."): Promise<string> {
|
||||||
|
const result = await transport.capture(command, cwd, 20);
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
const message = result.output.toString("utf8").trim();
|
||||||
|
throw new Error(message || `remote command failed with exit code ${result.exitCode}`);
|
||||||
|
}
|
||||||
|
return result.output.toString("utf8").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectSelection(
|
||||||
|
selection: HostSelection,
|
||||||
|
localCwd: string,
|
||||||
|
localHome: string,
|
||||||
|
): Promise<{ connection: SshConnection; transport: Ssh2Transport }> {
|
||||||
|
const config = loadVault();
|
||||||
|
const host = getConfiguredHost(config, selection.hostId);
|
||||||
|
const transport = new Ssh2Transport(host);
|
||||||
|
try {
|
||||||
|
await transport.connect();
|
||||||
|
const remoteHome = await captureChecked(transport, 'printf "%s" "$HOME"');
|
||||||
|
const remotePwd = await captureChecked(transport, "pwd");
|
||||||
|
if (!remoteHome || !remotePwd) throw new Error("remote HOME/cwd probe returned empty output");
|
||||||
|
const requestedPath = resolveRequestedPath(selection, host, remoteHome, remotePwd);
|
||||||
|
const remoteCwd = await captureChecked(transport, "pwd", requestedPath);
|
||||||
|
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);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const selection = parseConnectInput(params as Record<string, unknown>);
|
||||||
|
const connected = await connectSelection(selection, localCwd, localHome);
|
||||||
|
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({
|
||||||
|
...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. 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 or regular expression to search for" },
|
||||||
|
path: { type: "string", description: "Remote root path; defaults to the active remote cwd" },
|
||||||
|
literal: { type: "boolean", description: "Treat pattern as fixed text (default: true)" },
|
||||||
|
caseSensitive: { type: "boolean", description: "Use case-sensitive matching (default: false)" },
|
||||||
|
include: { type: "string", description: "Optional file glob such as *.ts" },
|
||||||
|
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 shell command through the active SSH2 connection. ${localBash.description}`,
|
||||||
|
async execute(id, params, signal, onUpdate) {
|
||||||
|
const active = requireSsh("ssh_bash");
|
||||||
|
return createBashTool(localCwd, { operations: createRemoteBashOps(active.transport) })
|
||||||
|
.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_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.`;
|
||||||
|
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,167 @@
|
|||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import type { PermissionsService } from "@gotgenes/pi-permission-system";
|
||||||
|
|
||||||
|
export interface SshPermissionConnection {
|
||||||
|
remote: string;
|
||||||
|
port?: number;
|
||||||
|
remoteCwd: 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_FILE_TOOLS = ["ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"] as const;
|
||||||
|
const REMOTE_TOOLS = ["ssh_connect", ...REMOTE_FILE_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)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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_read") {
|
||||||
|
const details = path ? [`remote path '${inline(path)}'`] : ["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 remote path '${inline(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 remote path '${inline(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 = [
|
||||||
|
`under '${inline(path ?? ".")}'`,
|
||||||
|
`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_FILE_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
+203
@@ -0,0 +1,203 @@
|
|||||||
|
#!/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 { 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();
|
||||||
|
const result = await transport.capture('printf "%s\\n%s" "$HOME" "$(pwd)"', ".", 20);
|
||||||
|
if (result.exitCode !== 0) throw new Error("remote HOME/cwd probe failed");
|
||||||
|
const [remoteHome, remoteCwd] = result.output.toString("utf8").trim().split(/\r?\n/, 2);
|
||||||
|
if (!remoteHome || !remoteCwd) throw new Error("remote HOME/cwd probe returned incomplete output");
|
||||||
|
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,33 @@
|
|||||||
|
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. Use this when the user names a remote server as part of a concrete task; 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,
|
||||||
|
},
|
||||||
|
} 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 };
|
||||||
|
}
|
||||||
@@ -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,243 @@
|
|||||||
|
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;
|
||||||
|
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;
|
||||||
|
|
||||||
|
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("/")) 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");
|
||||||
|
|
||||||
|
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)} 2>&1 | 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)} 2>&1 | 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 2>&1 | ${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/*' 2>&1 | ${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);
|
||||||
|
const fixed = input.literal === false ? "" : "-F";
|
||||||
|
const insensitive = input.caseSensitive ? "" : "-i";
|
||||||
|
const rgGlob = include ? `-g ${shellQuote(include)}` : "";
|
||||||
|
const gitPath = include ? `-- ${shellQuote(include)}` : "";
|
||||||
|
const findName = include ? `-name ${shellQuote(include)}` : "";
|
||||||
|
const take = limit + 1;
|
||||||
|
return [
|
||||||
|
STATUS_HELPER,
|
||||||
|
`if command -v rg >/dev/null 2>&1; then`,
|
||||||
|
` printf '${MARKER}ripgrep\\n'`,
|
||||||
|
` rg --line-number --no-heading --color never --with-filename --max-columns 500 --max-columns-preview ${fixed} ${insensitive} ${rgGlob} --glob '!.git/**' --glob '!node_modules/**' -- ${shellQuote(pattern)} ${shellQuote(root)} 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
|
||||||
|
' statuses=("${PIPESTATUS[@]}")',
|
||||||
|
' pi_ssh_accept_status "${statuses[0]}" 0 1 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-grep\\n'`,
|
||||||
|
` git -C ${shellQuote(root)} grep --untracked --exclude-standard -n -I ${fixed} ${insensitive} -e ${shellQuote(pattern)} ${gitPath} 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
|
||||||
|
' statuses=("${PIPESTATUS[@]}")',
|
||||||
|
' pi_ssh_accept_status "${statuses[0]}" 0 1 141 || exit $?',
|
||||||
|
`else`,
|
||||||
|
` printf '${MARKER}grep\\n'`,
|
||||||
|
` find ${shellQuote(root)} -type f ! -path '*/.git/*' ! -path '*/node_modules/*' ${findName} -exec grep -nH -I ${fixed} ${insensitive} -- ${shellQuote(pattern)} {} + 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
|
||||||
|
' statuses=("${PIPESTATUS[@]}")',
|
||||||
|
' pi_ssh_accept_status "${statuses[0]}" 0 141 || 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(line: string, root: string, grep: boolean): string {
|
||||||
|
if (line.startsWith("/") || line.startsWith("../")) return line;
|
||||||
|
if (!grep) return posixPath.join(root, line);
|
||||||
|
const separator = line.indexOf(":");
|
||||||
|
if (separator < 1) return line;
|
||||||
|
return `${posixPath.join(root, line.slice(0, separator))}${line.slice(separator)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRemoteSearchOutput(
|
||||||
|
raw: string,
|
||||||
|
root: string,
|
||||||
|
limit: number,
|
||||||
|
kind: "find" | "grep",
|
||||||
|
): RemoteSearchResult {
|
||||||
|
const lines = raw.replace(/\r\n?/gu, "\n").split("\n");
|
||||||
|
const markerIndex = lines.findIndex((line) => line.startsWith(MARKER));
|
||||||
|
if (markerIndex < 0) throw new Error(`remote ${kind} did not report a search backend`);
|
||||||
|
const backend = lines[markerIndex].slice(MARKER.length).trim() || "unknown";
|
||||||
|
const sourceRows = lines.slice(markerIndex + 1).filter((line) => line.length > 0);
|
||||||
|
const truncated = sourceRows.length > limit;
|
||||||
|
const rows = sourceRows.slice(0, limit).map((line) => {
|
||||||
|
const normalized = backend.startsWith("git-") ? prefixGitPath(line, root, kind === "grep") : line;
|
||||||
|
return normalized.length > MAX_LINE_CHARS ? `${normalized.slice(0, MAX_LINE_CHARS - 1)}…` : normalized;
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
root: string,
|
||||||
|
limit: number,
|
||||||
|
kind: "find" | "grep",
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<RemoteSearchResult> {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let captured = 0;
|
||||||
|
const result = await transport.exec(command, root, {
|
||||||
|
signal,
|
||||||
|
timeout: 30,
|
||||||
|
onData(data) {
|
||||||
|
if (captured >= MAX_CAPTURE_CHARS) return;
|
||||||
|
const remaining = MAX_CAPTURE_CHARS - captured;
|
||||||
|
const chunk = data.length > remaining ? data.subarray(0, remaining) : data;
|
||||||
|
chunks.push(chunk);
|
||||||
|
captured += chunk.length;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const output = Buffer.concat(chunks).toString("utf8");
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
const detail = output
|
||||||
|
.replace(/\r\n?/gu, "\n")
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => !line.startsWith(MARKER))
|
||||||
|
.join(" ")
|
||||||
|
.replace(/\s+/gu, " ")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 300);
|
||||||
|
throw new Error(`remote ${kind} failed${result.exitCode === null ? "" : ` with exit code ${result.exitCode}`}${detail ? `: ${detail}` : ""}`);
|
||||||
|
}
|
||||||
|
return formatRemoteSearchOutput(output, root, limit, kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, 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, 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,363 @@
|
|||||||
|
/// <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;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteTransport {
|
||||||
|
connect(): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 queue = new CommandQueue();
|
||||||
|
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(): Promise<void> {
|
||||||
|
if (this.connected) return;
|
||||||
|
if (this.disposed) throw new Error("SSH2 transport is disposed");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const succeed = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
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;
|
||||||
|
reject(normalized);
|
||||||
|
};
|
||||||
|
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 : ""));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
|
||||||
|
return this.queue.enqueue(() => this.execUnqueued(command, cwd, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => options.onData(Buffer.isBuffer(data) ? data : Buffer.from(data)));
|
||||||
|
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.queue.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.queue.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.queue.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.queue.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,28 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { 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.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("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,63 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
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}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,117 @@
|
|||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
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 '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 '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 'src', for 'TODO', limit 25, file glob '*.ts'",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("registers previews and disables local path extraction for remote file 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_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep", "ssh_bash"]);
|
||||||
|
assert.deepEqual([...extractors.keys()], ["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.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, 7);
|
||||||
|
assert.equal(extractors.size, 5);
|
||||||
|
|
||||||
|
pi.emit("session_shutdown");
|
||||||
|
assert.equal(formatters.size, 0);
|
||||||
|
assert.equal(extractors.size, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { execFileSync, spawnSync } from "node:child_process";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, 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";
|
||||||
|
|
||||||
|
class SearchTransport implements RemoteTransport {
|
||||||
|
command = "";
|
||||||
|
cwd = "";
|
||||||
|
private readonly output: string;
|
||||||
|
private readonly exitCode: number | null;
|
||||||
|
constructor(output: string, exitCode: number | null = 0) {
|
||||||
|
this.output = output;
|
||||||
|
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(Buffer.from(this.output));
|
||||||
|
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"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("builds bounded capability-adaptive commands with shell-quoted user input", () => {
|
||||||
|
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: "TODO", include: "*.ts", limit: 20 }, "/srv/app");
|
||||||
|
assert.match(grep.command, /command -v rg/);
|
||||||
|
assert.match(grep.command, /git-grep/);
|
||||||
|
assert.match(grep.command, /find .* -exec grep/);
|
||||||
|
assert.match(grep.command, /head -n 21/);
|
||||||
|
assert.throws(() => buildRemoteGrepCommand({ pattern: "bad\npattern" }, "/srv/app"), /newline/);
|
||||||
|
assert.throws(() => buildRemoteFindCommand({ pattern: "x", limit: 201 }, "/srv/app"), /limit/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes git paths, truncates rows, and reports the backend", () => {
|
||||||
|
const result = formatRemoteSearchOutput(
|
||||||
|
"__PI_SSH_SEARCH_BACKEND__:git-grep\nsrc/a.ts:2:TODO\nsrc/b.ts:3:TODO\nsrc/c.ts:4:TODO\n",
|
||||||
|
"/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.doesNotMatch(result.text, /src\/c\.ts/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("executes remote find through the structured transport with bounded output", async () => {
|
||||||
|
const transport = new SearchTransport("__PI_SSH_SEARCH_BACKEND__:fd\n/srv/app/a.ts\n");
|
||||||
|
const result = await runRemoteFind(transport, { pattern: "a", limit: 5 }, "/srv/app", "/home/build");
|
||||||
|
assert.equal(transport.cwd, "/srv/app");
|
||||||
|
assert.match(transport.command, /command -v fd/);
|
||||||
|
assert.equal(result.matchCount, 1);
|
||||||
|
assert.match(result.text, /backend: fd/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("propagates backend failures without exposing the internal marker", async () => {
|
||||||
|
const transport = new SearchTransport(
|
||||||
|
"__PI_SSH_SEARCH_BACKEND__:git-grep\nfatal: invalid regular expression\n",
|
||||||
|
128,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
runRemoteGrep(transport, { 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("adaptive commands preserve no-match success and propagate real backend errors", async () => {
|
||||||
|
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("/usr/bin/git", ["init", "-q", root]);
|
||||||
|
execFileSync("/usr/bin/git", ["-C", root, "add", "src/a.ts"]);
|
||||||
|
const environment = { ...process.env, PATH: "/usr/bin:/bin" };
|
||||||
|
const execute = (command: string) => execFileSync("/bin/bash", ["-c", command], {
|
||||||
|
cwd: root,
|
||||||
|
encoding: "utf8",
|
||||||
|
env: environment,
|
||||||
|
});
|
||||||
|
|
||||||
|
const find = buildRemoteFindCommand({ pattern: "a.ts", limit: 5 }, root);
|
||||||
|
const findResult = formatRemoteSearchOutput(execute(find.command), root, find.limit, "find");
|
||||||
|
assert.equal(findResult.backend, "git-ls-files");
|
||||||
|
assert.match(findResult.text, /src\/a\.ts/);
|
||||||
|
|
||||||
|
const grep = buildRemoteGrepCommand({ pattern: "TODO", include: "*.ts", limit: 5 }, root);
|
||||||
|
const grepResult = formatRemoteSearchOutput(execute(grep.command), root, grep.limit, "grep");
|
||||||
|
assert.equal(grepResult.backend, "git-grep");
|
||||||
|
assert.match(grepResult.text, /src\/a\.ts:1:/);
|
||||||
|
|
||||||
|
const noMatch = buildRemoteGrepCommand({ pattern: "ABSENT", include: "*.ts" }, root);
|
||||||
|
const noMatchProcess = spawnSync("/bin/bash", ["-c", noMatch.command], { cwd: root, encoding: "utf8", env: environment });
|
||||||
|
assert.equal(noMatchProcess.status, 0);
|
||||||
|
assert.equal(formatRemoteSearchOutput(noMatchProcess.stdout, root, noMatch.limit, "grep").matchCount, 0);
|
||||||
|
|
||||||
|
const invalidRegex = buildRemoteGrepCommand({ pattern: "[", literal: false }, root);
|
||||||
|
const invalidProcess = spawnSync("/bin/bash", ["-c", invalidRegex.command], { cwd: root, encoding: "utf8", env: environment });
|
||||||
|
assert.notEqual(invalidProcess.status, 0);
|
||||||
|
assert.match(`${invalidProcess.stdout}${invalidProcess.stderr}`, /git-grep|fatal|regular expression/i);
|
||||||
|
|
||||||
|
const missingRoot = join(root, "missing");
|
||||||
|
const missing = buildRemoteFindCommand({ pattern: "anything" }, missingRoot);
|
||||||
|
const missingProcess = spawnSync("/bin/bash", ["-c", missing.command], { cwd: root, encoding: "utf8", env: environment });
|
||||||
|
assert.notEqual(missingProcess.status, 0);
|
||||||
|
assert.match(`${missingProcess.stdout}${missingProcess.stderr}`, /find|No such file|not found/i);
|
||||||
|
|
||||||
|
const transport = new SearchTransport(execute(grep.command));
|
||||||
|
const throughTransport = await runRemoteGrep(transport, { pattern: "TODO", include: "*.ts" }, root, root);
|
||||||
|
assert.equal(throughTransport.matchCount, 1);
|
||||||
|
} 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, encoding: "utf8", env: environment });
|
||||||
|
assert.equal(failedFind.status, 3);
|
||||||
|
assert.match(failedFind.stdout, /fd exploded/);
|
||||||
|
|
||||||
|
const grep = buildRemoteGrepCommand({ pattern: "anything" }, root);
|
||||||
|
const failedGrep = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, encoding: "utf8", env: environment });
|
||||||
|
assert.equal(failedGrep.status, 2);
|
||||||
|
assert.match(failedGrep.stdout, /rg exploded/);
|
||||||
|
|
||||||
|
writeFileSync(rg, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||||
|
const noMatch = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, encoding: "utf8", 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,359 @@
|
|||||||
|
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 result = await transport.exec("printf ok", "/srv/build", {
|
||||||
|
onData: (data) => output.push(data),
|
||||||
|
timeout: 5,
|
||||||
|
});
|
||||||
|
assert.equal(result.exitCode, 7);
|
||||||
|
assert.equal(Buffer.concat(output).toString("utf8"), "stdout\nstderr\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 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());
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
- Added limits for active groups, generated group size, and total dynamic tools, plus current-host catalog/config/LRU tests.
|
- Added limits for active groups, generated group size, and total dynamic tools, plus current-host catalog/config/LRU tests.
|
||||||
- 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 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.
|
||||||
|
|
||||||
## [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_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 |
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
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. `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. `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: 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 the reviewed `ssh_bash` command surface stays isolated in `ssh-remote-shell`.
|
||||||
|
|
||||||
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 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"],
|
||||||
|
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 shell",
|
||||||
|
summary: "Run a reviewed shell command in the currently connected remote SSH workspace.",
|
||||||
|
useWhen: ["You need builds, tests, Git, or other shell operations on an active SSH target"],
|
||||||
|
avoidWhen: ["Structured remote file tools are sufficient, or the command should run locally"],
|
||||||
|
keywords: ["ssh", "remote", "bash", "build", "test", "git", "远程命令", "SSH"],
|
||||||
|
tools: ["ssh_bash"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "code-intelligence",
|
id: "code-intelligence",
|
||||||
title: "Code intelligence",
|
title: "Code intelligence",
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ test("bundle seed names are unique and cover the declared my-pi tools", () => {
|
|||||||
assert.equal(result.catalog.tools.find((card) => card.name === "tavily_web_fetch")?.primaryGroup, "web-tavily");
|
assert.equal(result.catalog.tools.find((card) => card.name === "tavily_web_fetch")?.primaryGroup, "web-tavily");
|
||||||
assert.equal(result.catalog.tools.find((card) => card.name === "hippo_recall")?.primaryGroup, "memory-recall");
|
assert.equal(result.catalog.tools.find((card) => card.name === "hippo_recall")?.primaryGroup, "memory-recall");
|
||||||
assert.equal(result.catalog.tools.find((card) => card.name === "hippo_remember")?.primaryGroup, "memory-management");
|
assert.equal(result.catalog.tools.find((card) => card.name === "hippo_remember")?.primaryGroup, "memory-management");
|
||||||
|
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");
|
||||||
});
|
});
|
||||||
|
|
||||||
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" "$@"
|
||||||
@@ -27,6 +27,27 @@ 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");
|
||||||
|
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.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.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",
|
||||||
|
|||||||
@@ -21,6 +21,20 @@ test("search routing narrows files before requesting matching line numbers", ()
|
|||||||
assert.match(section, /Use read with offset\/limit only for the exact matching region/);
|
assert.match(section, /Use read with offset\/limit only for the exact matching region/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("SSH connection routing requires an explicit named host and concrete task", () => {
|
||||||
|
const section = buildToolRoutingSection(["ssh_connect"]);
|
||||||
|
assert.match(section, /only when the user explicitly names an imported host/);
|
||||||
|
assert.match(section, /Connect before calling other ssh_\* tools/);
|
||||||
|
assert.match(section, /never infer or substitute a different host/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("remote search routing uses structured bounded SSH tools", () => {
|
||||||
|
const section = buildToolRoutingSection(["ssh_find", "ssh_grep"]);
|
||||||
|
assert.match(section, /use ssh_find to narrow remote file paths before ssh_grep/);
|
||||||
|
assert.match(section, /Do not run find, fd, grep, or rg through ssh_bash/);
|
||||||
|
assert.match(section, /when a result says truncated, narrow the path or pattern/);
|
||||||
|
});
|
||||||
|
|
||||||
test("context routing analyzes large files without a full read", () => {
|
test("context routing analyzes large files without a full read", () => {
|
||||||
const section = buildToolRoutingSection(["ctx_execute", "ctx_execute_file"]);
|
const section = buildToolRoutingSection(["ctx_execute", "ctx_execute_file"]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user