feat(pi-ssh): support SSH agent authentication

This commit is contained in:
云服务部-叶林立
2026-08-26 20:01:45 +08:00
parent b7758b239e
commit 874d4da096
10 changed files with 98 additions and 22 deletions
+3 -3
View File
@@ -204,10 +204,10 @@
- 扩展入口是 `pi-ssh/index.ts``ssh2` transport 与 SFTP 实现在 `pi-ssh/src/ssh2-transport.ts`,有界自适应远程搜索位于 `pi-ssh/src/remote-search.ts`AES-GCM vault 位于 `pi-ssh/src/vault.ts`,选择性导入入口是根 `ssh_config.sh``pi-ssh/scripts/ssh-config.mjs`,权限桥接位于 `pi-ssh/permission-integration.ts`,测试保留在 `pi-ssh/test/`,来源记录是 `pi-ssh/UPSTREAM.md`
- 同步上游必须从明确 tag/commit 移植并保留 `LICENSE`、作者和来源;参考其他实现时记录明确 commit,不得导入嵌套 `.git``node_modules` 或构建产物。
- 运行时必须保持纯 `ssh2`,不得回退到系统 OpenSSH、`sshpass``SSH_ASKPASS` 或 ControlMasterOpenSSH 只允许由显式配置脚本通过 `ssh -G` 解析用户选中的 alias,已导入配置变化必须由用户显式更新。第一版遇到 `ProxyJump` / `ProxyCommand` 必须拒绝,不能静默忽略。
- 运行时必须保持纯 `ssh2`,不得回退到系统 OpenSSH、`sshpass``SSH_ASKPASS` 或 ControlMaster认证可使用密码、私钥文件或 `ssh2` 直连的 SSH Agent socket(包括 1Password),Agent 模式只保存 socket 路径且不得导出私钥。OpenSSH 只允许由显式配置脚本通过 `ssh -G` 解析用户选中的 alias,已导入配置变化必须由用户显式更新。第一版遇到 `ProxyJump` / `ProxyCommand` 必须拒绝,不能静默忽略。
- 根包必须直接固定安装 `ssh2@1.17.0` 与配置 CLI 所需的 `jiti@2.7.0`,因为 packed bundle 直接加载其内置 `pi-ssh/` 源码;`ssh2``cpu-features` 的 install scripts 只构建可选加速绑定,当前不得加入根 `allowScripts`,纯 JavaScript fallback 必须可运行。
- SSH vault 使用同目录独立随机 key 和 AES-256-GCM 整体加密,目录/文件在 POSIX 上必须保持 `700` / `600`;该设计只防止误看或单独泄漏密文,不防同一用户读取 key。密码、私钥 passphrase、私钥内容、vault key 与解密明文不得进入命令参数、日志、Pi session、权限证据或明文临时文件。
- 已导入主机必须固定 SHA256 Host Key;不匹配时 fail closed。私钥只保存路径,不复制内容;文件工具使用 SFTP,写入优先临时文件与原子 rename,远端断线不得自动重放命令。
- SSH vault 使用同目录独立随机 key 和 AES-256-GCM 整体加密,目录/文件在 POSIX 上必须保持 `700` / `600`;该设计只防止误看或单独泄漏密文,不防同一用户读取 key。密码、私钥 passphrase、私钥内容、Agent 提供的签名材料、vault key 与解密明文不得进入命令参数、日志、Pi session、权限证据或明文临时文件。
- 已导入主机必须固定 SHA256 Host Key;不匹配时 fail closed。私钥认证只保存文件路径,Agent 认证只保存 socket 路径,均不得复制或导出私钥内容;文件工具使用 SFTP,写入优先临时文件与原子 rename,远端断线不得自动重放命令。
- `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 与 truncated30 秒超时必须报告解析后的 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` 解析相对路径与 `~/`,同时显示请求值和解析后的绝对远端路径。
+1 -1
View File
@@ -211,7 +211,7 @@ FFF 仍负责精确字面搜索;RTK 继续压缩未走 Context Mode 的 Bash
./ssh_config.sh import packaging-server
```
脚本只在导入阶段调用 `ssh -G` 解析用户选中的 alias,随后交互选择 Key 或密码认证、显示并确认服务器 Host Key、测试连接,再把完整配置写入 `${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/pi-ssh/hosts.enc`。邻接的随机 `vault.key` 用于 AES-256-GCM 解密目录与两个文件在 POSIX 上分别是 `700` / `600`。该加密只避免误看或单独泄漏密文,能读取两文件的同一用户仍可解密。
脚本只在导入阶段调用 `ssh -G` 解析用户选中的 alias,随后交互选择 SSH Agent(包括 1Password)、私钥文件或密码认证、显示并确认服务器 Host Key、测试连接,再把完整配置写入 `${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/pi-ssh/hosts.enc`Agent 模式只保存 socket 路径,私钥仍留在 Agent 中;邻接的随机 `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 transport 在会话内持久,独立命令最多使用 4 路有界并发 exec channel,但每次 `ssh_bash` 都启动独立的非交互 Shell;需要持续改变后续远端操作的工作目录时,Agent 同样必须把受复核且串行执行的 `ssh_cd` 作为独立步骤调用并等待成功,而不是依赖某次 Shell 中临时执行的 `cd`
+7 -6
View File
@@ -15,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. The SSH transport persists, but commands intentionally use fresh non-interactive Bash processes rather than a hidden stateful PTY.
Runtime connections are pure `ssh2`; the extension does not spawn OpenSSH and does not require `sshpass`, `ControlMaster`, or passwordless login. Authentication can use a private-key file, a password, or an SSH agent socket such as 1Password's agent. Remote file operations use SFTP and remote shell commands use an SSH exec channel. The SSH transport persists, but commands intentionally use fresh non-interactive Bash processes rather than a hidden stateful PTY.
Hosts must be explicitly imported before use. OpenSSH remains only an import source: the configuration helper runs `ssh -G <alias>` once to resolve the selected alias, then stores the resulting endpoint and authentication data in the pi-ssh vault. Later changes to `~/.ssh/config` require re-importing the host.
@@ -35,9 +35,9 @@ From the installed bundle or this repository:
The helper:
1. lets you select concrete aliases from `~/.ssh/config`;
2. uses `ssh -G` to resolve HostName, User, Port, and IdentityFile;
3. asks whether the selected host uses a private key or password;
4. asks for a private-key passphrase when needed;
2. uses `ssh -G` to resolve HostName, User, Port, IdentityFile, and IdentityAgent;
3. asks whether the selected host uses an SSH agent, private key, or password;
4. validates the selected agent socket or asks for a private-key passphrase when needed;
5. obtains and displays the server's SHA256 host-key fingerprint;
6. connects with `ssh2` to verify authentication;
7. optionally assigns a display label and group;
@@ -68,12 +68,13 @@ On POSIX systems the directory is mode `700` and both files are mode `600`. `hos
This is deliberately a **casual-disclosure boundary**, not protection against compromise of the local account: anyone who can read both files can decrypt the vault. Encryption prevents the host configuration and passwords from being exposed by accidentally viewing or copying `hosts.enc` alone.
The encrypted payload contains host endpoints, pinned host-key fingerprints, groups, and either:
The encrypted payload contains host endpoints, pinned host-key fingerprints, groups, and one of:
- an SSH agent socket path;
- a private-key path plus optional passphrase; or
- the server password.
Private-key contents are not copied into the vault.
Private-key contents are not copied into the vault. Agent-backed private keys remain inside the agent (including 1Password); pi-ssh stores only the socket path and asks `ssh2` to authenticate through it.
## Usage
+13 -4
View File
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
import { stdin, stdout } from "node:process";
import readline from "node:readline/promises";
import {
effectiveIdentityAgent,
effectiveValue,
effectiveValues,
listDirectSshAliases,
@@ -106,11 +107,19 @@ async function importHost(config, alias) {
const identityFiles = effectiveValues(effective, "identityfile")
.map((path) => path.replace(/^"|"$/g, ""))
.filter((path) => existsSync(expandUserPath(path)));
const defaultMode = identityFiles.length > 0 ? "key" : "password";
const modeInput = (await question(`Authentication [key/password] (${defaultMode}): `)).toLowerCase();
const agentSocket = effectiveIdentityAgent(effective);
const agentAvailable = agentSocket && (agentSocket === "pageant" || existsSync(expandUserPath(agentSocket)));
const defaultMode = agentAvailable ? "agent" : identityFiles.length > 0 ? "key" : "password";
const modeInput = (await question(`Authentication [agent/key/password] (${defaultMode}): `)).toLowerCase();
const mode = modeInput || defaultMode;
let auth;
if (mode === "key") {
if (mode === "agent") {
const socketPath = await question(`SSH agent socket${agentSocket ? ` (${agentSocket})` : ""}: `) || agentSocket;
if (!socketPath || (socketPath !== "pageant" && !existsSync(expandUserPath(socketPath)))) {
throw new Error(`SSH agent socket does not exist: ${socketPath ?? ""}`);
}
auth = { type: "agent", socketPath };
} else if (mode === "key") {
const suggested = identityFiles[0] ?? "";
const identityFile = await question(`Private key path${suggested ? ` (${suggested})` : ""}: `) || suggested;
if (!identityFile || !existsSync(expandUserPath(identityFile))) throw new Error(`private key does not exist: ${identityFile}`);
@@ -123,7 +132,7 @@ async function importHost(config, alias) {
if (!password) throw new Error("server password cannot be empty");
auth = { type: "password", password, method: "auto" };
} else {
throw new Error("authentication must be key or password");
throw new Error("authentication must be agent, key, or password");
}
console.log("Obtaining SSH host key fingerprint...");
+13 -2
View File
@@ -13,7 +13,12 @@ export interface PrivateKeyAuthConfig {
passphrase?: string;
}
export type SshAuthConfig = PasswordAuthConfig | PrivateKeyAuthConfig;
export interface AgentAuthConfig {
type: "agent";
socketPath: string;
}
export type SshAuthConfig = PasswordAuthConfig | PrivateKeyAuthConfig | AgentAuthConfig;
export interface SshHostConfig {
label?: string;
@@ -108,7 +113,13 @@ function validateAuth(value: unknown, name: string): SshAuthConfig {
...(auth.passphrase === undefined ? {} : { passphrase: nonEmptyString(auth.passphrase, `${name}.passphrase`) }),
};
}
throw new Error(`${name}.type must be password or private-key`);
if (auth.type === "agent") {
return {
type: "agent",
socketPath: nonEmptyString(auth.socketPath, `${name}.socketPath`),
};
}
throw new Error(`${name}.type must be password, private-key, or agent`);
}
function validateHost(value: unknown, name: string): SshHostConfig {
+12
View File
@@ -37,6 +37,18 @@ export function effectiveValues(config: EffectiveSshConfig, key: string): string
return config.get(key.toLowerCase()) ?? [];
}
export function effectiveIdentityAgent(
config: EffectiveSshConfig,
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
const configured = effectiveValue(config, "identityagent")?.replace(/^"|"$/g, "");
if (!configured || configured.toLowerCase() === "none") return undefined;
if (configured === "SSH_AUTH_SOCK" || configured === "$SSH_AUTH_SOCK" || configured === "${SSH_AUTH_SOCK}") {
return env.SSH_AUTH_SOCK;
}
return configured;
}
export function listDirectSshAliases(configPath = join(homedir(), ".ssh", "config")): string[] {
if (!existsSync(configPath)) return [];
const aliases: string[] = [];
+1
View File
@@ -8,6 +8,7 @@ declare module "ssh2" {
username: string;
password?: string;
privateKey?: Buffer | string;
agent?: string;
passphrase?: string;
tryKeyboard?: boolean;
readyTimeout?: number;
+3 -1
View File
@@ -128,9 +128,11 @@ function buildConnectConfig(host: SshHostConfig): ConnectConfig {
if (host.auth.type === "password") {
config.password = host.auth.password;
config.tryKeyboard = host.auth.method !== "password";
} else {
} else if (host.auth.type === "private-key") {
config.privateKey = readFileSync(expandUserPath(host.auth.identityFile));
if (host.auth.passphrase) config.passphrase = host.auth.passphrase;
} else {
config.agent = host.auth.socketPath === "pageant" ? "pageant" : expandUserPath(host.auth.socketPath);
}
return config;
}
+32 -4
View File
@@ -3,7 +3,7 @@ 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";
import { effectiveIdentityAgent, effectiveValue, effectiveValues, parseSshG } from "../src/import.ts";
import { fingerprintHostKey } from "../src/ssh2-transport.ts";
test("parses the effective ssh -G output including repeated identity files", () => {
@@ -21,7 +21,19 @@ test("parses the effective ssh -G output including repeated identity files", ()
assert.deepEqual(effectiveValues(parsed, "identityfile"), ["~/.ssh/first", "~/.ssh/second"]);
});
test("validates password and private-key host definitions", () => {
test("resolves IdentityAgent socket paths and SSH_AUTH_SOCK references", () => {
assert.equal(
effectiveIdentityAgent(parseSshG("identityagent /Users/test/.1password/agent.sock"), {}),
"/Users/test/.1password/agent.sock",
);
assert.equal(
effectiveIdentityAgent(parseSshG("identityagent $SSH_AUTH_SOCK"), { SSH_AUTH_SOCK: "/tmp/agent.sock" }),
"/tmp/agent.sock",
);
assert.equal(effectiveIdentityAgent(parseSshG("identityagent none"), { SSH_AUTH_SOCK: "/tmp/agent.sock" }), undefined);
});
test("validates password, private-key, and agent host definitions", () => {
const password = validatePiSshConfig({
version: 1,
hosts: {
@@ -37,6 +49,20 @@ test("validates password and private-key host definitions", () => {
});
assert.equal(password.hosts.build.auth.type, "password");
const agent = validatePiSshConfig({
version: 1,
hosts: {
build: {
hostName: "build.example.test",
user: "builder",
port: 22,
auth: { type: "agent", socketPath: "~/.1password/agent.sock" },
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:value" },
},
},
});
assert.deepEqual(agent.hosts.build.auth, { type: "agent", socketPath: "~/.1password/agent.sock" });
assert.throws(() => validatePiSshConfig({
version: 1,
hosts: {
@@ -44,11 +70,11 @@ test("validates password and private-key host definitions", () => {
hostName: "build.example.test",
user: "builder",
port: 22,
auth: { type: "password", password: "" },
auth: { type: "agent", socketPath: "" },
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:value" },
},
},
}), /password/);
}), /socketPath/);
});
test("formats SSH host keys as pinned SHA256 fingerprints", () => {
@@ -68,4 +94,6 @@ test("configuration import validates remote HOME and cwd with framed probes", as
assert.match(source, /probeRemotePath\(transport, "home"\)/);
assert.match(source, /probeRemotePath\(transport, "cwd"\)/);
assert.doesNotMatch(source, /transport\.capture\(/);
assert.match(source, /Authentication \[agent\/key\/password\]/);
assert.match(source, /effectiveIdentityAgent\(effective\)/);
});
+13 -1
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import type { Client, ClientChannel, ConnectConfig, SFTPWrapper } from "ssh2";
@@ -235,6 +235,18 @@ test("loads private-key authentication and forwards the passphrase", async () =>
}
});
test("forwards SSH agent authentication through ssh2", async () => {
const host: SshHostConfig = {
...passwordHost(),
auth: { type: "agent", socketPath: "~/.1password/agent.sock" },
};
const { fake, transport } = await connectedTransport(undefined, host);
assert.equal(fake.connectConfig?.agent, join(homedir(), ".1password", "agent.sock"));
assert.equal(fake.connectConfig?.password, undefined);
assert.equal(fake.connectConfig?.privateKey, undefined);
await transport.dispose();
});
test("reads files, checks access modes, detects images, and reuses SFTP", async () => {
const sftp = new FakeSftp();
sftp.files.set("/srv/app/a.txt", Buffer.from("hello"));