diff --git a/AGENTS.md b/AGENTS.md index 9f7de1f..2a9a05e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ - 上游来源: - 初始导入快照:`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`。 - 上游来源: - 初始导入快照:`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,不得转为无提示自动允许。 diff --git a/README.md b/README.md index 1a321d9..6a03f48 100644 --- a/README.md +++ b/README.md @@ -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)。 ### 在线搜索服务 diff --git a/config/pi-permission-system.json b/config/pi-permission-system.json index 13ce3d2..252ef66 100644 --- a/config/pi-permission-system.json +++ b/config/pi-permission-system.json @@ -19,6 +19,7 @@ "find": "allow", "multi_grep": "allow", "ssh_connect": "ask", + "ssh_cd": "ask", "ssh_read": "ask", "ssh_write": "ask", "ssh_edit": "ask", diff --git a/extensions/tool-routing.ts b/extensions/tool-routing.ts index 31400a6..e8a8f2a 100644 --- a/extensions/tool-routing.ts +++ b/extensions/tool-routing.ts @@ -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.", ); } diff --git a/pi-ssh/CHANGELOG.md b/pi-ssh/CHANGELOG.md index 98252e0..20fd70e 100644 --- a/pi-ssh/CHANGELOG.md +++ b/pi-ssh/CHANGELOG.md @@ -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. diff --git a/pi-ssh/README.md b/pi-ssh/README.md index 2e20ae3..c0687cd 100644 --- a/pi-ssh/README.md +++ b/pi-ssh/README.md @@ -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 ` 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 diff --git a/pi-ssh/extension-spec.md b/pi-ssh/extension-spec.md index f80eb72..ef9375a 100644 --- a/pi-ssh/extension-spec.md +++ b/pi-ssh/extension-spec.md @@ -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: "", 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. diff --git a/pi-ssh/index.ts b/pi-ssh/index.ts index 1a0fb7c..0469586 100644 --- a/pi-ssh/index.ts +++ b/pi-ssh/index.ts @@ -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 { - 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); - 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}` }; }); } diff --git a/pi-ssh/permission-integration.ts b/pi-ssh/permission-integration.ts index 56d45a0..3748795 100644 --- a/pi-ssh/permission-integration.ts +++ b/pi-ssh/permission-integration.ts @@ -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; type ToolInput = Record; -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 ''"}`; + } + 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 ?? "")}' (${countLines(content)} lines, ${content.length} characters)`; + return `${target}; write ${path ? remotePathDetail(path, connection) : "remote path ''"} (${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 ?? "")}' (replace ${countLines(oldText)} lines with ${countLines(newText)} lines)`; + return `${target}; edit ${path ? remotePathDetail(path, connection) : "remote path ''"} (replace ${countLines(oldText)} lines with ${countLines(newText)} lines)`; } if (toolName === "ssh_find" || toolName === "ssh_grep") { const pattern = inline(stringField(input, "pattern") ?? ""); 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; diff --git a/pi-ssh/scripts/ssh-config.mjs b/pi-ssh/scripts/ssh-config.mjs index bf08470..f4fcbf8 100755 --- a/pi-ssh/scripts/ssh-config.mjs +++ b/pi-ssh/scripts/ssh-config.mjs @@ -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 { diff --git a/pi-ssh/src/agent-connection.ts b/pi-ssh/src/agent-connection.ts index c7d00ef..92506af 100644 --- a/pi-ssh/src/agent-connection.ts +++ b/pi-ssh/src/agent-connection.ts @@ -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): HostSelection { @@ -31,3 +34,16 @@ export function parseConnectInput(input: Record): 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 `); + } + 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}`); +} diff --git a/pi-ssh/src/remote-bash.ts b/pi-ssh/src/remote-bash.ts new file mode 100644 index 0000000..1137a2b --- /dev/null +++ b/pi-ssh/src/remote-bash.ts @@ -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 }); + }, + }; +} diff --git a/pi-ssh/src/remote-cwd.ts b/pi-ssh/src/remote-cwd.ts new file mode 100644 index 0000000..1b21d4c --- /dev/null +++ b/pi-ssh/src/remote-cwd.ts @@ -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, +): Promise { + 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; +} diff --git a/pi-ssh/src/remote-probe.ts b/pi-ssh/src/remote-probe.ts new file mode 100644 index 0000000..1b022e1 --- /dev/null +++ b/pi-ssh/src/remote-probe.ts @@ -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 { + 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); +} diff --git a/pi-ssh/src/remote-search.ts b/pi-ssh/src/remote-search.ts index ac909c0..45d4097 100644 --- a/pi-ssh/src/remote-search.ts +++ b/pi-ssh/src/remote-search.ts @@ -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 { 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 { 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 { 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); } diff --git a/pi-ssh/src/ssh2-transport.ts b/pi-ssh/src/ssh2-transport.ts index b9bc26c..8f682be 100644 --- a/pi-ssh/src/ssh2-transport.ts +++ b/pi-ssh/src/ssh2-transport.ts @@ -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; + connect(signal?: AbortSignal): Promise; dispose(): Promise; 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 { + async connect(signal?: AbortSignal): Promise { 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((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 { - return this.queue.enqueue(async () => { + return this.sftpQueue.enqueue(async () => { const sftp = await this.sftp(); return new Promise((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 { - return this.queue.enqueue(async () => { + return this.sftpQueue.enqueue(async () => { const sftp = await this.sftp(); await new Promise((resolve, reject) => { sftp.open(remotePath, flags, (error, handle) => { @@ -267,7 +350,7 @@ export class Ssh2Transport implements RemoteTransport { } async mkdir(remoteDir: string): Promise { - return this.queue.enqueue(() => this.mkdirUnqueued(remoteDir)); + return this.sftpQueue.enqueue(() => this.mkdirUnqueued(remoteDir)); } private async mkdirUnqueued(remoteDir: string): Promise { @@ -290,7 +373,7 @@ export class Ssh2Transport implements RemoteTransport { } async writeFile(remotePath: string, content: Buffer): Promise { - 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`; diff --git a/pi-ssh/test/agent-connection.test.ts b/pi-ssh/test/agent-connection.test.ts index 2efae4d..ff5071c 100644 --- a/pi-ssh/test/agent-connection.test.ts +++ b/pi-ssh/test/agent-connection.test.ts @@ -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/); diff --git a/pi-ssh/test/config-and-import.test.ts b/pi-ssh/test/config-and-import.test.ts index fea7201..a32cdd9 100644 --- a/pi-ssh/test/config-and-import.test.ts +++ b/pi-ssh/test/config-and-import.test.ts @@ -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\(/); +}); diff --git a/pi-ssh/test/permission-integration.test.ts b/pi-ssh/test/permission-integration.test.ts index a17a362..6d3bad8 100644 --- a/pi-ssh/test/permission-integration.test.ts +++ b/pi-ssh/test/permission-integration.test.ts @@ -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); diff --git a/pi-ssh/test/remote-bash.test.ts b/pi-ssh/test/remote-bash.test.ts new file mode 100644 index 0000000..8bf5e13 --- /dev/null +++ b/pi-ssh/test/remote-bash.test.ts @@ -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 { return Promise.resolve(); } + dispose(): Promise { 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 { throw new Error("not used"); } + ensureReadable(): Promise { throw new Error("not used"); } + ensureReadableWritable(): Promise { throw new Error("not used"); } + detectImageMimeType(): Promise { throw new Error("not used"); } + mkdir(): Promise { throw new Error("not used"); } + writeFile(): Promise { 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/, + ); +}); diff --git a/pi-ssh/test/remote-cwd.test.ts b/pi-ssh/test/remote-cwd.test.ts new file mode 100644 index 0000000..ae31164 --- /dev/null +++ b/pi-ssh/test/remote-cwd.test.ts @@ -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 { return Promise.resolve(); } + dispose(): Promise { 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 { throw new Error("not used"); } + ensureReadable(): Promise { throw new Error("not used"); } + ensureReadableWritable(): Promise { throw new Error("not used"); } + detectImageMimeType(): Promise { throw new Error("not used"); } + mkdir(): Promise { throw new Error("not used"); } + writeFile(): Promise { 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"); +}); diff --git a/pi-ssh/test/remote-probe.test.ts b/pi-ssh/test/remote-probe.test.ts new file mode 100644 index 0000000..f7875f3 --- /dev/null +++ b/pi-ssh/test/remote-probe.test.ts @@ -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 { return Promise.resolve(); } + dispose(): Promise { 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 { throw new Error("not used"); } + ensureReadable(): Promise { throw new Error("not used"); } + ensureReadableWritable(): Promise { throw new Error("not used"); } + detectImageMimeType(): Promise { throw new Error("not used"); } + mkdir(): Promise { throw new Error("not used"); } + writeFile(): Promise { 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/); +}); diff --git a/pi-ssh/test/remote-search.test.ts b/pi-ssh/test/remote-search.test.ts index f946bbd..7b708eb 100644 --- a/pi-ssh/test/remote-search.test.ts +++ b/pi-ssh/test/remote-search.test.ts @@ -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 { 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 { 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[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 { diff --git a/pi-ssh/test/ssh2-transport.test.ts b/pi-ssh/test/ssh2-transport.test.ts index 930da36..1ecc07d 100644 --- a/pi-ssh/test/ssh2-transport.test.ts +++ b/pi-ssh/test/ssh2-transport.test.ts @@ -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((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((resolve) => setImmediate(resolve)); + assert.equal(channels.length, 4); + channels[0]?.emit("close", 0); + await new Promise((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((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(); diff --git a/pi-tool-search/CHANGELOG.md b/pi-tool-search/CHANGELOG.md index 20d8101..be842cb 100644 --- a/pi-tool-search/CHANGELOG.md +++ b/pi-tool-search/CHANGELOG.md @@ -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 diff --git a/pi-tool-search/README.md b/pi-tool-search/README.md index 0415dc1..6ab1c43 100644 --- a/pi-tool-search/README.md +++ b/pi-tool-search/README.md @@ -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 | diff --git a/pi-tool-search/docs/dynamic-tool-loading.md b/pi-tool-search/docs/dynamic-tool-loading.md index 4fb4fdb..9aa9f9c 100644 --- a/pi-tool-search/docs/dynamic-tool-loading.md +++ b/pi-tool-search/docs/dynamic-tool-loading.md @@ -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. diff --git a/pi-tool-search/extensions/bundle-groups.ts b/pi-tool-search/extensions/bundle-groups.ts index 9f3650a..626c606 100644 --- a/pi-tool-search/extensions/bundle-groups.ts +++ b/pi-tool-search/extensions/bundle-groups.ts @@ -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", diff --git a/tests/extension-load.test.ts b/tests/extension-load.test.ts index 859c75f..997f7b7 100644 --- a/tests/extension-load.test.ts +++ b/tests/extension-load.test.ts @@ -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 }; 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"); }); diff --git a/tests/tool-routing.test.ts b/tests/tool-routing.test.ts index 3432861..2858f3c 100644 --- a/tests/tool-routing.test.ts +++ b/tests/tool-routing.test.ts @@ -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/);