fix(pi-ssh): harden remote execution and search

This commit is contained in:
云服务部-叶林立
2026-08-24 23:04:29 +08:00
parent a7891f18bf
commit aff91b0972
30 changed files with 1063 additions and 222 deletions
+7 -5
View File
@@ -8,7 +8,7 @@
- 上游来源:<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` - `pi-ssh/`:从上游源码导入并由根组合包加载的纯 `ssh2` 远程操作扩展,通过受权限链复核的 `ssh_connect` 建立 Agent 控制的持久连接,`ssh_cd` 显式维护远端工作区,并以 SFTP 与有界自适应搜索提供独立的远端工具;运行时不调用 OpenSSH 或 `sshpass`
- 上游来源:<https://github.com/pansapiens/pi-ssh> - 上游来源:<https://github.com/pansapiens/pi-ssh>
- 初始导入快照:`e9a1059a0f37ab14b6a73ee608cb203edf803f31`2026-06-23`0.7.0`)。 - 初始导入快照:`e9a1059a0f37ab14b6a73ee608cb203edf803f31`2026-06-23`0.7.0`)。
- 该目录已纳入本仓库直接维护,不是 submodule,也不保留嵌套 `.git``node_modules` 或构建产物;纯 `ssh2` 设计参考 `99percentpeople/pi-extensions` 的明确 commit,来源记录保留在 `pi-ssh/UPSTREAM.md` - 该目录已纳入本仓库直接维护,不是 submodule,也不保留嵌套 `.git``node_modules` 或构建产物;纯 `ssh2` 设计参考 `99percentpeople/pi-extensions` 的明确 commit,来源记录保留在 `pi-ssh/UPSTREAM.md`
@@ -77,7 +77,7 @@
- `pi-condense` 负责压缩已经进入会话的历史工具结果,与 Context Mode 的输入隔离职责互补;`extensions/condense.ts` 只在 `settings.json` 尚无 `contextPrune.enabled` 时写入 `true`,必须保留用户显式设置的 `false`,配置无效时不得覆盖原文件。其他参数沿用上游默认,包括 `agent-message` 触发模式和 skill 路径保护。 - `pi-condense` 负责压缩已经进入会话的历史工具结果,与 Context Mode 的输入隔离职责互补;`extensions/condense.ts` 只在 `settings.json` 尚无 `contextPrune.enabled` 时写入 `true`,必须保留用户显式设置的 `false`,配置无效时不得覆盖原文件。其他参数沿用上游默认,包括 `agent-message` 触发模式和 skill 路径保护。
- Codex fast mode 只为符合条件的 `openai-codex-responses` 请求设置 priority service tier。`/fast-mode on|off` 同时更新当前分支记录与 Pi agent 目录中的 owner-only 全局默认值;新会话继承全局值,已有分支记录优先,配置缺失或无效时回退为关闭。 - Codex fast mode 只为符合条件的 `openai-codex-responses` 请求设置 priority service tier。`/fast-mode on|off` 同时更新当前分支记录与 Pi agent 目录中的 owner-only 全局默认值;新会话继承全局值,已有分支记录优先,配置缺失或无效时回退为关闭。
- 权限策略默认允许常规工具,允许 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 cwdAutoReview 只可依据用户直接消息中的明确目标授权连接。`ssh_bash` 通过 `shellTools` 映射到完整 Bash 策略并设置 `decisionFloor: "ask"`,把普通 Bash `allow` 提升为复核请求,同时保留原有 `ask` 与硬 `deny`。远端路径不得送入基于本机 cwd 的 `path` / `external_directory` 归一化。 - SSH 权限沿用同一条 `pi-permission-system` gate 与 `auto-review` authorizer chain`ssh_connect``ssh_cd``ssh_read``ssh_write``ssh_edit``ssh_find``ssh_grep` 默认 `ask`;连接前的证据解析已导入 host ID 为非秘密 target、port 与请求/default cwdAutoReview 只可依据用户直接消息中的明确目标授权连接。`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 内容不能自行授权。
@@ -168,9 +168,11 @@
- 根包必须直接固定安装 `ssh2@1.17.0` 与配置 CLI 所需的 `jiti@2.7.0`,因为 packed bundle 直接加载其内置 `pi-ssh/` 源码;`ssh2``cpu-features` 的 install scripts 只构建可选加速绑定,当前不得加入根 `allowScripts`,纯 JavaScript fallback 必须可运行。 - 根包必须直接固定安装 `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、权限证据或明文临时文件。 - SSH vault 使用同目录独立随机 key 和 AES-256-GCM 整体加密,目录/文件在 POSIX 上必须保持 `700` / `600`;该设计只防止误看或单独泄漏密文,不防同一用户读取 key。密码、私钥 passphrase、私钥内容、vault key 与解密明文不得进入命令参数、日志、Pi session、权限证据或明文临时文件。
- 已导入主机必须固定 SHA256 Host Key;不匹配时 fail closed。私钥只保存路径,不复制内容;文件工具使用 SFTP,写入优先临时文件与原子 rename,远端断线不得自动重放命令。 - 已导入主机必须固定 SHA256 Host Key;不匹配时 fail closed。私钥只保存路径,不复制内容;文件工具使用 SFTP,写入优先临时文件与原子 rename,远端断线不得自动重放命令。
- `ssh_connect` 是唯一运行时连接入口,只接受 vault 中已导入的 host ID 与可选绝对/`~/` remote cwd,并必须默认 `ask` 后进入 AutoReview;不得注册 `/ssh``--ssh`、session resume 自动重连或把用户 `!` 命令切换到远端。用户未在直接请求中明确服务器和具体远端任务时不得推断、替换或连接主机;新连接替换旧连接,会话结束自动断开。 - `ssh_connect` 是唯一运行时连接入口,只接受 vault 中已导入的 host ID 与可选绝对/`~/` remote cwd,并必须默认 `ask` 后进入 AutoReview它必须声明 `sequential` execution mode 且支持取消,Agent 必须作为独立步骤调用并等待成功后才能发出依赖连接的其他 `ssh_*` 调用,避免连接建立或替换与远端操作重叠。未知 host ID 只可有界列出已导入 ID,不得泄露 endpoint 或凭据。不得注册 `/ssh``--ssh`、session resume 自动重连或把用户 `!` 命令切换到远端。用户未在直接请求中明确服务器和具体远端任务时不得推断、替换或连接主机;新连接替换旧连接,会话结束自动断开。
- `ssh_find` / `ssh_grep` 必须在已授权工具调用内部按 `fd/fdfind → git ls-files → find``rg → git grep → find+grep` 顺序检测服务器现有能力,不安装或上传远端二进制;输入必须 shell-safe,结果数量/单行/总捕获必须有界并显式报告 backend 与 truncated。RTK 不得处理这两个搜索工具的结果。 - `ssh_find` / `ssh_grep` 必须在已授权工具调用内部按 `fd/fdfind → git ls-files → find``rg → git grep → find+grep` 顺序检测服务器现有能力,不安装或上传远端二进制;搜索目标必须作为绝对参数与执行 cwd 分离,搜索进程始终从当前已确认 `remoteCwd` 启动,单文件 `ssh_grep` 目标不得被当作目录执行 `cd`输入必须 shell-safe,结果数量/单行/总捕获必须有界并显式报告 backend 与 truncated;30 秒超时必须报告解析后的 root 并提示先缩小范围。RTK 不得处理这两个搜索工具的结果。
- `ssh_read``ssh_write``ssh_edit``ssh_find``ssh_grep` 的远端路径不能按本机路径执行 `path` / `external_directory` gate;必须通过公共 `PermissionsService` 注册显式 extractor 关闭默认 `input.path` 推断,并保持个工具的默认策略为 `ask` - `ssh_cd``ssh_read``ssh_write``ssh_edit``ssh_find``ssh_grep` 的远端路径不能按本机路径执行 `path` / `external_directory` gate;必须通过公共 `PermissionsService` 注册显式 extractor 关闭默认 `input.path` 推断,并保持个工具的默认策略为 `ask`权限预览必须依据远端 `remoteCwd` / `remoteHome` 解析相对路径与 `~/`,同时显示请求值和解析后的绝对远端路径。
- SSH transport 在会话中持久,但不得维护隐藏的交互式 Shell/PTY 状态;`ssh_bash` 每次调用必须从连接当前 `remoteCwd` 启动独立的非交互 Bash,必须忽略 Pi 本地 Bash factory 的 cwd 与 `PI_*` 会话环境。跨调用的工作区切换只能通过受复核且声明 `sequential` execution mode 的 `ssh_cd` 显式验证并更新连接状态;Agent 必须把它作为独立步骤调用并等待成功后,才可发出依赖新目录的远端工具调用。普通命令中的 `cd` 只可作为当次命令的临时目录变化,`cd``export`、alias 或函数不得被伪装为持久状态。
- 远端 HOME/cwd 探测与 `ssh_cd` 验证必须使用可取消、输出有界、随机标记 framing 的固定命令,只接受唯一绝对 POSIX 路径;banner、畸形/多行输出、超限、超时或取消时不得更新状态。独立 exec channel 最多并发 4 路,排队调用必须可立即取消;共享 SFTP 操作继续串行。
- `ssh_bash` 必须在权威权限配置的 `shellTools` 中映射 `commandArgument: "command"` 并设置 `decisionFloor: "ask"`,复用 Bash 命令拆分、硬拒绝、风险 `ask` 和 authorizer chain;全局 floor 必须在项目配置字段级合并时保留,不得新增第二个并行 `tool_call` 审批层。 - `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` 返回。 - 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,不得转为无提示自动允许。 - SSH 工具的权限预览必须包含连接请求或当前远端的 host id、target、port、remote cwd 与有界操作摘要;连接预览只解密本地 vault 以提取非秘密目标字段,绝不包含凭据,远端 shell 的完整命令继续由 Bash payload 单独提供。权限服务缺失或注册失败时必须 fail-safe,不得转为无提示自动允许。
+3 -3
View File
@@ -18,7 +18,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 复核的连接。 - 本仓库维护的 `pi-ssh`:通过纯 Node `ssh2` 持久连接、显式 `ssh_cd` 远端工作区切换、SFTP 与自适应有界搜索提供独立的远端工具;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接。
- `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,并按当前激活工具追加简短的工具与搜索路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。 - `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,并按当前激活工具追加简短的工具与搜索路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。
## 安装、升级与卸载 ## 安装、升级与卸载
@@ -198,9 +198,9 @@ FFF 仍负责精确字面搜索;RTK 继续压缩未走 Context Mode 的 Bash
脚本只在导入阶段调用 `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 -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_config.sh list|update|remove|rotate-key` 管理已导入主机。第一版拒绝 `ProxyJump` / `ProxyCommand`,且不会自动读取远端 `AGENTS.md``CLAUDE.md`。连接不再通过 `/ssh``--ssh` 或 session resume 建立:用户在具体远端任务中明确指定已导入 host ID 后,Agent 把受复核、串行且可取消的 `ssh_connect` 作为独立步骤调用并等待成功,再使用其他 `ssh_*` 工具;连接动作及后续操作均进入 `ask → auto-review`,相对远端路径会在预览中显示请求值和解析后的绝对路径,会话结束自动断开。SSH transport 在会话内持久,独立命令最多使用 4 路有界并发 exec channel,但每次 `ssh_bash` 都启动独立的非交互 Shell;需要持续改变后续远端操作的工作目录时,Agent 同样必须把受复核且串行执行的 `ssh_cd` 作为独立步骤调用并等待成功,而不是依赖某次 Shell 中临时执行的 `cd`
远端文件搜索使用独立的 `ssh_find` / `ssh_grep`:在每次获批调用内按服务器现有能力选择 `fd/fdfind → git ls-files → find``rg → git grep → find+grep`,不安装远端软件,并以最大 200 条结果、单行截断和 `truncated` 标记约束返回。RTK 只压缩 `ssh_bash` 的非搜索输出,不改写远端命令,也不处理 `ssh_find``ssh_grep``ssh_read`。详细说明见 [`pi-ssh/README.md`](pi-ssh/README.md)。 远端文件搜索使用独立的 `ssh_find` / `ssh_grep`:在每次获批调用内按服务器现有能力选择 `fd/fdfind → git ls-files → find``rg → git grep → find+grep`,不安装远端软件,并以最大 200 条结果、单行截断和 `truncated` 标记约束返回。搜索目标作为绝对参数与当前远端执行 cwd 分离,因此 `ssh_grep` 可直接搜索单个文件而不会尝试把文件当目录进入;30 秒超时会报告解析后的 root 并提示先用 `ssh_find` 缩小范围。远端 HOME/cwd 通过可取消、有界随机标记探测获得,畸形结果不会更新连接状态。RTK 只压缩 `ssh_bash` 的非搜索输出,不改写远端命令,也不处理 `ssh_find``ssh_grep``ssh_read`。详细说明见 [`pi-ssh/README.md`](pi-ssh/README.md)。
### 在线搜索服务 ### 在线搜索服务
+1
View File
@@ -19,6 +19,7 @@
"find": "allow", "find": "allow",
"multi_grep": "allow", "multi_grep": "allow",
"ssh_connect": "ask", "ssh_connect": "ask",
"ssh_cd": "ask",
"ssh_read": "ask", "ssh_read": "ask",
"ssh_write": "ask", "ssh_write": "ask",
"ssh_edit": "ask", "ssh_edit": "ask",
+7 -1
View File
@@ -46,7 +46,13 @@ export function buildToolRoutingSection(selectedTools: SelectedTools): string {
if (hasTool(selectedTools, "ssh_connect")) { if (hasTool(selectedTools, "ssh_connect")) {
rules.push( 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.", "- Use ssh_connect only when the user explicitly names an imported host as part of a concrete remote task. Call it as a separate step and wait for success before calling other ssh_* tools; never infer or substitute a different host.",
);
}
if (hasTool(selectedTools, "ssh_cd")) {
rules.push(
"- Treat ssh_cd as a reviewed persistent workspace transition: call it as a separate step and wait for success before issuing ssh_bash or relative ssh_read/ssh_write/ssh_edit/ssh_find/ssh_grep calls that depend on the new remote cwd. Use cd inside ssh_bash only for an intentionally temporary, command-local directory change.",
); );
} }
+14
View File
@@ -1,5 +1,19 @@
# Changelog # Changelog
## Unreleased
- Keep the SSH transport persistent while making `ssh_bash` explicitly stateless: every call starts in the active remote cwd and local Pi cwd/environment metadata can no longer leak into remote execution.
- Add reviewed, sequential `ssh_cd` workspace changes for persistent remote cwd selection without a hidden interactive shell; dependent remote calls are explicitly deferred until the change succeeds.
- Clarify remote shell prompt metadata and add regression coverage for cwd isolation, atomic workspace changes, failure preservation, and subsequent shell/file/search path resolution.
- Make `ssh_connect` a sequential reviewed state transition so establishing or replacing the active connection cannot overlap dependent remote operations.
- Separate remote-search execution cwd from its absolute target root, including regression coverage for single-file `ssh_grep` targets.
- Resolve relative and `~/` remote paths to absolute targets in permission previews while retaining the original request and excluding remote paths from local filesystem gates.
- Replace unbounded HOME/cwd capture with cancellable, bounded, random-marker probes that accept one absolute POSIX path and preserve prior state on failure.
- Allow up to four independent SSH exec channels while retaining serialized SFTP operations; queued exec cancellation is immediate.
- Report bounded imported host ID alternatives for unknown IDs and add actionable resolved-root guidance for 30-second remote-search timeouts.
- Normalize `ssh_grep` hidden-file, basename-glob, and portable ERE behavior across ripgrep, Git, and fallback backends; reject `~user` paths.
- Treat fallback grep exit 1 as a genuine no-match without hiding find or grep errors, separate backend stderr, and use bounded NUL-delimited grep records for newline-safe filenames.
## 0.9.0 - 2026-08-21 ## 0.9.0 - 2026-08-21
- Add reviewed, agent-callable `ssh_connect` for explicitly imported hosts. - Add reviewed, agent-callable `ssh_connect` for explicitly imported hosts.
+18 -9
View File
@@ -3,6 +3,7 @@
`pi-ssh` keeps Pi and its local tools on the local machine while exposing explicit remote tools over a persistent Node `ssh2` connection: `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_connect`
- `ssh_cd`
- `ssh_read` - `ssh_read`
- `ssh_write` - `ssh_write`
- `ssh_edit` - `ssh_edit`
@@ -14,7 +15,7 @@ The extension does not override Pi's local `read`, `write`, `edit`, `find`, `gre
## Architecture ## 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. Runtime connections are pure `ssh2`; the extension does not spawn OpenSSH and does not require `sshpass`, `ControlMaster`, or passwordless login. Remote file operations use SFTP and remote shell commands use an SSH exec channel. The SSH transport persists, but commands intentionally use fresh non-interactive Bash processes rather than a hidden stateful PTY.
Hosts must be explicitly imported before use. OpenSSH remains only an import source: the configuration helper runs `ssh -G <alias>` once to resolve the selected alias, then stores the resulting endpoint and authentication data in the pi-ssh vault. Later changes to `~/.ssh/config` require re-importing the host. 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.
@@ -88,20 +89,24 @@ The model first calls:
ssh_connect({ hostId: "packaging-server" }) 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. It may set `remotePath` to an absolute path, `~`, or a path beginning with `~/`. The model calls sequential `ssh_connect` as a separate step and waits for the reviewed connection to succeed before using other `ssh_*` tools. `ssh_cd` explicitly changes the active remote workspace for subsequent shell, relative file, and relative search operations; it accepts absolute paths, paths relative to the current remote cwd, and `~/` paths relative to remote HOME. The model must also call `ssh_cd` as a separate step and wait for its successful result before issuing dependent remote operations. Only imported host IDs are accepted; arbitrary `user@host` targets are rejected. Unknown IDs report a bounded list of available imported IDs without exposing credentials. A new connection replaces the previous active connection, and session shutdown disconnects it.
## Runtime behavior ## Runtime behavior
- `ssh_connect` is the only runtime connection surface; it is agent-callable and permission-reviewed. - `ssh_connect` is the only runtime connection surface; it is agent-callable, permission-reviewed, sequential, and cancellable so connection replacement cannot overlap another tool call or remain stuck after user cancellation.
- One persistent `ssh2` client is used for the active host. - One persistent `ssh2` client is used for the active host; no interactive shell or PTY state is retained.
- Each `ssh_bash` call opens an exec channel and runs under `bash -lc` in the selected remote cwd. - `ssh_cd` is a sequential state transition: it validates a remote directory and updates the active workspace without reconnecting. Dependent tool calls must wait for it to succeed.
- HOME/cwd probes use bounded random-marker framing, require one absolute POSIX path, and preserve the previous state on malformed output, timeout, or cancellation.
- Independent exec channels use bounded concurrency of four; shared SFTP operations remain serialized.
- Each `ssh_bash` call opens a fresh exec channel and runs under `bash -lc` in the active remote cwd. A command-local `cd` is temporary, and `cd`, `export`, alias, function, or other shell state inside one call does not persist to the next call.
- SFTP provides remote reads and writes. - 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. - 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. - A pinned SHA256 host-key mismatch fails closed.
- Connection loss fails closed; the extension does not silently replay a command. - Connection loss fails closed; the extension does not silently replay a command.
- Remote `AGENTS.md` and `CLAUDE.md` files are never discovered or injected. - 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. - `ssh_find` and `ssh_grep` perform capability detection inside each approved call and return at most 200 bounded result lines. Search targets are always separate from the execution cwd, so `ssh_grep` can target a single remote file without attempting to enter it as a directory. A 30-second timeout reports the resolved root and instructs the model to narrow it with `ssh_find`.
- RTK treats only `ssh_bash` as a Bash output-compaction alias; it does not rewrite remote commands or process remote search/read results. - RTK treats only `ssh_bash` as a Bash output-compaction alias; it does not rewrite remote commands or process remote search/read results.
- Permission previews display resolved absolute remote paths together with the original relative or `~/` request; remote paths never enter local filesystem gates.
### Adaptive remote search ### Adaptive remote search
@@ -111,13 +116,15 @@ It may set `remotePath` to an absolute path, `~`, or a path beginning with `~/`.
fd → fdfind → git ls-files → find fd → fdfind → git ls-files → find
``` ```
`ssh_grep` defaults to literal, case-insensitive content matching with this backend order: `ssh_grep` defaults to literal, case-insensitive content matching with this backend order. Hidden paths are excluded unless `includeHidden` is true, and `include` is a basename-only glob such as `*.ts` (globs containing `/` are rejected):
```text ```text
ripgrep → git grep → find + grep 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. No backend is installed or uploaded. The tools use what the server already provides, report the chosen backend and truncation state, and cap `limit` at 200. `literal: false` accepts the portable POSIX ERE subset shared by the backends; Git and fallback grep are explicitly run in ERE mode. Grep results use bounded NUL-delimited path/line/content records, so newline-containing filenames cannot corrupt match counts or truncation. Backend stderr is kept out of result rows and is reported as a warning on success or failure detail on error.
A genuine no-match result—including the `find + grep` fallback—succeeds with zero rows; invalid regexes, missing roots, and backend failures remain errors. Search paths accept absolute paths, relative paths, `~`, and `~/...`; `~user` expansion is rejected. Narrow `path` and `pattern` when truncated rather than increasing the limit. Direct `find`, `fd`, `grep`, and `rg` commands remain forbidden through `ssh_bash`; use the structured search tools instead.
The remote host must provide `bash`. SFTP support is required for file tools. The remote host must provide `bash`. SFTP support is required for file tools.
@@ -126,7 +133,7 @@ The remote host must provide `bash`. SFTP support is required for file tools.
All remote operations enter the bundle's existing permission chain: 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_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_cd`, `ssh_read`, `ssh_write`, `ssh_edit`, `ssh_find`, and `ssh_grep` start as `ask`;
- `ssh_bash` uses the full deterministic Bash policy and `decisionFloor: "ask"`; - `ssh_bash` uses the full deterministic Bash policy and `decisionFloor: "ask"`;
- deterministic hard denies remain denies; - deterministic hard denies remain denies;
- asks enter the configured `auto-review` authorizer; - asks enter the configured `auto-review` authorizer;
@@ -153,6 +160,8 @@ Important files:
- `index.ts` — Pi extension and tool registration - `index.ts` — Pi extension and tool registration
- `src/ssh2-transport.ts` — persistent ssh2, exec, and SFTP transport - `src/ssh2-transport.ts` — persistent ssh2, exec, and SFTP transport
- `src/remote-bash.ts` — stateless Bash adapter pinned to the active remote cwd
- `src/remote-cwd.ts` — explicit remote workspace path resolution
- `src/config.ts` — validated configuration types - `src/config.ts` — validated configuration types
- `src/vault.ts` — AES-GCM vault - `src/vault.ts` — AES-GCM vault
- `src/import.ts``ssh -G` import helpers - `src/import.ts``ssh -G` import helpers
+16 -8
View File
@@ -5,6 +5,7 @@
Pi and its default tools remain local. Explicit collision-free tools perform selected operations on one configured remote server: Pi and its default tools remain local. Explicit collision-free tools perform selected operations on one configured remote server:
- `ssh_connect` - `ssh_connect`
- `ssh_cd`
- `ssh_read` - `ssh_read`
- `ssh_write` - `ssh_write`
- `ssh_edit` - `ssh_edit`
@@ -33,7 +34,7 @@ The resulting host ID is the only runtime selector. When a direct user request n
ssh_connect({ hostId: "<host-id>", remotePath?: "/absolute/or/~/path" }) 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. `ssh_connect` is the only runtime connection surface. It is a sequential, cancellable model tool governed by `pi-permission-system`: the agent calls it as a separate step and waits for success before dependent remote tools, so establishing or replacing a connection cannot overlap another tool call. Unknown IDs return at most ten sorted imported IDs and never endpoint credentials. There is no `/ssh` command, `--ssh` flag, session-resume reconnect, or user `!` remote-shell override.
Arbitrary `user@host`, port overrides, ProxyJump, and ProxyCommand are not supported in the first pure-ssh2 version. Unsupported imported configuration is rejected rather than ignored. 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.
@@ -69,11 +70,13 @@ Import displays the observed host-key algorithm and SHA256 fingerprint for expli
## Transport ## Transport
One persistent `ssh2.Client` belongs to the active host. Connection loss fails closed and no operation is automatically replayed. One persistent `ssh2.Client` belongs to the active host. The transport persists, but there is no persistent interactive shell or PTY. Connection loss fails closed and no operation is automatically replayed.
### Shell ### 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. `ssh_bash` opens a fresh exec channel and non-interactive Bash process for every call. Commands run through `bash -lc` after changing to the active remote cwd. The cwd supplied by Pi's local Bash factory is ignored at the transport adapter boundary; only connection state may select the remote cwd. A command-local `cd` is intentionally temporary; shell-local `cd`, exports, aliases, functions, and other process state do not persist across calls. Output streams through the normal Pi Bash operations callback. Abort or timeout closes the channel without reconnecting or replaying.
`ssh_cd` is the explicit persistent workspace operation and declares Pi's sequential execution mode so it cannot overlap sibling tool calls. It resolves absolute paths, paths relative to the active remote cwd, and `~/` paths relative to remote HOME; validates the directory by executing `pwd` from it; and updates connection state without reconnecting. The model calls it as a separate step and waits for success before issuing subsequent relative file/search operations or `ssh_bash` calls that depend on the updated cwd.
### Files ### Files
@@ -92,10 +95,13 @@ Remote paths map from Pi's local factory cwd into the selected remote cwd, but p
Search tools execute one capability-adaptive, bounded shell pipeline inside the already approved tool call. They never install or upload binaries. 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_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. - `ssh_grep`: `rg``git grep``find + grep`; literal case-insensitive semantics by default, optional hidden-path inclusion, basename-only include globs, and a portable POSIX ERE subset when literal mode is disabled.
- each backend emits at most `limit + 1` lines so truncation is explicit; public `limit` is 1200; each returned line is capped. - each backend emits at most `limit + 1` NUL-delimited path/line/content records so truncation is explicit and newline filenames remain one result; public `limit` is 1200; each returned line is capped.
- relative paths resolve against remote cwd and `~/` against remote home. - relative paths resolve against remote cwd and `~/` against remote home; other `~user` forms are rejected.
- the search target root is passed to the backend as an absolute argument, while the search process executes from the active remote cwd; a single-file `ssh_grep` target is never used as a process cwd.
- searches time out after 30 seconds with the resolved root and explicit guidance to narrow it before retrying.
- user strings are single-quoted as shell arguments and NUL/newline input is rejected. - user strings are single-quoted as shell arguments and NUL/newline input is rejected.
- backend stdout contains only parseable results; stderr is captured independently for warnings and failure diagnostics.
- capability detection occurs only within the reviewed search call. - capability detection occurs only within the reviewed search call.
- search output is normalized by `pi-ssh` and excluded from RTK compaction. - search output is normalized by `pi-ssh` and excluded from RTK compaction.
@@ -106,7 +112,7 @@ Direct search commands remain denied through `ssh_bash`; structured search tools
There is one permission gate: `pi-permission-system`. 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_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_cd`, `ssh_read`, `ssh_write`, `ssh_edit`, `ssh_find`, and `ssh_grep` default to `ask`; remote path extractors disable local path normalization for all six tools, while previews resolve relative and `~/` requests against the active remote cwd/HOME and display both requested and absolute paths.
- `ssh_bash` is a Bash-semantic `shellTools` alias with `decisionFloor: "ask"`. - `ssh_bash` is a Bash-semantic `shellTools` alias with `decisionFloor: "ask"`.
- Bash hard denies remain denies. - Bash hard denies remain denies.
- All asks enter the configured authorizer chain. - All asks enter the configured authorizer chain.
@@ -114,8 +120,10 @@ There is one permission gate: `pi-permission-system`.
Evidence includes configured host ID, endpoint, port, remote cwd, and a bounded operation summary, never credentials. Evidence includes configured host ID, endpoint, port, remote cwd, and a bounded operation summary, never credentials.
Connection HOME/cwd discovery and `ssh_cd` validation use a bounded random-marker probe over a cancellable exec channel. The parser accepts exactly one framed absolute POSIX path; startup banners, malformed/multiline values, overflow, timeout, or cancellation cannot update connection state. Independent exec operations use at most four concurrent SSH channels, while SFTP operations remain serialized.
## Session lifecycle ## 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. 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. The system prompt states that default tools are local, `ssh_*` tools are remote, and each `ssh_bash` call starts a fresh non-interactive shell after a connection becomes active. It directs persistent workspace changes through `ssh_cd` and does not include remote file content.
+68 -48
View File
@@ -6,7 +6,6 @@ import {
createEditTool, createEditTool,
createReadTool, createReadTool,
createWriteTool, createWriteTool,
type BashOperations,
type EditOperations, type EditOperations,
type ReadOperations, type ReadOperations,
type WriteOperations, type WriteOperations,
@@ -17,19 +16,27 @@ import {
type SshPermissionConnection, type SshPermissionConnection,
} from "./permission-integration.ts"; } from "./permission-integration.ts";
import { import {
getConfiguredHost,
parseConnectInput, parseConnectInput,
SSH_CONNECT_TOOL_METADATA, SSH_CONNECT_TOOL_METADATA,
type HostSelection, type HostSelection,
} from "./src/agent-connection.ts"; } from "./src/agent-connection.ts";
import { loadVault } from "./src/vault.ts"; import { loadVault } from "./src/vault.ts";
import { Ssh2Transport, type RemoteTransport } from "./src/ssh2-transport.ts"; import { Ssh2Transport, type RemoteTransport } from "./src/ssh2-transport.ts";
import type { PiSshConfig, SshHostConfig } from "./src/config.ts"; import type { SshHostConfig } from "./src/config.ts";
import { import {
runRemoteFind, runRemoteFind,
runRemoteGrep, runRemoteGrep,
type RemoteFindInput, type RemoteFindInput,
type RemoteGrepInput, type RemoteGrepInput,
} from "./src/remote-search.ts"; } from "./src/remote-search.ts";
import { createRemoteBashOps } from "./src/remote-bash.ts";
import {
changeRemoteCwd,
mapLocalPathToRemote,
SSH_CD_EXECUTION_MODE,
} from "./src/remote-cwd.ts";
import { probeRemotePath } from "./src/remote-probe.ts";
interface SshConnection { interface SshConnection {
@@ -43,17 +50,6 @@ interface SshConnection {
} }
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 { function createRemoteReadOps(connection: SshConnection, transport: RemoteTransport): ReadOperations {
return { return {
@@ -87,17 +83,7 @@ function createRemoteEditOps(connection: SshConnection, transport: RemoteTranspo
}; };
} }
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 { function resolveRequestedPath(selection: HostSelection, host: SshHostConfig, remoteHome: string, remotePwd: string): string {
const requested = selection.remotePath ?? host.defaultCwd ?? remotePwd; const requested = selection.remotePath ?? host.defaultCwd ?? remotePwd;
@@ -106,30 +92,22 @@ function resolveRequestedPath(selection: HostSelection, host: SshHostConfig, rem
return requested; 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( async function connectSelection(
selection: HostSelection, selection: HostSelection,
localCwd: string, localCwd: string,
localHome: string, localHome: string,
signal?: AbortSignal,
): Promise<{ connection: SshConnection; transport: Ssh2Transport }> { ): Promise<{ connection: SshConnection; transport: Ssh2Transport }> {
const config = loadVault(); const config = loadVault();
const host = getConfiguredHost(config, selection.hostId); const host = getConfiguredHost(config, selection.hostId);
const transport = new Ssh2Transport(host); const transport = new Ssh2Transport(host);
try { try {
await transport.connect(); await transport.connect(signal);
const remoteHome = await captureChecked(transport, 'printf "%s" "$HOME"'); const remoteHome = await probeRemotePath(transport, "home", ".", signal);
const remotePwd = await captureChecked(transport, "pwd"); const remotePwd = await probeRemotePath(transport, "cwd", ".", signal);
if (!remoteHome || !remotePwd) throw new Error("remote HOME/cwd probe returned empty output");
const requestedPath = resolveRequestedPath(selection, host, remoteHome, remotePwd); const requestedPath = resolveRequestedPath(selection, host, remoteHome, remotePwd);
const remoteCwd = await captureChecked(transport, "pwd", requestedPath); const remoteCwd = await probeRemotePath(transport, "cwd", requestedPath, signal);
return { return {
connection: { connection: {
hostId: selection.hostId, hostId: selection.hostId,
@@ -169,7 +147,7 @@ export default function piSshExtension(pi: ExtensionAPI): void {
const localRead = createReadTool(localCwd); const localRead = createReadTool(localCwd);
const localWrite = createWriteTool(localCwd); const localWrite = createWriteTool(localCwd);
const localEdit = createEditTool(localCwd); const localEdit = createEditTool(localCwd);
const localBash = createBashTool(localCwd); const localBash = createBashTool(localCwd, { exposeSessionEnvironment: false });
let connection: SshConnection | null = null; let connection: SshConnection | null = null;
let transport: Ssh2Transport | null = null; let transport: Ssh2Transport | null = null;
@@ -199,9 +177,13 @@ export default function piSshExtension(pi: ExtensionAPI): void {
pi.registerTool({ pi.registerTool({
...SSH_CONNECT_TOOL_METADATA, ...SSH_CONNECT_TOOL_METADATA,
async execute(_id, params) { async execute(_id, params, signal) {
const selection = parseConnectInput(params as Record<string, unknown>); const selection = parseConnectInput(params as Record<string, unknown>);
const connected = await connectSelection(selection, localCwd, localHome); const connected = await connectSelection(selection, localCwd, localHome, signal);
if (signal?.aborted) {
await connected.transport.dispose();
throw new Error("SSH connection aborted");
}
await activateConnection(connected.connection, connected.transport); await activateConnection(connected.connection, connected.transport);
const text = `Connected to ${connected.connection.remote}:${connected.connection.remoteCwd} (port ${connected.connection.port}).`; const text = `Connected to ${connected.connection.remote}:${connected.connection.remoteCwd} (port ${connected.connection.port}).`;
return { return {
@@ -216,6 +198,37 @@ export default function piSshExtension(pi: ExtensionAPI): void {
}, },
}); });
pi.registerTool({
name: "ssh_cd",
label: "ssh_cd",
description: "Change the active SSH workspace directory as a reviewed, persistent state transition. Call ssh_cd separately and wait for it to succeed before issuing ssh_bash or relative remote file/search operations that depend on the new directory.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
minLength: 1,
description: "Remote directory: absolute, relative to the active remote cwd, or ~/ relative to remote HOME",
},
},
required: ["path"],
additionalProperties: false,
},
executionMode: SSH_CD_EXECUTION_MODE,
async execute(_id, params, signal) {
const active = requireSsh("ssh_cd");
const changed = await changeRemoteCwd(
active.connection,
(params as { path: string }).path,
(requestedCwd) => probeRemotePath(active.transport, "cwd", requestedCwd, signal),
);
return {
content: [{ type: "text", text: `Remote cwd changed from ${changed.previousCwd} to ${changed.remoteCwd}. Dependent remote tools may now run.` }],
details: changed,
};
},
});
pi.registerTool({ pi.registerTool({
...localRead, ...localRead,
name: "ssh_read", name: "ssh_read",
@@ -287,15 +300,16 @@ export default function piSshExtension(pi: ExtensionAPI): void {
pi.registerTool({ pi.registerTool({
name: "ssh_grep", name: "ssh_grep",
label: "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.", description: "Search remote file contents using ripgrep, git grep, or grep with consistent hidden-file, basename-glob, and portable POSIX ERE semantics. Literal case-insensitive matching is the default; results are bounded and require an active SSH2 connection.",
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
pattern: { type: "string", description: "Text or regular expression to search for" }, pattern: { type: "string", description: "Text to search for, or a portable POSIX ERE when literal is false" },
path: { type: "string", description: "Remote root path; defaults to the active remote cwd" }, path: { type: "string", description: "Remote root path; defaults to the active remote cwd" },
literal: { type: "boolean", description: "Treat pattern as fixed text (default: true)" }, literal: { type: "boolean", description: "Treat pattern as fixed text; false uses portable POSIX ERE syntax (default: true)" },
caseSensitive: { type: "boolean", description: "Use case-sensitive matching (default: false)" }, caseSensitive: { type: "boolean", description: "Use case-sensitive matching (default: false)" },
include: { type: "string", description: "Optional file glob such as *.ts" }, include: { type: "string", description: "Optional basename-only glob such as *.ts; / is not allowed" },
includeHidden: { type: "boolean", description: "Search hidden files and directories (default: false)" },
limit: { type: "integer", minimum: 1, maximum: 200, description: "Maximum result lines (default: 50)" }, limit: { type: "integer", minimum: 1, maximum: 200, description: "Maximum result lines (default: 50)" },
}, },
required: ["pattern"], required: ["pattern"],
@@ -321,11 +335,15 @@ export default function piSshExtension(pi: ExtensionAPI): void {
...localBash, ...localBash,
name: "ssh_bash", name: "ssh_bash",
label: "ssh_bash", label: "ssh_bash",
description: `Run a shell command through the active SSH2 connection. ${localBash.description}`, description: "Run a non-search shell command on the active SSH server. Every call starts a fresh non-interactive Bash process in the active remote cwd; cd, exported variables, aliases, and other shell state do not persist. For a persistent workspace change, call ssh_cd separately and wait for success before calling ssh_bash. Use command-local cd only for an intentionally temporary directory change. Relative paths are remote. Use ssh_find or ssh_grep instead of find, fd, grep, or rg.",
promptSnippet: undefined,
promptGuidelines: undefined,
async execute(id, params, signal, onUpdate) { async execute(id, params, signal, onUpdate) {
const active = requireSsh("ssh_bash"); const active = requireSsh("ssh_bash");
return createBashTool(localCwd, { operations: createRemoteBashOps(active.transport) }) return createBashTool(active.connection.remoteCwd, {
.execute(id, params, signal, onUpdate); operations: createRemoteBashOps(active.connection, active.transport),
exposeSessionEnvironment: false,
}).execute(id, params, signal, onUpdate);
}, },
}); });
@@ -341,9 +359,11 @@ export default function piSshExtension(pi: ExtensionAPI): void {
`\n\n# Remote SSH connection\n\n` + `\n\n# Remote SSH connection\n\n` +
`An SSH2 connection to configured host ${connection.remote} on port ${connection.port} is active. ` + `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. ` + `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_cd, ssh_read, ssh_write, ssh_edit, ssh_find, ssh_grep, and ssh_bash for explicit remote operations. ` +
`Use ssh_find before ssh_grep to narrow remote searches; both tools return bounded results and choose the fastest available remote backend. ` + `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.`; `Remote operations are rooted at ${connection.remoteCwd}; relative paths resolve against that directory. ` +
`For a persistent workspace change, call ssh_cd as a separate step and wait for its successful result before issuing dependent remote tool calls. ` +
`Each ssh_bash call starts a fresh non-interactive shell, so use command-local cd only for an intentionally temporary change; cd, exports, aliases, and other shell state inside one command do not persist to the next call.`;
return { systemPrompt: `${event.systemPrompt}${guidance}` }; return { systemPrompt: `${event.systemPrompt}${guidance}` };
}); });
} }
+30 -7
View File
@@ -1,10 +1,12 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type { PermissionsService } from "@gotgenes/pi-permission-system"; import type { PermissionsService } from "@gotgenes/pi-permission-system";
import { resolveRemoteCwd } from "./src/remote-cwd.ts";
export interface SshPermissionConnection { export interface SshPermissionConnection {
remote: string; remote: string;
port?: number; port?: number;
remoteCwd: string; remoteCwd: string;
remoteHome?: string;
} }
export interface SshPermissionIntegrationDependencies { export interface SshPermissionIntegrationDependencies {
@@ -17,8 +19,8 @@ export interface SshPermissionIntegrationDependencies {
type PermissionIntegrationApi = Pick<ExtensionAPI, "events" | "on">; type PermissionIntegrationApi = Pick<ExtensionAPI, "events" | "on">;
type ToolInput = Record<string, unknown>; type ToolInput = Record<string, unknown>;
const REMOTE_FILE_TOOLS = ["ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"] as const; const REMOTE_PATH_TOOLS = ["ssh_cd", "ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"] as const;
const REMOTE_TOOLS = ["ssh_connect", ...REMOTE_FILE_TOOLS, "ssh_bash"] as const; const REMOTE_TOOLS = ["ssh_connect", ...REMOTE_PATH_TOOLS, "ssh_bash"] as const;
function inline(value: string, limit = 240): string { function inline(value: string, limit = 240): string {
const normalized = value.replace(/\s+/g, " ").trim(); const normalized = value.replace(/\s+/g, " ").trim();
@@ -40,6 +42,23 @@ function formatTarget(connection: SshPermissionConnection | null): string {
return `SSH target '${inline(connection.remote)}${port}' in remote cwd '${inline(connection.remoteCwd)}'`; return `SSH target '${inline(connection.remote)}${port}' in remote cwd '${inline(connection.remoteCwd)}'`;
} }
function remotePathDetail(
requested: string,
connection: SshPermissionConnection | null,
label = "remote path",
): string {
let resolved: string | undefined;
if (connection?.remoteHome) {
try {
resolved = resolveRemoteCwd(requested, connection.remoteCwd, connection.remoteHome);
} catch {
// Execution performs authoritative validation; preserve the raw request.
}
}
if (!resolved || resolved === requested) return `${label} '${inline(requested)}'`;
return `${label} '${inline(resolved)}' (requested '${inline(requested)}')`;
}
export function formatSshPermissionInput( export function formatSshPermissionInput(
toolName: string, toolName: string,
input: ToolInput, input: ToolInput,
@@ -55,8 +74,12 @@ export function formatSshPermissionInput(
return `requested imported SSH host '${hostId}'${remotePath ? ` in remote cwd '${inline(remotePath)}'` : ""}; establish a persistent SSH2 connection`; return `requested imported SSH host '${hostId}'${remotePath ? ` in remote cwd '${inline(remotePath)}'` : ""}; establish a persistent SSH2 connection`;
} }
if (toolName === "ssh_cd") {
return `${target}; change the active ${path ? remotePathDetail(path, connection, "remote cwd to") : "remote cwd to '<unspecified>'"}`;
}
if (toolName === "ssh_read") { if (toolName === "ssh_read") {
const details = path ? [`remote path '${inline(path)}'`] : ["an unspecified remote path"]; const details = path ? [remotePathDetail(path, connection)] : ["an unspecified remote path"];
if (typeof input.offset === "number") details.push(`offset ${input.offset}`); if (typeof input.offset === "number") details.push(`offset ${input.offset}`);
if (typeof input.limit === "number") details.push(`limit ${input.limit}`); if (typeof input.limit === "number") details.push(`limit ${input.limit}`);
return `${target}; read ${details.join(", ")}`; return `${target}; read ${details.join(", ")}`;
@@ -64,20 +87,20 @@ export function formatSshPermissionInput(
if (toolName === "ssh_write") { if (toolName === "ssh_write") {
const content = stringField(input, "content") ?? ""; const content = stringField(input, "content") ?? "";
return `${target}; write remote path '${inline(path ?? "<unspecified>")}' (${countLines(content)} lines, ${content.length} characters)`; return `${target}; write ${path ? remotePathDetail(path, connection) : "remote path '<unspecified>'"} (${countLines(content)} lines, ${content.length} characters)`;
} }
if (toolName === "ssh_edit") { if (toolName === "ssh_edit") {
const oldText = stringField(input, "oldText") ?? ""; const oldText = stringField(input, "oldText") ?? "";
const newText = stringField(input, "newText") ?? ""; const newText = stringField(input, "newText") ?? "";
return `${target}; edit remote path '${inline(path ?? "<unspecified>")}' (replace ${countLines(oldText)} lines with ${countLines(newText)} lines)`; return `${target}; edit ${path ? remotePathDetail(path, connection) : "remote path '<unspecified>'"} (replace ${countLines(oldText)} lines with ${countLines(newText)} lines)`;
} }
if (toolName === "ssh_find" || toolName === "ssh_grep") { if (toolName === "ssh_find" || toolName === "ssh_grep") {
const pattern = inline(stringField(input, "pattern") ?? "<unspecified>"); const pattern = inline(stringField(input, "pattern") ?? "<unspecified>");
const operation = toolName === "ssh_find" ? "find remote files" : "search remote file contents"; const operation = toolName === "ssh_find" ? "find remote files" : "search remote file contents";
const details = [ const details = [
`under '${inline(path ?? ".")}'`, remotePathDetail(path ?? ".", connection, "under"),
`for '${pattern}'`, `for '${pattern}'`,
`limit ${typeof input.limit === "number" ? input.limit : 50}`, `limit ${typeof input.limit === "number" ? input.limit : 50}`,
]; ];
@@ -140,7 +163,7 @@ export function installSshPermissionIntegration(
}), }),
); );
} }
for (const toolName of REMOTE_FILE_TOOLS) { for (const toolName of REMOTE_PATH_TOOLS) {
pending.push(service.registerToolAccessExtractor(toolName, () => undefined)); pending.push(service.registerToolAccessExtractor(toolName, () => undefined));
} }
disposers = pending; disposers = pending;
+3 -4
View File
@@ -10,6 +10,7 @@ import {
} from "../src/import.ts"; } from "../src/import.ts";
import { expandUserPath } from "../src/config.ts"; import { expandUserPath } from "../src/config.ts";
import { probeHostKey, Ssh2Transport } from "../src/ssh2-transport.ts"; import { probeHostKey, Ssh2Transport } from "../src/ssh2-transport.ts";
import { probeRemotePath } from "../src/remote-probe.ts";
import { loadVaultOrEmpty, rotateVaultKey, saveVault } from "../src/vault.ts"; import { loadVaultOrEmpty, rotateVaultKey, saveVault } from "../src/vault.ts";
async function question(prompt) { async function question(prompt) {
@@ -136,10 +137,8 @@ async function importHost(config, alias) {
try { try {
console.log("Testing SSH2 authentication..."); console.log("Testing SSH2 authentication...");
await transport.connect(); await transport.connect();
const result = await transport.capture('printf "%s\\n%s" "$HOME" "$(pwd)"', ".", 20); await probeRemotePath(transport, "home");
if (result.exitCode !== 0) throw new Error("remote HOME/cwd probe failed"); const remoteCwd = await probeRemotePath(transport, "cwd");
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; host.defaultCwd = remoteCwd;
console.log(`Connected successfully; default cwd: ${remoteCwd}`); console.log(`Connected successfully; default cwd: ${remoteCwd}`);
} finally { } finally {
+17 -1
View File
@@ -1,3 +1,5 @@
import type { PiSshConfig, SshHostConfig } from "./config.ts";
export interface HostSelection { export interface HostSelection {
hostId: string; hostId: string;
remotePath?: string; remotePath?: string;
@@ -6,7 +8,7 @@ export interface HostSelection {
export const SSH_CONNECT_TOOL_METADATA = { export const SSH_CONNECT_TOOL_METADATA = {
name: "ssh_connect", name: "ssh_connect",
label: "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.", description: "Establish a persistent SSH2 connection to an explicitly imported host as a sequential state transition. Use this as a separate step when the user names a remote server as part of a concrete task, and wait for success before calling dependent ssh_* tools; the connection request is reviewed before any network connection is opened.",
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
@@ -16,6 +18,7 @@ export const SSH_CONNECT_TOOL_METADATA = {
required: ["hostId"], required: ["hostId"],
additionalProperties: false, additionalProperties: false,
}, },
executionMode: "sequential",
} as const; } as const;
export function parseConnectInput(input: Record<string, unknown>): HostSelection { export function parseConnectInput(input: Record<string, unknown>): HostSelection {
@@ -31,3 +34,16 @@ export function parseConnectInput(input: Record<string, unknown>): HostSelection
} }
return { hostId, remotePath }; return { hostId, remotePath };
} }
export function getConfiguredHost(config: PiSshConfig, hostId: string): SshHostConfig {
const host = config.hosts[hostId];
if (host) return host;
const hostIds = Object.keys(config.hosts).sort();
if (hostIds.length === 0) {
throw new Error(`unknown pi-ssh host '${hostId}'; no hosts are imported; run ssh_config.sh import <alias>`);
}
const shown = hostIds.slice(0, 10);
const remaining = hostIds.length - shown.length;
const available = `${shown.join(", ")}${remaining > 0 ? `, … (+${remaining} more)` : ""}`;
throw new Error(`unknown pi-ssh host '${hostId}'; available imported host IDs: ${available}`);
}
+27
View File
@@ -0,0 +1,27 @@
import type { BashOperations } from "@earendil-works/pi-coding-agent";
import type { RemoteTransport } from "./ssh2-transport.ts";
export interface RemoteBashConnection {
remoteCwd: string;
}
/**
* Adapt Pi's Bash output machinery to the active remote workspace.
*
* The cwd supplied by Pi's Bash factory is deliberately ignored: it belongs
* to the factory's filesystem namespace and must never leak into SSH command
* execution. The connection's mutable remoteCwd is the sole shell base.
*/
export function createRemoteBashOps(
connection: RemoteBashConnection,
transport: RemoteTransport,
): BashOperations {
return {
exec: (command, _factoryCwd, { onData, signal, timeout }) => {
if (!connection.remoteCwd.startsWith("/")) {
throw new Error(`ssh_bash requires an absolute remote cwd, received '${connection.remoteCwd}'`);
}
return transport.exec(command, connection.remoteCwd, { onData, signal, timeout });
},
};
}
+59
View File
@@ -0,0 +1,59 @@
import { posix as posixPath } from "node:path";
export const SSH_CD_EXECUTION_MODE = "sequential" as const;
export interface RemoteWorkspaceConnection {
remoteCwd: string;
remoteHome: string;
}
export interface RemotePathMappingConnection extends RemoteWorkspaceConnection {
localCwd: string;
localHome: string;
}
export interface RemoteCwdChange {
previousCwd: string;
remoteCwd: string;
}
export function resolveRemoteCwd(path: string, currentCwd: string, remoteHome: string): string {
if (typeof path !== "string" || path.length === 0) {
throw new Error("path must be a non-empty string");
}
if (/[\0\r\n]/u.test(path)) {
throw new Error("path must not contain NUL or newline characters");
}
if (!currentCwd.startsWith("/") || !remoteHome.startsWith("/")) {
throw new Error("the active SSH connection has an invalid remote workspace");
}
if (path === "~") return posixPath.normalize(remoteHome);
if (path.startsWith("~/")) return posixPath.normalize(posixPath.join(remoteHome, path.slice(2)));
if (path.startsWith("~")) throw new Error("path supports only '~' or '~/' home expansion");
if (path.startsWith("/")) return posixPath.normalize(path);
return posixPath.normalize(posixPath.join(currentCwd, path));
}
export async function changeRemoteCwd(
connection: RemoteWorkspaceConnection,
path: string,
verifyDirectory: (requestedCwd: string) => Promise<string>,
): Promise<RemoteCwdChange> {
const previousCwd = connection.remoteCwd;
const requestedCwd = resolveRemoteCwd(path, previousCwd, connection.remoteHome);
const remoteCwd = await verifyDirectory(requestedCwd);
connection.remoteCwd = remoteCwd;
return { previousCwd, remoteCwd };
}
export function mapLocalPathToRemote(path: string, connection: RemotePathMappingConnection): string {
if (path === connection.localCwd) return connection.remoteCwd;
if (path.startsWith(`${connection.localCwd}/`)) {
return `${connection.remoteCwd}${path.slice(connection.localCwd.length)}`;
}
if (path === connection.localHome) return connection.remoteHome;
if (path.startsWith(`${connection.localHome}/`)) {
return `${connection.remoteHome}${path.slice(connection.localHome.length)}`;
}
return path;
}
+70
View File
@@ -0,0 +1,70 @@
import { randomBytes } from "node:crypto";
import { posix as posixPath } from "node:path";
import type { RemoteTransport } from "./ssh2-transport.ts";
const PROBE_TIMEOUT_SECONDS = 20;
const MAX_PROBE_OUTPUT_BYTES = 64 * 1024;
export type RemotePathProbe = "home" | "cwd";
function probeCommand(kind: RemotePathProbe, token: string): { command: string; start: string; end: string } {
const start = `__PI_SSH_PROBE_${token}_START__`;
const end = `__PI_SSH_PROBE_${token}_END__`;
const assign = kind === "home"
? "pi_ssh_probe_value=$HOME"
: "pi_ssh_probe_value=$(pwd -P) || exit $?";
return {
command: `${assign}\nprintf '%s%s%s' '${start}' "$pi_ssh_probe_value" '${end}'`,
start,
end,
};
}
export function parseRemotePathProbe(output: Buffer, start: string, end: string, kind: RemotePathProbe): string {
if (output.length > MAX_PROBE_OUTPUT_BYTES) {
throw new Error(`remote ${kind} probe output exceeded ${MAX_PROBE_OUTPUT_BYTES} bytes`);
}
const text = output.toString("utf8");
const startIndex = text.indexOf(start);
const endIndex = startIndex < 0 ? -1 : text.indexOf(end, startIndex + start.length);
if (startIndex < 0 || endIndex < 0 || text.indexOf(start, startIndex + start.length) >= 0) {
throw new Error(`remote ${kind} probe returned an invalid framed response`);
}
const value = text.slice(startIndex + start.length, endIndex);
if (!value.startsWith("/") || /[\0\r\n]/u.test(value)) {
throw new Error(`remote ${kind} probe did not return one absolute POSIX path`);
}
return posixPath.normalize(value);
}
export async function probeRemotePath(
transport: RemoteTransport,
kind: RemotePathProbe,
cwd = ".",
signal?: AbortSignal,
): Promise<string> {
const token = randomBytes(12).toString("hex");
const probe = probeCommand(kind, token);
const chunks: Buffer[] = [];
let captured = 0;
let overflow = false;
const result = await transport.exec(probe.command, cwd, {
signal,
timeout: PROBE_TIMEOUT_SECONDS,
onData(data) {
if (captured + data.length > MAX_PROBE_OUTPUT_BYTES) {
overflow = true;
return;
}
chunks.push(data);
captured += data.length;
},
});
const output = Buffer.concat(chunks);
if (overflow) throw new Error(`remote ${kind} probe output exceeded ${MAX_PROBE_OUTPUT_BYTES} bytes`);
if (result.exitCode !== 0) {
const detail = output.toString("utf8").replace(/\s+/gu, " ").trim().slice(0, 300);
throw new Error(`remote ${kind} probe failed${result.exitCode === null ? "" : ` with exit code ${result.exitCode}`}${detail ? `: ${detail}` : ""}`);
}
return parseRemotePathProbe(output, probe.start, probe.end, kind);
}
+143 -46
View File
@@ -15,6 +15,7 @@ export interface RemoteGrepInput {
literal?: boolean; literal?: boolean;
caseSensitive?: boolean; caseSensitive?: boolean;
include?: string; include?: string;
includeHidden?: boolean;
limit?: number; limit?: number;
} }
@@ -30,6 +31,8 @@ const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200; const MAX_LIMIT = 200;
const MAX_LINE_CHARS = 800; const MAX_LINE_CHARS = 800;
const MAX_CAPTURE_CHARS = 512_000; const MAX_CAPTURE_CHARS = 512_000;
const SEARCH_TIMEOUT_SECONDS = 30;
const MAX_DIAGNOSTIC_CHARS = 16_000;
function shellQuote(value: string): string { function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`; return `'${value.replace(/'/g, `'"'"'`)}'`;
@@ -55,6 +58,7 @@ export function resolveRemoteSearchPath(path: string | undefined, remoteCwd: str
validateField(value, "path"); validateField(value, "path");
if (value === "~") return remoteHome; if (value === "~") return remoteHome;
if (value.startsWith("~/")) return posixPath.normalize(posixPath.join(remoteHome, value.slice(2))); if (value.startsWith("~/")) return posixPath.normalize(posixPath.join(remoteHome, value.slice(2)));
if (value.startsWith("~")) throw new Error("path supports only ~ or ~/... remote HOME expansion");
if (value.startsWith("/")) return posixPath.normalize(value); if (value.startsWith("/")) return posixPath.normalize(value);
return posixPath.normalize(posixPath.join(remoteCwd, value)); return posixPath.normalize(posixPath.join(remoteCwd, value));
} }
@@ -70,6 +74,27 @@ const STATUS_HELPER = [
`}`, `}`,
].join("\n"); ].join("\n");
const GREP_RECORD_HELPER = [
`pi_ssh_limit_colon_records() {`,
` local path match line content count=0`,
` while IFS= read -r -d '' path && IFS= read -r match; do`,
' line="${match%%:*}"',
' content="${match#*:}"',
' printf \'%s\\0%s\\0%s\\0\' "${path:0:' + MAX_LINE_CHARS + '}" "$line" "${content:0:' + MAX_LINE_CHARS + '}"',
` count=$((count + 1))`,
` if [ "$count" -ge "$PI_SSH_TAKE" ]; then return 0; fi`,
` done`,
`}`,
`pi_ssh_limit_git_records() {`,
` local path line content count=0`,
` while IFS= read -r -d '' path && IFS= read -r -d '' line && IFS= read -r content; do`,
' printf \'%s\\0%s\\0%s\\0\' "${path:0:' + MAX_LINE_CHARS + '}" "$line" "${content:0:' + MAX_LINE_CHARS + '}"',
` count=$((count + 1))`,
` if [ "$count" -ge "$PI_SSH_TAKE" ]; then return 0; fi`,
` done`,
`}`,
] .join("\n");
function findPipeline(input: RemoteFindInput, root: string, limit: number): string { function findPipeline(input: RemoteFindInput, root: string, limit: number): string {
const pattern = validateField(input.pattern, "pattern") as string; const pattern = validateField(input.pattern, "pattern") as string;
const fdCase = input.caseSensitive ? "--case-sensitive" : "--ignore-case"; const fdCase = input.caseSensitive ? "--case-sensitive" : "--ignore-case";
@@ -81,24 +106,24 @@ function findPipeline(input: RemoteFindInput, root: string, limit: number): stri
STATUS_HELPER, STATUS_HELPER,
`if command -v fd >/dev/null 2>&1; then`, `if command -v fd >/dev/null 2>&1; then`,
` printf '${MARKER}fd\\n'`, ` 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}`, ` fd --type f --color never --fixed-strings ${fdCase} ${fdHidden} --exclude .git --exclude node_modules -- ${shellQuote(pattern)} ${shellQuote(root)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
' statuses=("${PIPESTATUS[@]}")', ' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?', ' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
`elif command -v fdfind >/dev/null 2>&1; then`, `elif command -v fdfind >/dev/null 2>&1; then`,
` printf '${MARKER}fdfind\\n'`, ` 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}`, ` fdfind --type f --color never --fixed-strings ${fdCase} ${fdHidden} --exclude .git --exclude node_modules -- ${shellQuote(pattern)} ${shellQuote(root)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
' statuses=("${PIPESTATUS[@]}")', ' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?', ' 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`, `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'`, ` 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}`, ` git -C ${shellQuote(root)} ls-files -co --exclude-standard | ${hiddenFilter} | grep -F ${grepCase} -- ${shellQuote(pattern)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
' statuses=("${PIPESTATUS[@]}")', ' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?', ' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
' pi_ssh_accept_status "${statuses[1]}" 0 1 141 || exit $?', ' pi_ssh_accept_status "${statuses[1]}" 0 1 141 || exit $?',
' pi_ssh_accept_status "${statuses[2]}" 0 1 141 || exit $?', ' pi_ssh_accept_status "${statuses[2]}" 0 1 141 || exit $?',
`else`, `else`,
` printf '${MARKER}find\\n'`, ` 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}`, ` find ${shellQuote(root)} -type f ! -path '*/.git/*' ! -path '*/node_modules/*' | ${hiddenFilter} | grep -F ${grepCase} -- ${shellQuote(pattern)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
' statuses=("${PIPESTATUS[@]}")', ' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?', ' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
' pi_ssh_accept_status "${statuses[1]}" 0 1 141 || exit $?', ' pi_ssh_accept_status "${statuses[1]}" 0 1 141 || exit $?',
@@ -110,29 +135,64 @@ function findPipeline(input: RemoteFindInput, root: string, limit: number): stri
function grepPipeline(input: RemoteGrepInput, root: string, limit: number): string { function grepPipeline(input: RemoteGrepInput, root: string, limit: number): string {
const pattern = validateField(input.pattern, "pattern") as string; const pattern = validateField(input.pattern, "pattern") as string;
const include = validateField(input.include, "include", true); const include = validateField(input.include, "include", true);
const fixed = input.literal === false ? "" : "-F"; if (include?.includes("/")) throw new Error("include must be a basename glob without /");
const rgMode = input.literal === false ? "" : "-F";
const grepMode = input.literal === false ? "-E" : "-F";
const insensitive = input.caseSensitive ? "" : "-i"; const insensitive = input.caseSensitive ? "" : "-i";
const rgHidden = input.includeHidden
? "--hidden"
: "--glob '!.*' --glob '!**/.*' --glob '!**/.*/**'";
const rgGlob = include ? `-g ${shellQuote(include)}` : ""; const rgGlob = include ? `-g ${shellQuote(include)}` : "";
const gitPath = include ? `-- ${shellQuote(include)}` : ""; const gitPathspecs = [
...(include ? [`:(glob)**/${include}`] : []),
...(input.includeHidden ? [] : [":(exclude,glob)**/.*", ":(exclude,glob)**/.*/**"]),
];
const gitPath = gitPathspecs.length > 0 ? `-- ${gitPathspecs.map(shellQuote).join(" ")}` : "";
const findName = include ? `-name ${shellQuote(include)}` : ""; const findName = include ? `-name ${shellQuote(include)}` : "";
const includeHidden = input.includeHidden ? 1 : 0;
const take = limit + 1; const take = limit + 1;
return [ return [
STATUS_HELPER, STATUS_HELPER,
GREP_RECORD_HELPER,
`PI_SSH_TAKE=${take}`,
`PI_SSH_ROOT=${shellQuote(root)}`,
`PI_SSH_INCLUDE_HIDDEN=${includeHidden}`,
`if command -v rg >/dev/null 2>&1; then`, `if command -v rg >/dev/null 2>&1; then`,
` printf '${MARKER}ripgrep\\n'`, ` 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}`, ` rg --null --line-number --no-heading --color never --with-filename --max-columns ${MAX_LINE_CHARS} --max-columns-preview ${rgMode} ${insensitive} ${rgHidden} ${rgGlob} --glob '!.git/**' --glob '!node_modules/**' -- ${shellQuote(pattern)} ${shellQuote(root)} | pi_ssh_limit_colon_records`,
' statuses=("${PIPESTATUS[@]}")', ' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 1 141 || exit $?', ' pi_ssh_accept_status "${statuses[0]}" 0 1 141 || exit $?',
' pi_ssh_accept_status "${statuses[1]}" 0 || exit $?',
`elif command -v git >/dev/null 2>&1 && git -C ${shellQuote(root)} rev-parse --is-inside-work-tree >/dev/null 2>&1; then`, `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'`, ` 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}`, ` git -C ${shellQuote(root)} grep --untracked --exclude-standard -z -n -I ${grepMode} ${insensitive} -e ${shellQuote(pattern)} ${gitPath} | pi_ssh_limit_git_records`,
' statuses=("${PIPESTATUS[@]}")', ' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 1 141 || exit $?', ' pi_ssh_accept_status "${statuses[0]}" 0 1 141 || exit $?',
' pi_ssh_accept_status "${statuses[1]}" 0 || exit $?',
`else`, `else`,
` printf '${MARKER}grep\\n'`, ` 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}`, ` find ${shellQuote(root)} -type f ! -path '*/.git/*' ! -path '*/node_modules/*' ${findName} -print0 | (`,
` while IFS= read -r -d '' file; do`,
` if [ "$PI_SSH_INCLUDE_HIDDEN" -eq 0 ]; then`,
` if [ -d "$PI_SSH_ROOT" ]; then`,
' relative=${file#"$PI_SSH_ROOT"/}',
` else`,
' relative=${file##*/}',
` fi`,
' case "$relative" in .*|*/.*) continue ;; esac',
` fi`,
` grep -n -I ${grepMode} ${insensitive} -- ${shellQuote(pattern)} "$file" | while IFS= read -r match; do`,
` printf '%s\\0%s\\n' "$file" "$match"`,
` done`,
' grep_statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${grep_statuses[0]}" 0 1 141 || exit $?',
' pi_ssh_accept_status "${grep_statuses[1]}" 0 1 141 || exit $?',
` done`,
` ) | pi_ssh_limit_colon_records`,
' statuses=("${PIPESTATUS[@]}")', ' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?', ' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
' pi_ssh_accept_status "${statuses[1]}" 0 141 || exit $?',
' pi_ssh_accept_status "${statuses[2]}" 0 || exit $?',
`fi`, `fi`,
].join("\n"); ].join("\n");
} }
@@ -149,30 +209,54 @@ export function buildRemoteGrepCommand(input: RemoteGrepInput, root: string): {
return { command: grepPipeline(input, root, limit), limit }; return { command: grepPipeline(input, root, limit), limit };
} }
function prefixGitPath(line: string, root: string, grep: boolean): string { function prefixGitPath(path: string, root: string): string {
if (line.startsWith("/") || line.startsWith("../")) return line; return path.startsWith("/") || path.startsWith("../") ? path : posixPath.join(root, path);
if (!grep) return posixPath.join(root, line); }
const separator = line.indexOf(":");
if (separator < 1) return line; function visibleField(value: string): string {
return `${posixPath.join(root, line.slice(0, separator))}${line.slice(separator)}`; return value.replace(/\r/gu, "\\r").replace(/\n/gu, "\\n");
}
function normalizeDiagnostic(raw: Buffer): string {
return raw
.toString("utf8")
.replace(/\0/gu, " ")
.replace(new RegExp(`${MARKER}[^\\n]*`, "gu"), "")
.replace(/\s+/gu, " ")
.trim()
.slice(0, 300);
} }
export function formatRemoteSearchOutput( export function formatRemoteSearchOutput(
raw: string, raw: string | Buffer,
root: string, root: string,
limit: number, limit: number,
kind: "find" | "grep", kind: "find" | "grep",
): RemoteSearchResult { ): RemoteSearchResult {
const lines = raw.replace(/\r\n?/gu, "\n").split("\n"); const output = Buffer.isBuffer(raw) ? raw.toString("utf8") : raw;
const markerIndex = lines.findIndex((line) => line.startsWith(MARKER)); const markerIndex = output.indexOf(MARKER);
if (markerIndex < 0) throw new Error(`remote ${kind} did not report a search backend`); const markerEnd = markerIndex < 0 ? -1 : output.indexOf("\n", markerIndex);
const backend = lines[markerIndex].slice(MARKER.length).trim() || "unknown"; if (markerIndex < 0 || markerEnd < 0) throw new Error(`remote ${kind} did not report a search backend`);
const sourceRows = lines.slice(markerIndex + 1).filter((line) => line.length > 0); const backend = output.slice(markerIndex + MARKER.length, markerEnd).trim() || "unknown";
const payload = output.slice(markerEnd + 1);
let sourceRows: string[];
if (kind === "grep") {
const fields = payload.length === 0 ? [] : payload.split("\0");
if (fields.at(-1) === "") fields.pop();
if (fields.length % 3 !== 0) throw new Error("remote grep returned a malformed or truncated NUL-delimited result");
sourceRows = [];
for (let index = 0; index < fields.length; index += 3) {
const path = backend.startsWith("git-") ? prefixGitPath(fields[index], root) : fields[index];
sourceRows.push(`${visibleField(path)}:${visibleField(fields[index + 1])}:${visibleField(fields[index + 2])}`);
}
} else {
sourceRows = payload.replace(/\r\n?/gu, "\n").split("\n").filter((line) => line.length > 0);
if (backend.startsWith("git-")) sourceRows = sourceRows.map((line) => prefixGitPath(line, root));
}
const truncated = sourceRows.length > limit; const truncated = sourceRows.length > limit;
const rows = sourceRows.slice(0, limit).map((line) => { const rows = sourceRows.slice(0, limit).map((line) =>
const normalized = backend.startsWith("git-") ? prefixGitPath(line, root, kind === "grep") : line; line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS - 1)}` : 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"})`; const header = `Remote ${kind}: ${rows.length} result${rows.length === 1 ? "" : "s"} (backend: ${backend}, root: ${root}, truncated: ${truncated ? "yes" : "no"})`;
return { return {
text: rows.length > 0 ? `${header}\n\n${rows.join("\n")}` : `${header}\n\nNo matches found.`, text: rows.length > 0 ? `${header}\n\n${rows.join("\n")}` : `${header}\n\nNo matches found.`,
@@ -185,37 +269,50 @@ export function formatRemoteSearchOutput(
async function runRemoteSearch( async function runRemoteSearch(
transport: RemoteTransport, transport: RemoteTransport,
command: string, command: string,
executionCwd: string,
root: string, root: string,
limit: number, limit: number,
kind: "find" | "grep", kind: "find" | "grep",
signal?: AbortSignal, signal?: AbortSignal,
): Promise<RemoteSearchResult> { ): Promise<RemoteSearchResult> {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let captured = 0; let captured = 0;
const result = await transport.exec(command, root, { let stderrCaptured = 0;
signal, const capture = (target: Buffer[], data: Buffer, stderr = false) => {
timeout: 30, const current = stderr ? stderrCaptured : captured;
onData(data) { const maximum = stderr ? MAX_DIAGNOSTIC_CHARS : MAX_CAPTURE_CHARS;
if (captured >= MAX_CAPTURE_CHARS) return; if (current >= maximum) return;
const remaining = MAX_CAPTURE_CHARS - captured; const remaining = maximum - current;
const chunk = data.length > remaining ? data.subarray(0, remaining) : data; const chunk = data.length > remaining ? data.subarray(0, remaining) : data;
chunks.push(chunk); target.push(chunk);
captured += chunk.length; if (stderr) stderrCaptured += chunk.length;
}, else captured += chunk.length;
};
let result: { exitCode: number | null };
try {
result = await transport.exec(command, executionCwd, {
signal,
timeout: SEARCH_TIMEOUT_SECONDS,
onData(data) { capture(chunks, data); },
onStderr(data) { capture(stderrChunks, data, true); },
}); });
const output = Buffer.concat(chunks).toString("utf8"); } catch (error) {
if (!signal?.aborted && /timed out/iu.test(error instanceof Error ? error.message : String(error))) {
throw new Error(`remote ${kind} timed out after ${SEARCH_TIMEOUT_SECONDS}s (root: ${root}); narrow the remote path with ssh_find before retrying`);
}
throw error;
}
const output = Buffer.concat(chunks);
const stderr = Buffer.concat(stderrChunks);
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
const detail = output const detail = normalizeDiagnostic(stderr.length > 0 ? stderr : 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}` : ""}`); throw new Error(`remote ${kind} failed${result.exitCode === null ? "" : ` with exit code ${result.exitCode}`}${detail ? `: ${detail}` : ""}`);
} }
return formatRemoteSearchOutput(output, root, limit, kind); const formatted = formatRemoteSearchOutput(output, root, limit, kind);
const warning = normalizeDiagnostic(stderr);
if (warning) formatted.text += `\n\nRemote warning: ${warning}`;
return formatted;
} }
export function runRemoteFind( export function runRemoteFind(
@@ -227,7 +324,7 @@ export function runRemoteFind(
): Promise<RemoteSearchResult> { ): Promise<RemoteSearchResult> {
const root = resolveRemoteSearchPath(input.path, remoteCwd, remoteHome); const root = resolveRemoteSearchPath(input.path, remoteCwd, remoteHome);
const built = buildRemoteFindCommand(input, root); const built = buildRemoteFindCommand(input, root);
return runRemoteSearch(transport, built.command, root, built.limit, "find", signal); return runRemoteSearch(transport, built.command, remoteCwd, root, built.limit, "find", signal);
} }
export function runRemoteGrep( export function runRemoteGrep(
@@ -239,5 +336,5 @@ export function runRemoteGrep(
): Promise<RemoteSearchResult> { ): Promise<RemoteSearchResult> {
const root = resolveRemoteSearchPath(input.path, remoteCwd, remoteHome); const root = resolveRemoteSearchPath(input.path, remoteCwd, remoteHome);
const built = buildRemoteGrepCommand(input, root); const built = buildRemoteGrepCommand(input, root);
return runRemoteSearch(transport, built.command, root, built.limit, "grep", signal); return runRemoteSearch(transport, built.command, remoteCwd, root, built.limit, "grep", signal);
} }
+93 -10
View File
@@ -7,12 +7,13 @@ import { expandUserPath, type SshHostConfig } from "./config.ts";
export interface RemoteExecOptions { export interface RemoteExecOptions {
onData: (data: Buffer) => void; onData: (data: Buffer) => void;
onStderr?: (data: Buffer) => void;
signal?: AbortSignal; signal?: AbortSignal;
timeout?: number; timeout?: number;
} }
export interface RemoteTransport { export interface RemoteTransport {
connect(): Promise<void>; connect(signal?: AbortSignal): Promise<void>;
dispose(): Promise<void>; dispose(): Promise<void>;
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }>; exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }>;
capture(command: string, cwd?: string, timeout?: number): Promise<{ exitCode: number | null; output: Buffer }>; capture(command: string, cwd?: string, timeout?: number): Promise<{ exitCode: number | null; output: Buffer }>;
@@ -36,6 +37,66 @@ class CommandQueue {
} }
} }
interface SemaphoreWaiter {
resolve: (release: () => void) => void;
reject: (error: Error) => void;
signal?: AbortSignal;
onAbort?: () => void;
}
export class AsyncSemaphore {
private active = 0;
private readonly waiters: SemaphoreWaiter[] = [];
private readonly limit: number;
constructor(limit: number) {
if (!Number.isInteger(limit) || limit < 1) throw new Error("semaphore limit must be a positive integer");
this.limit = limit;
}
acquire(signal?: AbortSignal): Promise<() => void> {
if (signal?.aborted) return Promise.reject(new Error("SSH command aborted"));
if (this.active < this.limit) {
this.active += 1;
return Promise.resolve(this.createRelease());
}
return new Promise((resolve, reject) => {
const waiter: SemaphoreWaiter = { resolve, reject, signal };
waiter.onAbort = () => {
const index = this.waiters.indexOf(waiter);
if (index >= 0) this.waiters.splice(index, 1);
reject(new Error("SSH command aborted"));
};
signal?.addEventListener("abort", waiter.onAbort, { once: true });
this.waiters.push(waiter);
});
}
private createRelease(): () => void {
let released = false;
return () => {
if (released) return;
released = true;
this.active -= 1;
this.dispatch();
};
}
private dispatch(): void {
while (this.active < this.limit) {
const waiter = this.waiters.shift();
if (!waiter) return;
if (waiter.onAbort) waiter.signal?.removeEventListener("abort", waiter.onAbort);
if (waiter.signal?.aborted) {
waiter.reject(new Error("SSH command aborted"));
continue;
}
this.active += 1;
waiter.resolve(this.createRelease());
}
}
}
function shellQuote(value: string): string { function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`; return `'${value.replace(/'/g, `'"'"'`)}'`;
} }
@@ -80,7 +141,8 @@ function errorMessage(error: unknown): string {
export class Ssh2Transport implements RemoteTransport { export class Ssh2Transport implements RemoteTransport {
private readonly client: Client; private readonly client: Client;
private readonly queue = new CommandQueue(); private readonly sftpQueue = new CommandQueue();
private readonly execSemaphore = new AsyncSemaphore(4);
private connected = false; private connected = false;
private disposed = false; private disposed = false;
private disconnectError: Error | null = null; private disconnectError: Error | null = null;
@@ -92,14 +154,17 @@ export class Ssh2Transport implements RemoteTransport {
this.client = client; this.client = client;
} }
async connect(): Promise<void> { async connect(signal?: AbortSignal): Promise<void> {
if (this.connected) return; if (this.connected) return;
if (this.disposed) throw new Error("SSH2 transport is disposed"); if (this.disposed) throw new Error("SSH2 transport is disposed");
if (signal?.aborted) throw new Error("SSH connection aborted");
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
let settled = false; let settled = false;
const cleanup = () => signal?.removeEventListener("abort", onAbort);
const succeed = () => { const succeed = () => {
if (settled) return; if (settled) return;
settled = true; settled = true;
cleanup();
this.connected = true; this.connected = true;
resolve(); resolve();
}; };
@@ -108,8 +173,17 @@ export class Ssh2Transport implements RemoteTransport {
this.disconnectError = normalized; this.disconnectError = normalized;
if (settled) return; if (settled) return;
settled = true; settled = true;
cleanup();
reject(normalized); reject(normalized);
}; };
const onAbort = () => {
try {
this.client.destroy();
} catch {
// client may already be closed
}
fail(new Error("SSH connection aborted"));
};
this.client.once("ready", succeed); this.client.once("ready", succeed);
this.client.on("error", fail); this.client.on("error", fail);
this.client.on("close", () => { this.client.on("close", () => {
@@ -122,6 +196,7 @@ export class Ssh2Transport implements RemoteTransport {
finish(prompts.map(() => this.host.auth.type === "password" ? this.host.auth.password : "")); finish(prompts.map(() => this.host.auth.type === "password" ? this.host.auth.password : ""));
}); });
} }
signal?.addEventListener("abort", onAbort, { once: true });
try { try {
this.client.connect(buildConnectConfig(this.host)); this.client.connect(buildConnectConfig(this.host));
} catch (error) { } catch (error) {
@@ -141,8 +216,13 @@ export class Ssh2Transport implements RemoteTransport {
if (!this.connected) throw this.disconnectError ?? new Error("SSH2 connection is not active"); if (!this.connected) throw this.disconnectError ?? new Error("SSH2 connection is not active");
} }
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> { async exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
return this.queue.enqueue(() => this.execUnqueued(command, cwd, options)); const release = await this.execSemaphore.acquire(options.signal);
try {
return await this.execUnqueued(command, cwd, options);
} finally {
release();
}
} }
private async execUnqueued(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> { private async execUnqueued(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
@@ -197,7 +277,10 @@ export class Ssh2Transport implements RemoteTransport {
} }
channel = stream; channel = stream;
stream.on("data", (data: Buffer | string) => options.onData(Buffer.isBuffer(data) ? data : Buffer.from(data))); 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.stderr.on("data", (data: Buffer | string) => {
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
(options.onStderr ?? options.onData)(chunk);
});
stream.once("error", fail); stream.once("error", fail);
stream.once("close", (code: number | undefined) => { stream.once("close", (code: number | undefined) => {
if (settled) return; if (settled) return;
@@ -225,7 +308,7 @@ export class Ssh2Transport implements RemoteTransport {
} }
async readFile(remotePath: string): Promise<Buffer> { async readFile(remotePath: string): Promise<Buffer> {
return this.queue.enqueue(async () => { return this.sftpQueue.enqueue(async () => {
const sftp = await this.sftp(); const sftp = await this.sftp();
return new Promise<Buffer>((resolve, reject) => { return new Promise<Buffer>((resolve, reject) => {
sftp.readFile(remotePath, (error, data) => error ? reject(error) : resolve(data)); sftp.readFile(remotePath, (error, data) => error ? reject(error) : resolve(data));
@@ -234,7 +317,7 @@ export class Ssh2Transport implements RemoteTransport {
} }
private async ensureOpen(remotePath: string, flags: "r" | "r+"): Promise<void> { private async ensureOpen(remotePath: string, flags: "r" | "r+"): Promise<void> {
return this.queue.enqueue(async () => { return this.sftpQueue.enqueue(async () => {
const sftp = await this.sftp(); const sftp = await this.sftp();
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
sftp.open(remotePath, flags, (error, handle) => { sftp.open(remotePath, flags, (error, handle) => {
@@ -267,7 +350,7 @@ export class Ssh2Transport implements RemoteTransport {
} }
async mkdir(remoteDir: string): Promise<void> { async mkdir(remoteDir: string): Promise<void> {
return this.queue.enqueue(() => this.mkdirUnqueued(remoteDir)); return this.sftpQueue.enqueue(() => this.mkdirUnqueued(remoteDir));
} }
private async mkdirUnqueued(remoteDir: string): Promise<void> { private async mkdirUnqueued(remoteDir: string): Promise<void> {
@@ -290,7 +373,7 @@ export class Ssh2Transport implements RemoteTransport {
} }
async writeFile(remotePath: string, content: Buffer): Promise<void> { async writeFile(remotePath: string, content: Buffer): Promise<void> {
return this.queue.enqueue(async () => { return this.sftpQueue.enqueue(async () => {
const sftp = await this.sftp(); const sftp = await this.sftp();
await this.mkdirUnqueued(posixPath.dirname(remotePath)); await this.mkdirUnqueued(posixPath.dirname(remotePath));
const temporary = `${remotePath}.pi-ssh-${randomBytes(8).toString("hex")}.tmp`; const temporary = `${remotePath}.pi-ssh-${randomBytes(8).toString("hex")}.tmp`;
+30 -1
View File
@@ -2,10 +2,13 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
import { parseConnectInput, SSH_CONNECT_TOOL_METADATA } from "../src/agent-connection.ts"; import { getConfiguredHost, parseConnectInput, SSH_CONNECT_TOOL_METADATA } from "../src/agent-connection.ts";
test("defines the reviewed agent-controlled SSH connection tool", () => { test("defines the reviewed agent-controlled SSH connection tool", () => {
assert.equal(SSH_CONNECT_TOOL_METADATA.name, "ssh_connect"); assert.equal(SSH_CONNECT_TOOL_METADATA.name, "ssh_connect");
assert.equal(SSH_CONNECT_TOOL_METADATA.executionMode, "sequential");
assert.match(SSH_CONNECT_TOOL_METADATA.description, /separate step/);
assert.match(SSH_CONNECT_TOOL_METADATA.description, /wait for success/);
assert.deepEqual(SSH_CONNECT_TOOL_METADATA.parameters.required, ["hostId"]); assert.deepEqual(SSH_CONNECT_TOOL_METADATA.parameters.required, ["hostId"]);
assert.ok(SSH_CONNECT_TOOL_METADATA.parameters.properties.remotePath); assert.ok(SSH_CONNECT_TOOL_METADATA.parameters.properties.remotePath);
assert.deepEqual(parseConnectInput({ hostId: " packaging-server " }), { hostId: "packaging-server" }); assert.deepEqual(parseConnectInput({ hostId: " packaging-server " }), { hostId: "packaging-server" });
@@ -17,6 +20,32 @@ test("defines the reviewed agent-controlled SSH connection tool", () => {
assert.throws(() => parseConnectInput({ hostId: "packaging-server", remotePath: "relative" }), /remote path/); assert.throws(() => parseConnectInput({ hostId: "packaging-server", remotePath: "relative" }), /remote path/);
}); });
test("unknown hosts report bounded imported host ID alternatives", () => {
const host = {
hostName: "example.test",
user: "builder",
port: 22,
auth: { type: "password" as const, password: "secret" },
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:fixture" },
};
assert.equal(getConfiguredHost({ version: 1, hosts: { "packaging-server": host } }, "packaging-server"), host);
assert.throws(
() => getConfiguredHost({ version: 1, hosts: { "packaging-server": host, "build-server": host } }, "connect-packaging-server"),
/available imported host IDs: build-server, packaging-server/,
);
const manyHosts = Object.fromEntries(Array.from({ length: 12 }, (_, index) => [`host-${String(index).padStart(2, "0")}`, host]));
assert.throws(
() => getConfiguredHost({ version: 1, hosts: manyHosts }, "missing"),
(error: unknown) => {
assert.match(String(error), /host-00, host-01, host-02/);
assert.match(String(error), /… \(\+2 more\)/);
assert.doesNotMatch(String(error), /example\.test|secret/);
return true;
},
);
assert.throws(() => getConfiguredHost({ version: 1, hosts: {} }, "missing"), /no hosts are imported/);
});
test("removes manual and implicit SSH connection surfaces", async () => { test("removes manual and implicit SSH connection surfaces", async () => {
const source = await readFile(new URL("../index.ts", import.meta.url), "utf8"); const source = await readFile(new URL("../index.ts", import.meta.url), "utf8");
assert.match(source, /\.\.\.SSH_CONNECT_TOOL_METADATA/); assert.match(source, /\.\.\.SSH_CONNECT_TOOL_METADATA/);
+8
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
import { validatePiSshConfig } from "../src/config.ts"; import { validatePiSshConfig } from "../src/config.ts";
import { effectiveValue, effectiveValues, parseSshG } from "../src/import.ts"; import { effectiveValue, effectiveValues, parseSshG } from "../src/import.ts";
@@ -61,3 +62,10 @@ test("formats SSH host keys as pinned SHA256 fingerprints", () => {
fingerprint: `SHA256:${expected}`, fingerprint: `SHA256:${expected}`,
}); });
}); });
test("configuration import validates remote HOME and cwd with framed probes", async () => {
const source = await readFile(new URL("../scripts/ssh-config.mjs", import.meta.url), "utf8");
assert.match(source, /probeRemotePath\(transport, "home"\)/);
assert.match(source, /probeRemotePath\(transport, "cwd"\)/);
assert.doesNotMatch(source, /transport\.capture\(/);
});
+18 -8
View File
@@ -43,6 +43,7 @@ const connection: SshPermissionConnection = {
remote: "packaging-server", remote: "packaging-server",
port: 2222, port: 2222,
remoteCwd: "/srv/build", remoteCwd: "/srv/build",
remoteHome: "/home/builder",
}; };
test("formats reviewed connection requests without exposing credentials", () => { test("formats reviewed connection requests without exposing credentials", () => {
@@ -59,19 +60,27 @@ test("formats reviewed connection requests without exposing credentials", () =>
test("formats the SSH target and bounded operation details", () => { test("formats the SSH target and bounded operation details", () => {
assert.equal( assert.equal(
formatSshPermissionInput("ssh_read", { path: "src/main.ts", offset: 5, limit: 20 }, connection), 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", "SSH target 'packaging-server:2222' in remote cwd '/srv/build'; read remote path '/srv/build/src/main.ts' (requested 'src/main.ts'), offset 5, limit 20",
); );
assert.equal( assert.equal(
formatSshPermissionInput("ssh_write", { path: "dist/a.txt", content: "one\ntwo" }, connection), 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)", "SSH target 'packaging-server:2222' in remote cwd '/srv/build'; write remote path '/srv/build/dist/a.txt' (requested 'dist/a.txt') (2 lines, 7 characters)",
); );
assert.equal( assert.equal(
formatSshPermissionInput("ssh_grep", { pattern: "TODO", path: "src", include: "*.ts", limit: 25 }, connection), 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'", "SSH target 'packaging-server:2222' in remote cwd '/srv/build'; search remote file contents under '/srv/build/src' (requested 'src'), for 'TODO', limit 25, file glob '*.ts'",
);
assert.equal(
formatSshPermissionInput("ssh_cd", { path: "../release" }, connection),
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; change the active remote cwd to '/srv/release' (requested '../release')",
);
assert.match(
formatSshPermissionInput("ssh_read", { path: "~/logs/app.log" }, connection),
/remote path '\/home\/builder\/logs\/app\.log' \(requested '~\/logs\/app\.log'\)/,
); );
}); });
test("registers previews and disables local path extraction for remote file tools", () => { test("registers previews and disables local path extraction for remote path tools", () => {
const { service, formatters, extractors } = makeService(); const { service, formatters, extractors } = makeService();
const pi = makePi(); const pi = makePi();
const dispose = installSshPermissionIntegration( const dispose = installSshPermissionIntegration(
@@ -84,10 +93,11 @@ test("registers previews and disables local path extraction for remote file tool
}, },
); );
assert.deepEqual([...formatters.keys()], ["ssh_connect", "ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep", "ssh_bash"]); assert.deepEqual([...formatters.keys()], ["ssh_connect", "ssh_cd", "ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep", "ssh_bash"]);
assert.deepEqual([...extractors.keys()], ["ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"]); assert.deepEqual([...extractors.keys()], ["ssh_cd", "ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"]);
assert.equal(extractors.get("ssh_read")?.({ path: "/remote/secret" }), undefined); assert.equal(extractors.get("ssh_read")?.({ path: "/remote/secret" }), undefined);
assert.equal(extractors.get("ssh_grep")?.({ path: "/remote/src" }), undefined); assert.equal(extractors.get("ssh_grep")?.({ path: "/remote/src" }), undefined);
assert.equal(extractors.get("ssh_cd")?.({ path: "/remote/release" }), undefined);
assert.match(formatters.get("ssh_bash")?.({ command: "git push" }) ?? "", /packaging-server:2222/); assert.match(formatters.get("ssh_bash")?.({ command: "git push" }) ?? "", /packaging-server:2222/);
assert.match(formatters.get("ssh_connect")?.({ hostId: "packaging-server" }) ?? "", /establish a persistent SSH2 connection/); assert.match(formatters.get("ssh_connect")?.({ hostId: "packaging-server" }) ?? "", /establish a persistent SSH2 connection/);
@@ -108,8 +118,8 @@ test("registers when the permission service becomes ready and cleans up on shutd
published = service; published = service;
pi.emitEvent("permissions:ready"); pi.emitEvent("permissions:ready");
assert.equal(formatters.size, 7); assert.equal(formatters.size, 8);
assert.equal(extractors.size, 5); assert.equal(extractors.size, 6);
pi.emit("session_shutdown"); pi.emit("session_shutdown");
assert.equal(formatters.size, 0); assert.equal(formatters.size, 0);
+43
View File
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createRemoteBashOps } from "../src/remote-bash.ts";
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
class CapturingTransport implements RemoteTransport {
calls: Array<{ command: string; cwd: string }> = [];
connect(): Promise<void> { return Promise.resolve(); }
dispose(): Promise<void> { return Promise.resolve(); }
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
this.calls.push({ command, cwd });
options.onData(Buffer.from("ok\n"));
return Promise.resolve({ exitCode: 0 });
}
capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); }
readFile(): Promise<Buffer> { throw new Error("not used"); }
ensureReadable(): Promise<void> { throw new Error("not used"); }
ensureReadableWritable(): Promise<void> { throw new Error("not used"); }
detectImageMimeType(): Promise<string | null> { throw new Error("not used"); }
mkdir(): Promise<void> { throw new Error("not used"); }
writeFile(): Promise<void> { throw new Error("not used"); }
}
test("ssh_bash always executes from the active remote cwd", async () => {
const connection = { remoteCwd: "/srv/project" };
const transport = new CapturingTransport();
const operations = createRemoteBashOps(connection, transport);
await operations.exec("pwd", "/Users/local/project", { onData() {} });
assert.deepEqual(transport.calls, [{ command: "pwd", cwd: "/srv/project" }]);
connection.remoteCwd = "/opt/next-project";
await operations.exec("npm test", "/another/local/path", { onData() {} });
assert.deepEqual(transport.calls[1], { command: "npm test", cwd: "/opt/next-project" });
});
test("ssh_bash rejects an invalid non-absolute remote cwd", () => {
const operations = createRemoteBashOps({ remoteCwd: "relative/path" }, new CapturingTransport());
assert.throws(
() => operations.exec("pwd", "/Users/local/project", { onData() {} }),
/requires an absolute remote cwd/,
);
});
+95
View File
@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createRemoteBashOps } from "../src/remote-bash.ts";
import {
changeRemoteCwd,
mapLocalPathToRemote,
resolveRemoteCwd,
SSH_CD_EXECUTION_MODE,
} from "../src/remote-cwd.ts";
import { resolveRemoteSearchPath } from "../src/remote-search.ts";
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
class CapturingTransport implements RemoteTransport {
calls: Array<{ command: string; cwd: string }> = [];
connect(): Promise<void> { return Promise.resolve(); }
dispose(): Promise<void> { return Promise.resolve(); }
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
this.calls.push({ command, cwd });
options.onData(Buffer.from("ok\n"));
return Promise.resolve({ exitCode: 0 });
}
capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); }
readFile(): Promise<Buffer> { throw new Error("not used"); }
ensureReadable(): Promise<void> { throw new Error("not used"); }
ensureReadableWritable(): Promise<void> { throw new Error("not used"); }
detectImageMimeType(): Promise<string | null> { throw new Error("not used"); }
mkdir(): Promise<void> { throw new Error("not used"); }
writeFile(): Promise<void> { throw new Error("not used"); }
}
test("resolves explicit remote workspace changes", () => {
assert.equal(resolveRemoteCwd("services/api", "/srv/project", "/home/build"), "/srv/project/services/api");
assert.equal(resolveRemoteCwd("../shared", "/srv/project", "/home/build"), "/srv/shared");
assert.equal(resolveRemoteCwd("/opt/app/../release", "/srv/project", "/home/build"), "/opt/release");
assert.equal(resolveRemoteCwd("~", "/srv/project", "/home/build"), "/home/build");
assert.equal(resolveRemoteCwd("~/jobs/app", "/srv/project", "/home/build"), "/home/build/jobs/app");
});
test("rejects invalid remote workspace paths", () => {
assert.throws(() => resolveRemoteCwd("", "/srv/project", "/home/build"), /non-empty/);
assert.throws(() => resolveRemoteCwd("bad\npath", "/srv/project", "/home/build"), /NUL or newline/);
assert.throws(() => resolveRemoteCwd("~other/project", "/srv/project", "/home/build"), /only '~' or '~\/'/);
});
test("ssh_cd is a sequential workspace transition", () => {
assert.equal(SSH_CD_EXECUTION_MODE, "sequential");
});
test("a validated workspace change drives subsequent remote tools", async () => {
const connection = {
remoteCwd: "/srv/project",
remoteHome: "/home/build",
localCwd: "/Users/local/project",
localHome: "/Users/local",
};
const verified: string[] = [];
const changed = await changeRemoteCwd(connection, "../release", async (requestedCwd) => {
verified.push(requestedCwd);
return requestedCwd;
});
assert.deepEqual(verified, ["/srv/release"]);
assert.deepEqual(changed, { previousCwd: "/srv/project", remoteCwd: "/srv/release" });
assert.equal(connection.remoteCwd, "/srv/release");
assert.equal(mapLocalPathToRemote("/Users/local/project/logs/build.log", connection), "/srv/release/logs/build.log");
assert.equal(resolveRemoteSearchPath(undefined, connection.remoteCwd, connection.remoteHome), "/srv/release");
const transport = new CapturingTransport();
const operations = createRemoteBashOps(connection, transport);
await operations.exec("npm test", "/Users/local/project", { onData() {} });
assert.deepEqual(transport.calls, [{ command: "npm test", cwd: "/srv/release" }]);
});
test("a failed workspace validation leaves the previous cwd active", async () => {
const connection = { remoteCwd: "/srv/project", remoteHome: "/home/build" };
await assert.rejects(
changeRemoteCwd(connection, "missing", async () => {
throw new Error("not a directory");
}),
/not a directory/,
);
assert.equal(connection.remoteCwd, "/srv/project");
});
test("successive workspace changes resolve from the latest confirmed cwd", async () => {
const connection = { remoteCwd: "/srv/project", remoteHome: "/home/build" };
const verify = async (requestedCwd: string) => requestedCwd;
await changeRemoteCwd(connection, "services/api", verify);
await changeRemoteCwd(connection, "../worker", verify);
assert.equal(connection.remoteCwd, "/srv/project/services/worker");
});
+57
View File
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseRemotePathProbe, probeRemotePath } from "../src/remote-probe.ts";
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
class ProbeTransport implements RemoteTransport {
command = "";
cwd = "";
connect(): Promise<void> { return Promise.resolve(); }
dispose(): Promise<void> { return Promise.resolve(); }
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
this.command = command;
this.cwd = cwd;
const start = command.match(/'(__PI_SSH_PROBE_[a-f0-9]+_START__)'/)?.[1];
const end = command.match(/'(__PI_SSH_PROBE_[a-f0-9]+_END__)'/)?.[1];
if (!start || !end) throw new Error("probe markers missing");
options.onData(Buffer.from(`login banner\n${start}/srv/project${end}\nlogout banner\n`));
return Promise.resolve({ exitCode: 0 });
}
capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); }
readFile(): Promise<Buffer> { throw new Error("not used"); }
ensureReadable(): Promise<void> { throw new Error("not used"); }
ensureReadableWritable(): Promise<void> { throw new Error("not used"); }
detectImageMimeType(): Promise<string | null> { throw new Error("not used"); }
mkdir(): Promise<void> { throw new Error("not used"); }
writeFile(): Promise<void> { throw new Error("not used"); }
}
test("extracts one framed absolute path while ignoring shell startup output", async () => {
const transport = new ProbeTransport();
assert.equal(await probeRemotePath(transport, "cwd", "/srv"), "/srv/project");
assert.equal(transport.cwd, "/srv");
assert.match(transport.command, /pwd -P/);
});
test("rejects unframed, relative, multiline, and oversized path probes", () => {
assert.throws(() => parseRemotePathProbe(Buffer.from("/srv"), "START", "END", "cwd"), /invalid framed/);
assert.throws(() => parseRemotePathProbe(Buffer.from("STARTrelativeEND"), "START", "END", "cwd"), /absolute POSIX path/);
assert.throws(() => parseRemotePathProbe(Buffer.from("START/srv\notherEND"), "START", "END", "cwd"), /absolute POSIX path/);
assert.throws(
() => parseRemotePathProbe(Buffer.alloc(64 * 1024 + 1), "START", "END", "cwd"),
/exceeded 65536 bytes/,
);
});
test("forwards cancellation to the probe exec call", async () => {
const controller = new AbortController();
controller.abort();
const transport = new ProbeTransport();
const original = transport.exec.bind(transport);
transport.exec = (command, cwd, options) => {
assert.equal(options.signal, controller.signal);
if (options.signal?.aborted) return Promise.reject(new Error("SSH command aborted"));
return original(command, cwd, options);
};
await assert.rejects(probeRemotePath(transport, "home", ".", controller.signal), /SSH command aborted/);
});
+147 -56
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process"; import { execFileSync, spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import test from "node:test"; import test from "node:test";
@@ -14,13 +14,20 @@ import {
} from "../src/remote-search.ts"; } from "../src/remote-search.ts";
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts"; import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
function grepProtocol(backend: string, rows: Array<[string, number, string]> = []): Buffer {
const fields = rows.flatMap(([path, line, content]) => [path, String(line), content]);
return Buffer.from(`__PI_SSH_SEARCH_BACKEND__:${backend}\n${fields.length > 0 ? `${fields.join("\0")}\0` : ""}`);
}
class SearchTransport implements RemoteTransport { class SearchTransport implements RemoteTransport {
command = ""; command = "";
cwd = ""; cwd = "";
private readonly output: string; private readonly output: Buffer;
private readonly stderr: Buffer;
private readonly exitCode: number | null; private readonly exitCode: number | null;
constructor(output: string, exitCode: number | null = 0) { constructor(output: string | Buffer, exitCode: number | null = 0, stderr = "") {
this.output = output; this.output = Buffer.isBuffer(output) ? output : Buffer.from(output);
this.stderr = Buffer.from(stderr);
this.exitCode = exitCode; this.exitCode = exitCode;
} }
connect(): Promise<void> { return Promise.resolve(); } connect(): Promise<void> { return Promise.resolve(); }
@@ -28,7 +35,8 @@ class SearchTransport implements RemoteTransport {
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> { exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
this.command = command; this.command = command;
this.cwd = cwd; this.cwd = cwd;
options.onData(Buffer.from(this.output)); options.onData(this.output);
if (this.stderr.length > 0) (options.onStderr ?? options.onData)(this.stderr);
return Promise.resolve({ exitCode: this.exitCode }); return Promise.resolve({ exitCode: this.exitCode });
} }
capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); } capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); }
@@ -40,14 +48,30 @@ class SearchTransport implements RemoteTransport {
writeFile(): Promise<void> { throw new Error("not used"); } writeFile(): Promise<void> { throw new Error("not used"); }
} }
function toolPath(name: string): string {
return execFileSync("/bin/sh", ["-c", `command -v ${name}`], { encoding: "utf8" }).trim();
}
function toolBin(root: string, names: string[]): string {
const bin = join(root, `bin-${names.join("-")}`);
mkdirSync(bin);
for (const name of names) symlinkSync(toolPath(name), join(bin, name));
return bin;
}
function execute(command: string, cwd: string, path: string) {
return spawnSync("/bin/bash", ["-c", command], { cwd, env: { ...process.env, PATH: path } });
}
test("resolves remote search roots without using local filesystem semantics", () => { test("resolves remote search roots without using local filesystem semantics", () => {
assert.equal(resolveRemoteSearchPath(undefined, "/srv/app", "/home/build"), "/srv/app"); assert.equal(resolveRemoteSearchPath(undefined, "/srv/app", "/home/build"), "/srv/app");
assert.equal(resolveRemoteSearchPath("src", "/srv/app", "/home/build"), "/srv/app/src"); 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("~/logs", "/srv/app", "/home/build"), "/home/build/logs");
assert.equal(resolveRemoteSearchPath("/var/log", "/srv/app", "/home/build"), "/var/log"); assert.equal(resolveRemoteSearchPath("/var/log", "/srv/app", "/home/build"), "/var/log");
assert.throws(() => resolveRemoteSearchPath("~other/project", "/srv/app", "/home/build"), /only ~ or ~\//);
}); });
test("builds bounded capability-adaptive commands with shell-quoted user input", () => { test("builds bounded capability-adaptive commands with unified grep semantics", () => {
const find = buildRemoteFindCommand({ pattern: "it's-app", limit: 12 }, "/srv/app dir"); const find = buildRemoteFindCommand({ pattern: "it's-app", limit: 12 }, "/srv/app dir");
assert.match(find.command, /command -v fd/); assert.match(find.command, /command -v fd/);
assert.match(find.command, /git-ls-files/); assert.match(find.command, /git-ls-files/);
@@ -55,18 +79,20 @@ test("builds bounded capability-adaptive commands with shell-quoted user input",
assert.match(find.command, /'it'"'"'s-app'/); assert.match(find.command, /'it'"'"'s-app'/);
assert.match(find.command, /'\/srv\/app dir'/); assert.match(find.command, /'\/srv\/app dir'/);
const grep = buildRemoteGrepCommand({ pattern: "TODO", include: "*.ts", limit: 20 }, "/srv/app"); const grep = buildRemoteGrepCommand({ pattern: "needle+", literal: false, include: "*.ts", includeHidden: true, limit: 20 }, "/srv/app");
assert.match(grep.command, /command -v rg/); assert.match(grep.command, /rg --null/);
assert.match(grep.command, /git-grep/); assert.match(grep.command, /git .* grep .* -z .* -E/);
assert.match(grep.command, /find .* -exec grep/); assert.match(grep.command, /grep -n -I -E/);
assert.match(grep.command, /head -n 21/); assert.match(grep.command, /PI_SSH_TAKE=21/);
assert.match(grep.command, /--hidden/);
assert.throws(() => buildRemoteGrepCommand({ pattern: "x", include: "sub\/*.ts" }, "/srv/app"), /basename glob without \//);
assert.throws(() => buildRemoteGrepCommand({ pattern: "bad\npattern" }, "/srv/app"), /newline/); assert.throws(() => buildRemoteGrepCommand({ pattern: "bad\npattern" }, "/srv/app"), /newline/);
assert.throws(() => buildRemoteFindCommand({ pattern: "x", limit: 201 }, "/srv/app"), /limit/); assert.throws(() => buildRemoteFindCommand({ pattern: "x", limit: 201 }, "/srv/app"), /limit/);
}); });
test("normalizes git paths, truncates rows, and reports the backend", () => { test("normalizes NUL-delimited git paths, escaped newlines, truncation, and backend", () => {
const result = formatRemoteSearchOutput( 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", grepProtocol("git-grep", [["src/a.ts", 2, "TODO"], ["src/line\nb.ts", 3, "TODO"], ["src/c.ts", 4, "TODO"]]),
"/srv/app", "/srv/app",
2, 2,
"grep", "grep",
@@ -75,25 +101,39 @@ test("normalizes git paths, truncates rows, and reports the backend", () => {
assert.equal(result.matchCount, 2); assert.equal(result.matchCount, 2);
assert.equal(result.truncated, true); assert.equal(result.truncated, true);
assert.match(result.text, /\/srv\/app\/src\/a\.ts:2:TODO/); assert.match(result.text, /\/srv\/app\/src\/a\.ts:2:TODO/);
assert.match(result.text, /src\/line\\nb\.ts:3:TODO/);
assert.doesNotMatch(result.text, /src\/c\.ts/); assert.doesNotMatch(result.text, /src\/c\.ts/);
}); });
test("executes remote find through the structured transport with bounded output", async () => { test("executes remote find from the active workspace while searching a separate root", async () => {
const transport = new SearchTransport("__PI_SSH_SEARCH_BACKEND__:fd\n/srv/app/a.ts\n"); const transport = new SearchTransport("__PI_SSH_SEARCH_BACKEND__:fd\n/data/search/a.ts\n");
const result = await runRemoteFind(transport, { pattern: "a", limit: 5 }, "/srv/app", "/home/build"); const result = await runRemoteFind(transport, { pattern: "a", path: "/data/search", limit: 5 }, "/srv/app", "/home/build");
assert.equal(transport.cwd, "/srv/app"); assert.equal(transport.cwd, "/srv/app");
assert.match(transport.command, /command -v fd/); assert.match(transport.command, /command -v fd/);
assert.match(transport.command, /\/data\/search/);
assert.equal(result.matchCount, 1); assert.equal(result.matchCount, 1);
assert.match(result.text, /backend: fd/); assert.match(result.text, /backend: fd/);
}); });
test("propagates backend failures without exposing the internal marker", async () => { test("searches a single remote file without treating the file as execution cwd", async () => {
const transport = new SearchTransport( const transport = new SearchTransport(grepProtocol("ripgrep", [["/var/log/application.log", 7, "ERROR"]]));
"__PI_SSH_SEARCH_BACKEND__:git-grep\nfatal: invalid regular expression\n", const result = await runRemoteGrep(transport, { pattern: "ERROR", path: "/var/log/application.log" }, "/srv/app", "/home/build");
128, assert.equal(transport.cwd, "/srv/app");
); assert.match(transport.command, /\/var\/log\/application\.log/);
assert.equal(result.matchCount, 1);
assert.match(result.text, /application\.log:7:ERROR/);
});
test("keeps successful stderr warnings out of match rows and uses stderr for failures", async () => {
const warningTransport = new SearchTransport(grepProtocol("ripgrep"), 0, "warning: skipped socket\n");
const warningResult = await runRemoteGrep(warningTransport, { pattern: "ABSENT" }, "/srv/app", "/home/build");
assert.equal(warningResult.matchCount, 0);
assert.match(warningResult.text, /No matches found/);
assert.match(warningResult.text, /Remote warning: warning: skipped socket/);
const failed = new SearchTransport(grepProtocol("git-grep"), 128, "fatal: invalid regular expression\n");
await assert.rejects( await assert.rejects(
runRemoteGrep(transport, { pattern: "[", literal: false }, "/srv/app", "/home/build"), runRemoteGrep(failed, { pattern: "[", literal: false }, "/srv/app", "/home/build"),
(error: unknown) => { (error: unknown) => {
assert.match(String(error), /remote grep failed with exit code 128: fatal: invalid regular expression/); assert.match(String(error), /remote grep failed with exit code 128: fatal: invalid regular expression/);
assert.doesNotMatch(String(error), /__PI_SSH_SEARCH_BACKEND__/); assert.doesNotMatch(String(error), /__PI_SSH_SEARCH_BACKEND__/);
@@ -102,49 +142,100 @@ test("propagates backend failures without exposing the internal marker", async (
); );
}); });
test("adaptive commands preserve no-match success and propagate real backend errors", async () => { test("reports actionable bounded-search timeout guidance", async () => {
const transport = new SearchTransport("");
transport.exec = () => Promise.reject(new Error("SSH command timed out after 30s"));
await assert.rejects(
runRemoteGrep(transport, { pattern: "TODO", path: "/home/build" }, "/srv/app", "/home/build"),
/remote grep timed out after 30s \(root: \/home\/build\); narrow the remote path with ssh_find/,
);
});
test("forces git-grep and fallback no-match paths and preserves real errors", () => {
const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-")); const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-"));
try { try {
mkdirSync(join(root, "src")); mkdirSync(join(root, "src"));
writeFileSync(join(root, "src", "a.ts"), "const value = 'TODO';\n", "utf8"); writeFileSync(join(root, "src", "a.ts"), "const value = 'TODO';\n", "utf8");
execFileSync("/usr/bin/git", ["init", "-q", root]); execFileSync(toolPath("git"), ["init", "-q", root]);
execFileSync("/usr/bin/git", ["-C", root, "add", "src/a.ts"]); execFileSync(toolPath("git"), ["-C", root, "add", "src/a.ts"]);
const environment = { ...process.env, PATH: "/usr/bin:/bin" }; const gitPath = toolBin(root, ["git"]);
const execute = (command: string) => execFileSync("/bin/bash", ["-c", command], { const fallbackPath = toolBin(root, ["find", "grep"]);
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 grep = buildRemoteGrepCommand({ pattern: "TODO", include: "*.ts", limit: 5 }, root);
const grepResult = formatRemoteSearchOutput(execute(grep.command), root, grep.limit, "grep"); const gitProcess = execute(grep.command, root, gitPath);
assert.equal(grepResult.backend, "git-grep"); assert.equal(gitProcess.status, 0, gitProcess.stderr.toString());
assert.match(grepResult.text, /src\/a\.ts:1:/); const gitResult = formatRemoteSearchOutput(gitProcess.stdout, root, grep.limit, "grep");
assert.equal(gitResult.backend, "git-grep");
assert.equal(gitResult.matchCount, 1);
const fallbackProcess = execute(grep.command, root, fallbackPath);
assert.equal(fallbackProcess.status, 0, fallbackProcess.stderr.toString());
const fallbackResult = formatRemoteSearchOutput(fallbackProcess.stdout, root, grep.limit, "grep");
assert.equal(fallbackResult.backend, "grep");
assert.equal(fallbackResult.matchCount, 1);
const noMatch = buildRemoteGrepCommand({ pattern: "ABSENT", include: "*.ts" }, root); const noMatch = buildRemoteGrepCommand({ pattern: "ABSENT", include: "*.ts" }, root);
const noMatchProcess = spawnSync("/bin/bash", ["-c", noMatch.command], { cwd: root, encoding: "utf8", env: environment }); for (const path of [gitPath, fallbackPath]) {
assert.equal(noMatchProcess.status, 0); const process = execute(noMatch.command, root, path);
assert.equal(formatRemoteSearchOutput(noMatchProcess.stdout, root, noMatch.limit, "grep").matchCount, 0); assert.equal(process.status, 0, process.stderr.toString());
assert.equal(formatRemoteSearchOutput(process.stdout, root, noMatch.limit, "grep").matchCount, 0);
}
const invalidRegex = buildRemoteGrepCommand({ pattern: "[", literal: false }, root); const invalidRegex = buildRemoteGrepCommand({ pattern: "[", literal: false }, root);
const invalidProcess = spawnSync("/bin/bash", ["-c", invalidRegex.command], { cwd: root, encoding: "utf8", env: environment }); const invalidProcess = execute(invalidRegex.command, root, fallbackPath);
assert.notEqual(invalidProcess.status, 0); assert.notEqual(invalidProcess.status, 0);
assert.match(`${invalidProcess.stdout}${invalidProcess.stderr}`, /git-grep|fatal|regular expression/i); assert.match(invalidProcess.stderr.toString(), /regular expression|bracket/i);
const missingRoot = join(root, "missing"); const missing = buildRemoteFindCommand({ pattern: "anything" }, join(root, "missing"));
const missing = buildRemoteFindCommand({ pattern: "anything" }, missingRoot); const missingProcess = execute(missing.command, root, fallbackPath);
const missingProcess = spawnSync("/bin/bash", ["-c", missing.command], { cwd: root, encoding: "utf8", env: environment });
assert.notEqual(missingProcess.status, 0); assert.notEqual(missingProcess.status, 0);
assert.match(`${missingProcess.stdout}${missingProcess.stderr}`, /find|No such file|not found/i); assert.match(missingProcess.stderr.toString(), /find|No such file|not found/i);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
const transport = new SearchTransport(execute(grep.command)); test("rg, git-grep, and fallback agree on hidden files, basename globs, and portable ERE", (context) => {
const throughTransport = await runRemoteGrep(transport, { pattern: "TODO", include: "*.ts" }, root, root); let rg: string;
assert.equal(throughTransport.matchCount, 1); try { rg = toolPath("rg"); } catch { context.skip("rg is unavailable"); return; }
const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-matrix-"));
try {
mkdirSync(join(root, "sub"));
mkdirSync(join(root, ".secret"));
for (const path of ["visible.ts", "sub/nested.ts", ".hidden.ts", ".secret/deep.ts"]) {
writeFileSync(join(root, path), "needle\nneedlee\n", "utf8");
}
execFileSync(toolPath("git"), ["init", "-q", root]);
execFileSync(toolPath("git"), ["-C", root, "add", "."]);
const paths = [toolBin(root, ["rg"]), toolBin(root, ["git"]), toolBin(root, ["find", "grep"])];
assert.equal(toolPath("rg"), rg);
const runCounts = (input: Parameters<typeof buildRemoteGrepCommand>[0]) => paths.map((path) => {
const built = buildRemoteGrepCommand(input, root);
const process = execute(built.command, root, path);
assert.equal(process.status, 0, process.stderr.toString());
return formatRemoteSearchOutput(process.stdout, root, built.limit, "grep").matchCount;
});
assert.deepEqual(runCounts({ pattern: "needle", include: "*.ts" }), [4, 4, 4]);
assert.deepEqual(runCounts({ pattern: "needle", include: "*.ts", includeHidden: true }), [8, 8, 8]);
assert.deepEqual(runCounts({ pattern: "needle+", literal: false, include: "*.ts" }), [4, 4, 4]);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("fallback NUL protocol keeps newline filenames as one bounded result", () => {
const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-newline-"));
try {
const filename = "line\nbreak.ts";
writeFileSync(join(root, filename), "needle\n", "utf8");
const fallbackPath = toolBin(root, ["find", "grep"]);
const built = buildRemoteGrepCommand({ pattern: "needle", include: "*.ts" }, root);
const process = execute(built.command, root, fallbackPath);
assert.equal(process.status, 0, process.stderr.toString());
const result = formatRemoteSearchOutput(process.stdout, root, built.limit, "grep");
assert.equal(result.matchCount, 1);
assert.match(result.text, /line\\nbreak\.ts:1:needle/);
} finally { } finally {
rmSync(root, { recursive: true, force: true }); rmSync(root, { recursive: true, force: true });
} }
@@ -162,17 +253,17 @@ test("propagates fd and ripgrep failures while preserving ripgrep no-match", ()
const environment = { ...process.env, PATH: `${bin}:/usr/bin:/bin` }; const environment = { ...process.env, PATH: `${bin}:/usr/bin:/bin` };
const find = buildRemoteFindCommand({ pattern: "anything" }, root); const find = buildRemoteFindCommand({ pattern: "anything" }, root);
const failedFind = spawnSync("/bin/bash", ["-c", find.command], { cwd: root, encoding: "utf8", env: environment }); const failedFind = spawnSync("/bin/bash", ["-c", find.command], { cwd: root, env: environment });
assert.equal(failedFind.status, 3); assert.equal(failedFind.status, 3);
assert.match(failedFind.stdout, /fd exploded/); assert.match(failedFind.stderr.toString(), /fd exploded/);
const grep = buildRemoteGrepCommand({ pattern: "anything" }, root); const grep = buildRemoteGrepCommand({ pattern: "anything" }, root);
const failedGrep = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, encoding: "utf8", env: environment }); const failedGrep = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, env: environment });
assert.equal(failedGrep.status, 2); assert.equal(failedGrep.status, 2);
assert.match(failedGrep.stdout, /rg exploded/); assert.match(failedGrep.stderr.toString(), /rg exploded/);
writeFileSync(rg, "#!/bin/sh\nexit 1\n", { mode: 0o755 }); writeFileSync(rg, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
const noMatch = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, encoding: "utf8", env: environment }); const noMatch = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, env: environment });
assert.equal(noMatch.status, 0); assert.equal(noMatch.status, 0);
assert.equal(formatRemoteSearchOutput(noMatch.stdout, root, grep.limit, "grep").matchCount, 0); assert.equal(formatRemoteSearchOutput(noMatch.stdout, root, grep.limit, "grep").matchCount, 0);
} finally { } finally {
+62 -1
View File
@@ -182,12 +182,15 @@ test("connects with password auth, pins the host key, and streams exec output",
assert.equal(fake.connectConfig?.hostVerifier?.(fixtureKey("wrong")), false); assert.equal(fake.connectConfig?.hostVerifier?.(fixtureKey("wrong")), false);
const output: Buffer[] = []; const output: Buffer[] = [];
const stderr: Buffer[] = [];
const result = await transport.exec("printf ok", "/srv/build", { const result = await transport.exec("printf ok", "/srv/build", {
onData: (data) => output.push(data), onData: (data) => output.push(data),
onStderr: (data) => stderr.push(data),
timeout: 5, timeout: 5,
}); });
assert.equal(result.exitCode, 7); assert.equal(result.exitCode, 7);
assert.equal(Buffer.concat(output).toString("utf8"), "stdout\nstderr\n"); assert.equal(Buffer.concat(output).toString("utf8"), "stdout\n");
assert.equal(Buffer.concat(stderr).toString("utf8"), "stderr\n");
assert.match(fake.command ?? "", /^cd -- '\/srv\/build' && bash -lc 'printf ok' <\/dev\/null$/); assert.match(fake.command ?? "", /^cd -- '\/srv\/build' && bash -lc 'printf ok' <\/dev\/null$/);
await transport.dispose(); await transport.dispose();
assert.equal(fake.ended, true); assert.equal(fake.ended, true);
@@ -279,6 +282,64 @@ test("falls back to direct overwrite when SFTP v3 rename cannot replace", async
await transport.dispose(); await transport.dispose();
}); });
test("aborts an in-progress SSH connection attempt", async () => {
const fake = new FakeClient();
fake.connectAction = () => {};
const transport = new Ssh2Transport(passwordHost(), fake as unknown as Client);
const controller = new AbortController();
const connecting = transport.connect(controller.signal);
await new Promise<void>((resolve) => setImmediate(resolve));
controller.abort();
await assert.rejects(connecting, /SSH connection aborted/);
assert.equal(fake.destroyed, true);
await transport.dispose();
});
test("allows four independent exec channels and bounds additional commands", async () => {
const fake = new FakeClient();
const channels: FakeChannel[] = [];
fake.execAction = (_command, callback) => {
const channel = new FakeChannel();
channels.push(channel);
callback(undefined, channel as ClientChannel);
};
const transport = new Ssh2Transport(passwordHost(), fake as unknown as Client);
await transport.connect();
const commands = Array.from({ length: 5 }, (_, index) =>
transport.exec(`command-${index}`, "/srv", { onData() {}, timeout: 0 }),
);
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(channels.length, 4);
channels[0]?.emit("close", 0);
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(channels.length, 5);
for (const channel of channels.slice(1)) channel.emit("close", 0);
await Promise.all(commands);
await transport.dispose();
});
test("cancels an exec while it is waiting for a concurrency slot", async () => {
const fake = new FakeClient();
const channels: FakeChannel[] = [];
fake.execAction = (_command, callback) => {
const channel = new FakeChannel();
channels.push(channel);
callback(undefined, channel as ClientChannel);
};
const transport = new Ssh2Transport(passwordHost(), fake as unknown as Client);
await transport.connect();
const active = Array.from({ length: 4 }, () => transport.exec("sleep", "/srv", { onData() {}, timeout: 0 }));
await new Promise<void>((resolve) => setImmediate(resolve));
const controller = new AbortController();
const queued = transport.exec("queued", "/srv", { onData() {}, timeout: 0, signal: controller.signal });
controller.abort();
await assert.rejects(queued, /SSH command aborted/);
assert.equal(channels.length, 4);
for (const channel of channels) channel.emit("close", 0);
await Promise.all(active);
await transport.dispose();
});
test("aborts and times out commands by closing the active channel", async () => { test("aborts and times out commands by closing the active channel", async () => {
const abortClient = new FakeClient(); const abortClient = new FakeClient();
const abortChannel = new FakeChannel(); const abortChannel = new FakeChannel();
+2 -1
View File
@@ -11,7 +11,8 @@
- 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. - Added authoritative `ssh-connection`, `ssh-remote-files`, `ssh-remote-search`, and `ssh-remote-shell` groups for the maintained `pi-ssh` tools, keeping reviewed connection, bounded discovery, structured file operations, and reviewed shell execution separate; the connection workflow now requires sequential `ssh_connect` to succeed before dependent tools.
- Added sequential `ssh_cd` to the authoritative remote-shell workflow and direct the model to wait for a successful workspace change before dependent remote calls, avoiding both the unknown-tools fallback and cwd races.
## [0.3.6] - 2026-04-24 ## [0.3.6] - 2026-04-24
+1 -1
View File
@@ -30,7 +30,7 @@ Run `/tool-search-rebuild` to remove model enrichment. Standard bundle tools imm
| `ssh-connection` | `ssh_connect` | | `ssh-connection` | `ssh_connect` |
| `ssh-remote-files` | `ssh_read`, `ssh_write`, `ssh_edit` | | `ssh-remote-files` | `ssh_read`, `ssh_write`, `ssh_edit` |
| `ssh-remote-search` | `ssh_find`, `ssh_grep` | | `ssh-remote-search` | `ssh_find`, `ssh_grep` |
| `ssh-remote-shell` | `ssh_bash` | | `ssh-remote-shell` | `ssh_cd`, `ssh_bash` |
| `code-intelligence` | CodeGraph and all LSP tools | | `code-intelligence` | CodeGraph and all LSP tools |
| `web-tavily` | Tavily search/fetch | | `web-tavily` | Tavily search/fetch |
| `web-exa` | Exa search/advanced/fetch | | `web-exa` | Exa search/advanced/fetch |
+1 -1
View File
@@ -13,7 +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`. The maintained `pi-ssh` tools are also split by workflow: sequential reviewed agent-controlled connection lives in `ssh-connection`, structured `ssh_read`/`ssh_write`/`ssh_edit` file operations live in `ssh-remote-files`, bounded `ssh_find`/`ssh_grep` discovery lives in `ssh-remote-search`, and sequential reviewed workspace changes plus reviewed command execution live in `ssh-remote-shell`. The workflows tell the model to call `ssh_connect` or `ssh_cd` separately and wait for success before issuing remote calls that depend on the new connection or cwd.
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.
+8 -8
View File
@@ -34,8 +34,8 @@ export const BUNDLE_GROUP_DEFINITIONS: BundleGroupDefinition[] = [
{ {
id: "ssh-connection", id: "ssh-connection",
title: "SSH connection", title: "SSH connection",
summary: "Connect to an explicitly imported SSH host through the reviewed agent tool flow.", summary: "Connect sequentially to an explicitly imported SSH host through the reviewed agent tool flow.",
useWhen: ["The user names a remote server as part of a concrete task and no matching SSH2 connection is active"], useWhen: ["The user names a remote server as part of a concrete task and no matching SSH2 connection is active; call ssh_connect separately and wait for success before dependent remote tools"],
avoidWhen: ["The task is local, the server was not explicitly named, or the host has not been imported"], 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"], keywords: ["ssh", "connect", "server", "host", "remote", "连接服务器", "远程主机", "SSH"],
tools: ["ssh_connect"], tools: ["ssh_connect"],
@@ -60,12 +60,12 @@ export const BUNDLE_GROUP_DEFINITIONS: BundleGroupDefinition[] = [
}, },
{ {
id: "ssh-remote-shell", id: "ssh-remote-shell",
title: "SSH remote shell", title: "SSH remote workspace shell",
summary: "Run a reviewed shell command in the currently connected remote SSH workspace.", summary: "Change the active remote cwd as a separate reviewed step, or run a reviewed shell command in that workspace.",
useWhen: ["You need builds, tests, Git, or other shell operations on an active SSH target"], useWhen: ["You need to change the active remote workspace or run builds, tests, Git, or other shell operations there; call ssh_cd separately and wait for success before dependent remote calls"],
avoidWhen: ["Structured remote file tools are sufficient, or the command should run locally"], avoidWhen: ["Structured remote file tools are sufficient, or the operation should run locally"],
keywords: ["ssh", "remote", "bash", "build", "test", "git", "远程命令", "SSH"], keywords: ["ssh", "remote", "bash", "cd", "cwd", "workspace", "build", "test", "git", "远程命令", "工作目录", "SSH"],
tools: ["ssh_bash"], tools: ["ssh_cd", "ssh_bash"],
}, },
{ {
id: "code-intelligence", id: "code-intelligence",
+5
View File
@@ -37,6 +37,7 @@ test("all package extensions load together without global registration conflicts
await readFile(join(repositoryRoot, "config", "pi-permission-system.json"), "utf8"), await readFile(join(repositoryRoot, "config", "pi-permission-system.json"), "utf8"),
) as { authorizerChain: string[]; permission: Record<string, unknown> }; ) as { authorizerChain: string[]; permission: Record<string, unknown> };
assert.equal(permissionConfig.permission.ssh_connect, "ask", "SSH connection must enter the permission gate"); assert.equal(permissionConfig.permission.ssh_connect, "ask", "SSH connection must enter the permission gate");
assert.equal(permissionConfig.permission.ssh_cd, "ask", "remote workspace changes must enter the permission gate");
assert.deepEqual(permissionConfig.authorizerChain, ["auto-review"], "SSH connection asks must reach AutoReview"); assert.deepEqual(permissionConfig.authorizerChain, ["auto-review"], "SSH connection asks must reach AutoReview");
assert.ok( assert.ok(
packageJson.pi.extensions.indexOf("./extensions/permission-system.ts") < packageJson.pi.extensions.indexOf("./extensions/permission-system.ts") <
@@ -93,4 +94,8 @@ test("all package extensions load together without global registration conflicts
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Failed to load extension|conflicts with/u); assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Failed to load extension|conflicts with/u);
const deployedPermissionConfig = JSON.parse(
await readFile(join(home, ".pi-agent", "extensions", "pi-permission-system", "config.json"), "utf8"),
);
assert.deepEqual(deployedPermissionConfig, permissionConfig, "the deployed permission config must match the bundle source");
}); });
+8 -1
View File
@@ -24,10 +24,17 @@ test("search routing narrows files before requesting matching line numbers", ()
test("SSH connection routing requires an explicit named host and concrete task", () => { test("SSH connection routing requires an explicit named host and concrete task", () => {
const section = buildToolRoutingSection(["ssh_connect"]); const section = buildToolRoutingSection(["ssh_connect"]);
assert.match(section, /only when the user explicitly names an imported host/); assert.match(section, /only when the user explicitly names an imported host/);
assert.match(section, /Connect before calling other ssh_\* tools/); assert.match(section, /separate step and wait for success before calling other ssh_\* tools/);
assert.match(section, /never infer or substitute a different host/); assert.match(section, /never infer or substitute a different host/);
}); });
test("remote cwd routing separates persistent workspace changes from shell commands", () => {
const section = buildToolRoutingSection(["ssh_cd", "ssh_bash"]);
assert.match(section, /call it as a separate step and wait for success/);
assert.match(section, /relative ssh_read\/ssh_write\/ssh_edit\/ssh_find\/ssh_grep/);
assert.match(section, /intentionally temporary, command-local directory change/);
});
test("remote search routing uses structured bounded SSH tools", () => { test("remote search routing uses structured bounded SSH tools", () => {
const section = buildToolRoutingSection(["ssh_find", "ssh_grep"]); const section = buildToolRoutingSection(["ssh_find", "ssh_grep"]);
assert.match(section, /use ssh_find to narrow remote file paths before ssh_grep/); assert.match(section, /use ssh_find to narrow remote file paths before ssh_grep/);