diff --git a/AGENTS.md b/AGENTS.md index 48ece38..9f7de1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ - 上游来源: - 初始导入快照:`d155d253cb2f1358e34e717d47a82ebccb08cb8e`(2026-07-03,`0.9.0`)。 - 该目录已纳入本仓库直接维护,不是 submodule,也不保留嵌套 `.git`。 -- `pi-ssh/`:从上游源码导入并由根组合包加载的纯 `ssh2` 远程操作扩展,使用持久连接、SFTP 与有界自适应搜索提供独立的 `ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 与 `ssh_bash` 工具;运行时不调用 OpenSSH 或 `sshpass`。 +- `pi-ssh/`:从上游源码导入并由根组合包加载的纯 `ssh2` 远程操作扩展,通过受权限链复核的 `ssh_connect` 建立 Agent 控制的持久连接,并以 SFTP 与有界自适应搜索提供独立的 `ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 与 `ssh_bash` 工具;运行时不调用 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_read`、`ssh_write`、`ssh_edit` 默认 `ask`;`ssh_bash` 通过 `shellTools` 映射到完整 Bash 策略并设置 `decisionFloor: "ask"`,把普通 Bash `allow` 提升为复核请求,同时保留原有 `ask` 与硬 `deny`。远端路径不得送入基于本机 cwd 的 `path` / `external_directory` 归一化,授权证据必须显示当前 SSH target、port 与远端 cwd。 +- SSH 权限沿用同一条 `pi-permission-system` gate 与 `auto-review` authorizer chain:`ssh_connect`、`ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 默认 `ask`;连接前的证据解析已导入 host ID 为非秘密 target、port 与请求/default cwd,AutoReview 只可依据用户直接消息中的明确目标授权连接。`ssh_bash` 通过 `shellTools` 映射到完整 Bash 策略并设置 `decisionFloor: "ask"`,把普通 Bash `allow` 提升为复核请求,同时保留原有 `ask` 与硬 `deny`。远端路径不得送入基于本机 cwd 的 `path` / `external_directory` 归一化。 - `pi-permission-auto-review` 不是独立 `tool_call` gate,而是 `pi-permission-system` authorizer chain 中名为 `auto-review` 的链路;只复核权限基线产生的 `ask`,不会重复处理已 `allow` 或已 `deny` 的请求。 - 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,12 +168,13 @@ - 根包必须直接固定安装 `ssh2@1.17.0` 与配置 CLI 所需的 `jiti@2.7.0`,因为 packed bundle 直接加载其内置 `pi-ssh/` 源码;`ssh2` 与 `cpu-features` 的 install scripts 只构建可选加速绑定,当前不得加入根 `allowScripts`,纯 JavaScript fallback 必须可运行。 - SSH vault 使用同目录独立随机 key 和 AES-256-GCM 整体加密,目录/文件在 POSIX 上必须保持 `700` / `600`;该设计只防止误看或单独泄漏密文,不防同一用户读取 key。密码、私钥 passphrase、私钥内容、vault key 与解密明文不得进入命令参数、日志、Pi session、权限证据或明文临时文件。 - 已导入主机必须固定 SHA256 Host Key;不匹配时 fail closed。私钥只保存路径,不复制内容;文件工具使用 SFTP,写入优先临时文件与原子 rename,远端断线不得自动重放命令。 +- `ssh_connect` 是唯一运行时连接入口,只接受 vault 中已导入的 host ID 与可选绝对/`~/` remote cwd,并必须默认 `ask` 后进入 AutoReview;不得注册 `/ssh`、`--ssh`、session resume 自动重连或把用户 `!` 命令切换到远端。用户未在直接请求中明确服务器和具体远端任务时不得推断、替换或连接主机;新连接替换旧连接,会话结束自动断开。 - `ssh_find` / `ssh_grep` 必须在已授权工具调用内部按 `fd/fdfind → git ls-files → find` 与 `rg → git grep → find+grep` 顺序检测服务器现有能力,不安装或上传远端二进制;输入必须 shell-safe,结果数量/单行/总捕获必须有界并显式报告 backend 与 truncated。RTK 不得处理这两个搜索工具的结果。 - `ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 的远端路径不能按本机路径执行 `path` / `external_directory` gate;必须通过公共 `PermissionsService` 注册显式 extractor 关闭默认 `input.path` 推断,并保持五个工具的默认策略为 `ask`。 - `ssh_bash` 必须在权威权限配置的 `shellTools` 中映射 `commandArgument: "command"` 并设置 `decisionFloor: "ask"`,复用 Bash 命令拆分、硬拒绝、风险 `ask` 和 authorizer chain;全局 floor 必须在项目配置字段级合并时保留,不得新增第二个并行 `tool_call` 审批层。 - RTK 只把 `ssh_bash` 当作 Bash 输出别名执行 ANSI 清理、测试/构建/Git/Lint 聚合与兜底截断;不得对远端命令启用 RTK rewrite,也不得处理 `ssh_read`、`ssh_find` 或 `ssh_grep` 返回。 -- SSH 工具的权限预览必须包含当前远端 host id、target、port、remote cwd 与有界操作摘要;远端 shell 的完整命令继续由 Bash payload 单独提供。权限服务缺失或注册失败时必须 fail-safe,不得转为无提示自动允许。 -- `pi-ssh` 只用于从本地项目显式操作服务器,不得自动探测、读取或向系统提示词注入远端 `AGENTS.md`、`CLAUDE.md` 或其他项目说明;远端内容只能由明确的 `ssh_*` 工具调用或用户命令获取。 +- SSH 工具的权限预览必须包含连接请求或当前远端的 host id、target、port、remote cwd 与有界操作摘要;连接预览只解密本地 vault 以提取非秘密目标字段,绝不包含凭据,远端 shell 的完整命令继续由 Bash payload 单独提供。权限服务缺失或注册失败时必须 fail-safe,不得转为无提示自动允许。 +- `pi-ssh` 只用于从本地项目显式操作服务器,不得自动探测、读取或向系统提示词注入远端 `AGENTS.md`、`CLAUDE.md` 或其他项目说明;远端内容只能由明确且已授权的 `ssh_*` 工具调用获取。 - 修改 SSH transport、vault、导入、权限集成或加载入口后至少运行 `pi-ssh` 包内 `npm test`、根脚本语法与 mock 配置测试、`pi-permission-system` 的 `npm run typecheck` / `npm run test` / `npm run build`、根扩展联合加载和实际 packed tarball 隔离安装验证。未经明确要求不得连接真实服务器或把真实密码写入测试。 - 根加载顺序必须保持 `pi-permission-auto-review` → `pi-permission-system` → `pi-ssh` → `pi-tool-search`,确保 authorizer 先注册、权限服务先发布、SSH 桥接随后安装且 Tool Search 最后收集完整工具目录。 diff --git a/README.md b/README.md index 4348399..1a321d9 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_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep`、`ssh_bash` 远端工具,支持导入 Key/密码主机、邻接 key 的 AES-GCM 配置 vault,并复用同一条权限与 auto-review 链。 +- 本仓库维护的 `pi-ssh`:通过纯 Node `ssh2` 持久连接、SFTP 与自适应有界搜索提供 `ssh_connect`、`ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep`、`ssh_bash`;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接。 - `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,并按当前激活工具追加简短的工具与搜索路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。 ## 安装、升级与卸载 @@ -198,7 +198,7 @@ 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`。在 Pi 中使用 `/ssh` 选择主机;所有 `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_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)。 diff --git a/config/pi-permission-system.json b/config/pi-permission-system.json index 17f32e4..13ce3d2 100644 --- a/config/pi-permission-system.json +++ b/config/pi-permission-system.json @@ -18,6 +18,7 @@ "grep": "allow", "find": "allow", "multi_grep": "allow", + "ssh_connect": "ask", "ssh_read": "ask", "ssh_write": "ask", "ssh_edit": "ask", diff --git a/extensions/tool-routing.ts b/extensions/tool-routing.ts index df04ec2..31400a6 100644 --- a/extensions/tool-routing.ts +++ b/extensions/tool-routing.ts @@ -44,6 +44,12 @@ export function buildToolRoutingSection(selectedTools: SelectedTools): string { ); } + if (hasTool(selectedTools, "ssh_connect")) { + rules.push( + "- Use ssh_connect only when the user explicitly names an imported host as part of a concrete remote task. Connect before calling other ssh_* tools; never infer or substitute a different host.", + ); + } + if (hasTool(selectedTools, "ssh_find") || hasTool(selectedTools, "ssh_grep")) { rules.push( "- For remote SSH searches, use ssh_find to narrow remote file paths before ssh_grep searches file contents. Keep path and limit bounded; when a result says truncated, narrow the path or pattern instead of increasing the limit. Do not run find, fd, grep, or rg through ssh_bash.", diff --git a/package-lock.json b/package-lock.json index b3e6227..155875b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7164,7 +7164,7 @@ } }, "pi-ssh": { - "version": "0.8.0", + "version": "0.9.0", "license": "MIT", "dependencies": { "jiti": "2.7.0", diff --git a/pi-ssh/CHANGELOG.md b/pi-ssh/CHANGELOG.md index dc31a53..98252e0 100644 --- a/pi-ssh/CHANGELOG.md +++ b/pi-ssh/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.9.0 - 2026-08-21 + +- Add reviewed, agent-callable `ssh_connect` for explicitly imported hosts. +- Remove the user-side `/ssh` command, `--ssh` startup flag, session-resume reconnect, and remote user-`!` override. +- Route connection authorization through the existing `pi-permission-system` and AutoReview chain with a non-secret target preview. +- Disconnect active transports at session shutdown and require an explicit reviewed connection in each session. + ## 0.8.0 - 2026-08-20 - Replace the OpenSSH subprocess, ControlMaster, and persistent PTY implementation with a pure `ssh2` transport. diff --git a/pi-ssh/README.md b/pi-ssh/README.md index e6724c2..2e20ae3 100644 --- a/pi-ssh/README.md +++ b/pi-ssh/README.md @@ -2,6 +2,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_read` - `ssh_write` - `ssh_edit` @@ -17,6 +18,8 @@ Runtime connections are pure `ssh2`; the extension does not spawn OpenSSH and do 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. +Connections are agent-controlled: when the user explicitly names an imported host as part of a concrete remote task, the model calls `ssh_connect`. The connection request enters the normal permission and auto-review chain before any network connection is opened. The extension does not register `/ssh`, `--ssh`, automatic session resume, or remote user-`!` overrides. + ProxyJump and ProxyCommand are intentionally rejected in the first ssh2 release. ## Configure hosts @@ -73,29 +76,23 @@ Private-key contents are not copied into the vault. ## Usage -Connect interactively: +Ask Pi for a concrete task on an imported host, for example: ```text -/ssh -/ssh packaging-server -/ssh packaging-server:/absolute/remote/path -/ssh status -/ssh off +Connect to packaging-server, inspect the API logs, and identify the recent 500 errors. ``` -Or at startup: +The model first calls: -```sh -pi --ssh packaging-server -pi --ssh packaging-server:/absolute/remote/path +```text +ssh_connect({ hostId: "packaging-server" }) ``` -Only imported host IDs are accepted. Arbitrary `user@host` targets are rejected. - -The active host ID and remote cwd are stored in the Pi session for resume. Credentials are never stored in Pi session entries. +It may set `remotePath` to an absolute path, `~`, or a path beginning with `~/`. After the reviewed connection succeeds, the model uses the other `ssh_*` tools to complete the requested work. Only imported host IDs are accepted; arbitrary `user@host` targets are rejected. A new connection replaces the previous active connection, and session shutdown disconnects it. ## Runtime behavior +- `ssh_connect` is the only runtime connection surface; it is agent-callable and permission-reviewed. - One persistent `ssh2` client is used for the active host. - Each `ssh_bash` call opens an exec channel and runs under `bash -lc` in the selected remote cwd. - SFTP provides remote reads and writes. @@ -128,6 +125,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_bash` uses the full deterministic Bash policy and `decisionFloor: "ask"`; - deterministic hard denies remain denies; @@ -143,7 +141,7 @@ Permission evidence includes the configured host ID, resolved endpoint, port, re - Treat both vault files as secrets even though `hosts.enc` is encrypted. - The extension's threat model does not protect credentials from malicious code already running as the same local user. - Password keyboard-interactive mode reuses the configured password for the server's prompts; use it only with a trusted pinned host. -- Remote content reaches the model only through an explicit `ssh_*` call or explicit user `!` command. +- Remote content reaches the model only through an explicit reviewed `ssh_*` tool call. ## Development diff --git a/pi-ssh/extension-spec.md b/pi-ssh/extension-spec.md index 370c70e..f80eb72 100644 --- a/pi-ssh/extension-spec.md +++ b/pi-ssh/extension-spec.md @@ -4,6 +4,7 @@ Pi and its default tools remain local. Explicit collision-free tools perform selected operations on one configured remote server: +- `ssh_connect` - `ssh_read` - `ssh_write` - `ssh_edit` @@ -26,13 +27,14 @@ ssh_config.sh import → encrypted vault is updated ``` -The resulting host ID is the only runtime selector: +The resulting host ID is the only runtime selector. When a direct user request names that imported host as part of a concrete remote task, the agent calls: ```text -/ssh [:/absolute/path] -pi --ssh [:/absolute/path] +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. + Arbitrary `user@host`, port overrides, ProxyJump, and ProxyCommand are not supported in the first pure-ssh2 version. Unsupported imported configuration is rejected rather than ignored. ## Vault @@ -71,7 +73,7 @@ One persistent `ssh2.Client` belongs to the active host. Connection loss fails c ### Shell -`ssh_bash` and explicit user `!` commands open 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 exec channels. Commands run through `bash -lc` after changing to the selected remote cwd. Output streams through the normal Pi Bash operations callback. Abort or timeout closes the channel without reconnecting or replaying. ### Files @@ -103,6 +105,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_bash` is a Bash-semantic `shellTools` alias with `decisionFloor: "ask"`. - Bash hard denies remain denies. @@ -111,8 +114,8 @@ There is one permission gate: `pi-permission-system`. Evidence includes configured host ID, endpoint, port, remote cwd, and a bounded operation summary, never credentials. -## Session and UI +## Session lifecycle -The session stores only host ID, remote cwd, and remote home. Resume reloads current vault data and establishes a new ssh2 connection; failures fall back to local mode. `/ssh off` disposes the client and clears the status line. +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. It does not include remote file content. +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. diff --git a/pi-ssh/index.ts b/pi-ssh/index.ts index 28c2747..1a0fb7c 100644 --- a/pi-ssh/index.ts +++ b/pi-ssh/index.ts @@ -1,6 +1,6 @@ import { homedir } from "node:os"; import { posix as posixPath } from "node:path"; -import type { CustomEntry, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { createBashTool, createEditTool, @@ -12,7 +12,15 @@ import { type WriteOperations, } from "@earendil-works/pi-coding-agent"; import { getPermissionsService, PERMISSIONS_READY_CHANNEL } from "@gotgenes/pi-permission-system"; -import { installSshPermissionIntegration } from "./permission-integration.ts"; +import { + installSshPermissionIntegration, + type SshPermissionConnection, +} from "./permission-integration.ts"; +import { + parseConnectInput, + SSH_CONNECT_TOOL_METADATA, + type HostSelection, +} from "./src/agent-connection.ts"; import { loadVault } from "./src/vault.ts"; import { Ssh2Transport, type RemoteTransport } from "./src/ssh2-transport.ts"; import type { PiSshConfig, SshHostConfig } from "./src/config.ts"; @@ -23,11 +31,6 @@ import { type RemoteGrepInput, } from "./src/remote-search.ts"; -interface SshStoredConfig { - hostId: string; - remoteCwd: string; - remoteHome: string; -} interface SshConnection { hostId: string; @@ -39,24 +42,6 @@ interface SshConnection { localHome: string; } -interface HostSelection { - hostId: string; - remotePath?: string; -} - -function parseHostSelection(raw: string): HostSelection { - const value = raw.trim(); - if (!value) throw new Error("SSH host id is required"); - const colon = value.indexOf(":"); - const hostId = (colon < 0 ? value : value.slice(0, colon)).trim(); - if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(hostId)) throw new Error(`invalid pi-ssh host id: ${hostId}`); - if (colon < 0) return { hostId }; - const remotePath = value.slice(colon + 1).trim(); - if (!remotePath || !(remotePath === "~" || remotePath.startsWith("~/") || remotePath.startsWith("/"))) { - throw new Error("remote path must be absolute or start with ~/"); - } - return { hostId, remotePath }; -} function mapLocalPathToRemote(path: string, connection: SshConnection): string { if (path === connection.localCwd) return connection.remoteCwd; @@ -163,22 +148,21 @@ async function connectSelection( } } -function pickerEntries(config: PiSshConfig): Array<{ value: string; hostId: string }> { - const groupByHost = new Map(); - for (const group of Object.values(config.groups ?? {})) { - for (const hostId of group.hosts) if (!groupByHost.has(hostId)) groupByHost.set(hostId, group.label); +function describeRequestedTarget(input: Record): SshPermissionConnection | null { + try { + const selection = parseConnectInput(input); + const host = getConfiguredHost(loadVault(), selection.hostId); + return { + remote: `${selection.hostId} [${host.user}@${host.hostName}]`, + port: host.port, + remoteCwd: selection.remotePath ?? host.defaultCwd ?? "", + }; + } catch { + return null; } - return Object.entries(config.hosts).map(([hostId, host]) => ({ - hostId, - value: `${groupByHost.get(hostId) ? `${groupByHost.get(hostId)} / ` : ""}${host.label ?? hostId} [${hostId}]`, - })); } export default function piSshExtension(pi: ExtensionAPI): void { - pi.registerFlag("ssh", { - description: "Configured pi-ssh host id, optionally followed by :/absolute/remote/path", - type: "string", - }); const localCwd = process.cwd(); const localHome = homedir(); @@ -193,11 +177,12 @@ export default function piSshExtension(pi: ExtensionAPI): void { installSshPermissionIntegration(pi, getConnection, { getPermissionsService, permissionsReadyChannel: PERMISSIONS_READY_CHANNEL, + getConnectTarget: describeRequestedTarget, }); const requireSsh = (toolName: string): { connection: SshConnection; transport: Ssh2Transport } => { if (!connection || !transport) { - throw new Error(`${toolName} requires an active SSH2 connection. Run /ssh and select an imported host.`); + throw new Error(`${toolName} requires an active SSH2 connection. Call ssh_connect with an imported host ID first.`); } return { connection, transport }; }; @@ -205,33 +190,31 @@ export default function piSshExtension(pi: ExtensionAPI): void { const activateConnection = async ( nextConnection: SshConnection, nextTransport: Ssh2Transport, - ctx: ExtensionContext, - options: { persist: boolean; verb: string }, ): Promise => { if (transport) await transport.dispose(); connection = nextConnection; transport = nextTransport; - if (options.persist) { - pi.appendEntry("pi-ssh-config", { - hostId: nextConnection.hostId, - remoteCwd: nextConnection.remoteCwd, - remoteHome: nextConnection.remoteHome, - } satisfies SshStoredConfig); - } - const message = `pi-ssh ${options.verb}: ${nextConnection.remote}:${nextConnection.remoteCwd} (port ${nextConnection.port})`; - console.log(message); - if (ctx.hasUI) { - ctx.ui.setStatus("pi-ssh", ctx.ui.theme.fg("accent", `SSH ${nextConnection.hostId}:${nextConnection.remoteCwd}`)); - ctx.ui.notify(message, "info"); - } + console.log(`pi-ssh connected: ${nextConnection.remote}:${nextConnection.remoteCwd} (port ${nextConnection.port})`); }; - const deactivateConnection = async (ctx: ExtensionContext): Promise => { - if (transport) await transport.dispose(); - transport = null; - connection = null; - if (ctx.hasUI) ctx.ui.setStatus("pi-ssh", undefined); - }; + pi.registerTool({ + ...SSH_CONNECT_TOOL_METADATA, + async execute(_id, params) { + const selection = parseConnectInput(params as Record); + const connected = await connectSelection(selection, localCwd, localHome); + await activateConnection(connected.connection, connected.transport); + const text = `Connected to ${connected.connection.remote}:${connected.connection.remoteCwd} (port ${connected.connection.port}).`; + return { + content: [{ type: "text", text }], + details: { + hostId: connected.connection.hostId, + remote: connected.connection.remote, + port: connected.connection.port, + remoteCwd: connected.connection.remoteCwd, + }, + }; + }, + }); pi.registerTool({ ...localRead, @@ -346,115 +329,12 @@ export default function piSshExtension(pi: ExtensionAPI): void { }, }); - pi.on("session_start", async (event, ctx) => { - const flag = pi.getFlag("ssh") as string | undefined; - if (flag) { - try { - const connected = await connectSelection(parseHostSelection(flag), localCwd, localHome); - await activateConnection(connected.connection, connected.transport, ctx, { persist: true, verb: "enabled" }); - return; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - await deactivateConnection(ctx); - console.error(`pi-ssh failed to connect: ${message}`); - if (ctx.hasUI) ctx.ui.notify(`pi-ssh failed to connect: ${message}`, "error"); - throw error; - } - } - - if (event.reason !== "startup" && event.reason !== "resume") return; - const entries = ctx.sessionManager.getEntries(); - let stored: SshStoredConfig | undefined; - for (let index = entries.length - 1; index >= 0; index -= 1) { - const entry = entries[index]; - if (entry.type === "custom" && (entry as CustomEntry).customType === "pi-ssh-config") { - stored = (entry as CustomEntry).data; - if (stored) break; - } - } - if (!stored) return; - try { - const connected = await connectSelection( - { hostId: stored.hostId, remotePath: stored.remoteCwd }, - localCwd, - localHome, - ); - await activateConnection(connected.connection, connected.transport, ctx, { persist: false, verb: "resumed" }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - await deactivateConnection(ctx); - console.error(`pi-ssh resume failed: ${message}`); - if (ctx.hasUI) ctx.ui.notify(`pi-ssh resume failed: ${message}`, "warning"); - } - }); - - pi.registerCommand("ssh", { - description: "Connect configured SSH2 hosts: /ssh [host-id[:/path]], /ssh status, /ssh off", - getArgumentCompletions: (prefix) => { - try { - const options = ["off", "status", ...Object.keys(loadVault().hosts)]; - const filtered = options.filter((option) => option.startsWith(prefix)); - return filtered.length > 0 ? filtered.map((option) => ({ value: option, label: option })) : null; - } catch { - return ["off", "status"].filter((option) => option.startsWith(prefix)).map((option) => ({ value: option, label: option })); - } - }, - handler: async (args, ctx) => { - const input = args.trim(); - if (input === "status") { - ctx.ui.notify(connection - ? `pi-ssh: ${connection.remote}:${connection.remoteCwd} (port ${connection.port})` - : "pi-ssh: not connected (local tools active)", "info"); - return; - } - if (input === "off") { - await deactivateConnection(ctx); - ctx.ui.notify("pi-ssh: disconnected", "info"); - return; - } - - let target = input; - if (!target) { - let config: PiSshConfig; - try { - config = loadVault(); - } catch (error) { - ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning"); - return; - } - const entries = pickerEntries(config); - if (entries.length === 0) { - ctx.ui.notify("No pi-ssh hosts configured. Run ssh_config.sh import.", "warning"); - return; - } - const values = [...(connection ? ["Disconnect [off]"] : []), ...entries.map((entry) => entry.value)]; - const selected = await ctx.ui.select("SSH2 host", values); - if (!selected) return; - if (selected === "Disconnect [off]") { - await deactivateConnection(ctx); - ctx.ui.notify("pi-ssh: disconnected", "info"); - return; - } - target = entries.find((entry) => entry.value === selected)?.hostId ?? ""; - } - - try { - const connected = await connectSelection(parseHostSelection(target), localCwd, localHome); - await activateConnection(connected.connection, connected.transport, ctx, { persist: true, verb: "connected" }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - ctx.ui.notify(`pi-ssh: failed to connect: ${message}`, "error"); - } - }, - }); - pi.on("session_shutdown", async () => { if (transport) await transport.dispose(); transport = null; + connection = null; }); - pi.on("user_bash", () => transport ? { operations: createRemoteBashOps(transport) } : undefined); - pi.on("before_agent_start", async (event) => { if (!connection) return; const guidance = @@ -463,7 +343,6 @@ export default function piSshExtension(pi: ExtensionAPI): void { `The default read/write/edit/bash/find/grep tools act on the LOCAL machine. ` + `Use ssh_read, ssh_write, ssh_edit, ssh_find, ssh_grep, and ssh_bash for explicit remote operations. ` + `Use ssh_find before ssh_grep to narrow remote searches; both tools return bounded results and choose the fastest available remote backend. ` + - `(User \`!\` commands run remotely.) ` + `Remote operations are rooted at ${connection.remoteCwd}; relative paths resolve against that directory.`; return { systemPrompt: `${event.systemPrompt}${guidance}` }; }); diff --git a/pi-ssh/package.json b/pi-ssh/package.json index 25abebb..ca023df 100644 --- a/pi-ssh/package.json +++ b/pi-ssh/package.json @@ -1,7 +1,7 @@ { "name": "pi-ssh", - "version": "0.8.0", - "description": "Explicit remote SSH tools for Pi using a pure ssh2 transport and encrypted host vault", + "version": "0.9.0", + "description": "Reviewed agent-controlled SSH tools for Pi using a pure ssh2 transport and encrypted host vault", "type": "module", "private": false, "main": "index.ts", diff --git a/pi-ssh/permission-integration.ts b/pi-ssh/permission-integration.ts index 0db4ded..56d45a0 100644 --- a/pi-ssh/permission-integration.ts +++ b/pi-ssh/permission-integration.ts @@ -10,6 +10,7 @@ export interface SshPermissionConnection { export interface SshPermissionIntegrationDependencies { getPermissionsService: () => PermissionsService | undefined; permissionsReadyChannel: string; + getConnectTarget?: (input: Record) => SshPermissionConnection | null; warn?: (message: string) => void; } @@ -17,7 +18,7 @@ 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 = [...REMOTE_FILE_TOOLS, "ssh_bash"] as const; +const REMOTE_TOOLS = ["ssh_connect", ...REMOTE_FILE_TOOLS, "ssh_bash"] as const; function inline(value: string, limit = 240): string { const normalized = value.replace(/\s+/g, " ").trim(); @@ -47,6 +48,13 @@ export function formatSshPermissionInput( const target = formatTarget(connection); const path = stringField(input, "path"); + if (toolName === "ssh_connect") { + if (connection !== null) return `${target}; establish a persistent SSH2 connection`; + const hostId = inline(stringField(input, "hostId") ?? ""); + const remotePath = stringField(input, "remotePath"); + return `requested imported SSH host '${hostId}'${remotePath ? ` in remote cwd '${inline(remotePath)}'` : ""}; establish a persistent SSH2 connection`; + } + if (toolName === "ssh_read") { const details = path ? [`remote path '${inline(path)}'`] : ["an unspecified remote path"]; if (typeof input.offset === "number") details.push(`offset ${input.offset}`); @@ -124,9 +132,12 @@ export function installSshPermissionIntegration( try { for (const toolName of REMOTE_TOOLS) { pending.push( - service.registerToolInputFormatter(toolName, (input) => - formatSshPermissionInput(toolName, input, getConnection()), - ), + service.registerToolInputFormatter(toolName, (input) => { + const target = toolName === "ssh_connect" + ? dependencies.getConnectTarget?.(input) ?? null + : getConnection(); + return formatSshPermissionInput(toolName, input, target); + }), ); } for (const toolName of REMOTE_FILE_TOOLS) { diff --git a/pi-ssh/src/agent-connection.ts b/pi-ssh/src/agent-connection.ts new file mode 100644 index 0000000..c7d00ef --- /dev/null +++ b/pi-ssh/src/agent-connection.ts @@ -0,0 +1,33 @@ +export interface HostSelection { + hostId: string; + remotePath?: string; +} + +export const SSH_CONNECT_TOOL_METADATA = { + name: "ssh_connect", + label: "ssh_connect", + description: "Establish a persistent SSH2 connection to an explicitly imported host. Use this when the user names a remote server as part of a concrete task; the connection request is reviewed before any network connection is opened.", + parameters: { + type: "object", + properties: { + hostId: { type: "string", description: "Imported pi-ssh host ID explicitly named by the user" }, + remotePath: { type: "string", description: "Optional remote cwd; must be absolute, ~, or start with ~/" }, + }, + required: ["hostId"], + additionalProperties: false, + }, +} as const; + +export function parseConnectInput(input: Record): HostSelection { + const hostId = typeof input.hostId === "string" ? input.hostId.trim() : ""; + if (!hostId) throw new Error("SSH host id is required"); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(hostId)) throw new Error(`invalid pi-ssh host id: ${hostId}`); + + if (input.remotePath === undefined) return { hostId }; + if (typeof input.remotePath !== "string") throw new Error("remote path must be a string"); + const remotePath = input.remotePath.trim(); + if (!remotePath || !(remotePath === "~" || remotePath.startsWith("~/") || remotePath.startsWith("/"))) { + throw new Error("remote path must be absolute or start with ~/"); + } + return { hostId, remotePath }; +} diff --git a/pi-ssh/test/agent-connection.test.ts b/pi-ssh/test/agent-connection.test.ts new file mode 100644 index 0000000..2efae4d --- /dev/null +++ b/pi-ssh/test/agent-connection.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { parseConnectInput, SSH_CONNECT_TOOL_METADATA } from "../src/agent-connection.ts"; + +test("defines the reviewed agent-controlled SSH connection tool", () => { + assert.equal(SSH_CONNECT_TOOL_METADATA.name, "ssh_connect"); + assert.deepEqual(SSH_CONNECT_TOOL_METADATA.parameters.required, ["hostId"]); + assert.ok(SSH_CONNECT_TOOL_METADATA.parameters.properties.remotePath); + assert.deepEqual(parseConnectInput({ hostId: " packaging-server " }), { hostId: "packaging-server" }); + assert.deepEqual(parseConnectInput({ hostId: "packaging-server", remotePath: "~/api" }), { + hostId: "packaging-server", + remotePath: "~/api", + }); + assert.throws(() => parseConnectInput({ hostId: "user@host" }), /invalid pi-ssh host id/); + assert.throws(() => parseConnectInput({ hostId: "packaging-server", remotePath: "relative" }), /remote path/); +}); + +test("removes manual and implicit SSH connection surfaces", async () => { + const source = await readFile(new URL("../index.ts", import.meta.url), "utf8"); + assert.match(source, /\.\.\.SSH_CONNECT_TOOL_METADATA/); + assert.doesNotMatch(source, /registerCommand\(["']ssh["']/); + assert.doesNotMatch(source, /registerFlag\(["']ssh["']/); + assert.doesNotMatch(source, /getFlag\(["']ssh["']/); + assert.doesNotMatch(source, /appendEntry\(["']pi-ssh-config["']/); + assert.doesNotMatch(source, /pi\.on\(["']user_bash["']/); +}); diff --git a/pi-ssh/test/permission-integration.test.ts b/pi-ssh/test/permission-integration.test.ts index ca1d8ae..a17a362 100644 --- a/pi-ssh/test/permission-integration.test.ts +++ b/pi-ssh/test/permission-integration.test.ts @@ -45,6 +45,17 @@ const connection: SshPermissionConnection = { remoteCwd: "/srv/build", }; +test("formats reviewed connection requests without exposing credentials", () => { + assert.equal( + formatSshPermissionInput("ssh_connect", { hostId: "packaging-server", remotePath: "/srv/build" }, connection), + "SSH target 'packaging-server:2222' in remote cwd '/srv/build'; establish a persistent SSH2 connection", + ); + assert.equal( + formatSshPermissionInput("ssh_connect", { hostId: "unknown" }, null), + "requested imported SSH host 'unknown'; establish a persistent SSH2 connection", + ); +}); + test("formats the SSH target and bounded operation details", () => { assert.equal( formatSshPermissionInput("ssh_read", { path: "src/main.ts", offset: 5, limit: 20 }, connection), @@ -66,14 +77,19 @@ test("registers previews and disables local path extraction for remote file tool const dispose = installSshPermissionIntegration( pi.api as never, () => connection, - { getPermissionsService: () => service, permissionsReadyChannel: "permissions:ready" }, + { + getPermissionsService: () => service, + permissionsReadyChannel: "permissions:ready", + getConnectTarget: () => connection, + }, ); - assert.deepEqual([...formatters.keys()], ["ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep", "ssh_bash"]); + assert.deepEqual([...formatters.keys()], ["ssh_connect", "ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep", "ssh_bash"]); assert.deepEqual([...extractors.keys()], ["ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"]); assert.equal(extractors.get("ssh_read")?.({ path: "/remote/secret" }), undefined); assert.equal(extractors.get("ssh_grep")?.({ path: "/remote/src" }), undefined); assert.match(formatters.get("ssh_bash")?.({ command: "git push" }) ?? "", /packaging-server:2222/); + assert.match(formatters.get("ssh_connect")?.({ hostId: "packaging-server" }) ?? "", /establish a persistent SSH2 connection/); dispose(); assert.equal(formatters.size, 0); @@ -92,7 +108,7 @@ test("registers when the permission service becomes ready and cleans up on shutd published = service; pi.emitEvent("permissions:ready"); - assert.equal(formatters.size, 6); + assert.equal(formatters.size, 7); assert.equal(extractors.size, 5); pi.emit("session_shutdown"); diff --git a/pi-tool-search/CHANGELOG.md b/pi-tool-search/CHANGELOG.md index 986ce87..20d8101 100644 --- a/pi-tool-search/CHANGELOG.md +++ b/pi-tool-search/CHANGELOG.md @@ -11,7 +11,7 @@ - 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-remote-files`, `ssh-remote-search`, and `ssh-remote-shell` groups for the maintained `pi-ssh` tools, keeping 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. ## [0.3.6] - 2026-04-24 diff --git a/pi-tool-search/README.md b/pi-tool-search/README.md index e370a84..0415dc1 100644 --- a/pi-tool-search/README.md +++ b/pi-tool-search/README.md @@ -27,6 +27,7 @@ Run `/tool-search-rebuild` to remove model enrichment. Standard bundle tools imm | Group | Bundle tools | |---|---| | `filesystem-navigation` | `ls`, `multi_grep` | +| `ssh-connection` | `ssh_connect` | | `ssh-remote-files` | `ssh_read`, `ssh_write`, `ssh_edit` | | `ssh-remote-search` | `ssh_find`, `ssh_grep` | | `ssh-remote-shell` | `ssh_bash` | diff --git a/pi-tool-search/docs/dynamic-tool-loading.md b/pi-tool-search/docs/dynamic-tool-loading.md index 22c37a8..4fb4fdb 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: 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: reviewed agent-controlled connection lives in `ssh-connection`, structured `ssh_read`/`ssh_write`/`ssh_edit` file operations live in `ssh-remote-files`, bounded `ssh_find`/`ssh_grep` discovery lives in `ssh-remote-search`, and the reviewed `ssh_bash` command surface stays isolated in `ssh-remote-shell`. A purely additive first load receives Pi's native dynamic-tool result propagation. A replacement that evicts one group while adding another is intentionally non-additive; current Pi detects the removal and uses its safe next-request fallback instead of attaching an invalid additive-only hint. diff --git a/pi-tool-search/extensions/bundle-groups.ts b/pi-tool-search/extensions/bundle-groups.ts index 181575b..9f3650a 100644 --- a/pi-tool-search/extensions/bundle-groups.ts +++ b/pi-tool-search/extensions/bundle-groups.ts @@ -31,6 +31,15 @@ export const BUNDLE_GROUP_DEFINITIONS: BundleGroupDefinition[] = [ keywords: ["files", "directory", "list", "multi grep", "目录", "文件", "多模式搜索"], tools: ["ls", "multi_grep"], }, + { + id: "ssh-connection", + title: "SSH connection", + summary: "Connect to an explicitly imported SSH host through the reviewed agent tool flow.", + useWhen: ["The user names a remote server as part of a concrete task and no matching SSH2 connection is active"], + avoidWhen: ["The task is local, the server was not explicitly named, or the host has not been imported"], + keywords: ["ssh", "connect", "server", "host", "remote", "连接服务器", "远程主机", "SSH"], + tools: ["ssh_connect"], + }, { id: "ssh-remote-files", title: "SSH remote files", diff --git a/tests/extension-load.test.ts b/tests/extension-load.test.ts index 3c54644..859c75f 100644 --- a/tests/extension-load.test.ts +++ b/tests/extension-load.test.ts @@ -33,6 +33,11 @@ test("all package extensions load together without global registration conflicts assert.equal(packageJson.dependencies.jiti, "2.7.0", "the packed helper must load local TypeScript outside Pi"); assert.ok(packageJson.files.includes("pi-ssh"), "the packed bundle must include the locally maintained SSH source"); assert.ok(packageJson.files.includes("ssh_config.sh"), "the packed bundle must include the SSH host import helper"); + const permissionConfig = JSON.parse( + await readFile(join(repositoryRoot, "config", "pi-permission-system.json"), "utf8"), + ) as { authorizerChain: string[]; permission: Record }; + assert.equal(permissionConfig.permission.ssh_connect, "ask", "SSH connection must enter the permission gate"); + assert.deepEqual(permissionConfig.authorizerChain, ["auto-review"], "SSH connection asks must reach AutoReview"); assert.ok( packageJson.pi.extensions.indexOf("./extensions/permission-system.ts") < packageJson.pi.extensions.indexOf("./pi-ssh/index.ts"), diff --git a/tests/tool-routing.test.ts b/tests/tool-routing.test.ts index 8199344..3432861 100644 --- a/tests/tool-routing.test.ts +++ b/tests/tool-routing.test.ts @@ -21,6 +21,13 @@ test("search routing narrows files before requesting matching line numbers", () assert.match(section, /Use read with offset\/limit only for the exact matching region/); }); +test("SSH connection routing requires an explicit named host and concrete task", () => { + const section = buildToolRoutingSection(["ssh_connect"]); + assert.match(section, /only when the user explicitly names an imported host/); + assert.match(section, /Connect before calling other ssh_\* tools/); + assert.match(section, /never infer or substitute a different host/); +}); + test("remote search routing uses structured bounded SSH tools", () => { const section = buildToolRoutingSection(["ssh_find", "ssh_grep"]); assert.match(section, /use ssh_find to narrow remote file paths before ssh_grep/);