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