feat: add pure ssh2 remote operations

This commit is contained in:
云服务部-叶林立
2026-08-21 19:51:43 +08:00
parent d3bf562189
commit 0ac50eb581
62 changed files with 3701 additions and 47 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
.DS_Store
.pi/
*.log
+16
View File
@@ -0,0 +1,16 @@
# Changelog
## 0.8.0 - 2026-08-20
- Replace the OpenSSH subprocess, ControlMaster, and persistent PTY implementation with a pure `ssh2` transport.
- Add password, keyboard-interactive, private-key, and encrypted-private-key authentication.
- Add selective `ssh -G` host import through the root `ssh_config.sh` helper.
- Add an AES-256-GCM host vault with an adjacent owner-only random key.
- Pin and verify SHA256 SSH host-key fingerprints.
- Move remote file operations to SFTP and keep dedicated collision-free `ssh_*` tools.
- Restrict runtime connections to explicitly imported host IDs and reject ProxyJump/ProxyCommand in this release.
- Remove automatic remote project instruction discovery and preserve the existing permission-system/auto-review integration.
- Add reviewed `ssh_find` and `ssh_grep` tools with bounded `fd`/Git/POSIX fallback pipelines and no remote installation.
- Route `ssh_bash` output through RTK's Bash compaction alias while leaving remote command rewriting and structured search/read output disabled.
- Preserve adaptive-search backend exit status through bounded pipelines, treating genuine no-match results as success while surfacing invalid regexes, missing roots, and backend failures.
- Add regression coverage for private-key/passphrase and keyboard-interactive authentication, pinned-key probing, SFTP reads/access/writes/rename fallback, image detection, aborts, timeouts, and disconnect fail-closed behavior.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Helmut Januschka
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+166
View File
@@ -0,0 +1,166 @@
# pi-ssh
`pi-ssh` keeps Pi and its local tools on the local machine while exposing explicit remote tools over a persistent Node `ssh2` connection:
- `ssh_read`
- `ssh_write`
- `ssh_edit`
- `ssh_find`
- `ssh_grep`
- `ssh_bash`
The extension does not override Pi's local `read`, `write`, `edit`, `find`, `grep`, or `bash` tools.
## 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.
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.
ProxyJump and ProxyCommand are intentionally rejected in the first ssh2 release.
## Configure hosts
From the installed bundle or this repository:
```sh
./ssh_config.sh import
./ssh_config.sh import packaging-server
```
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;
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;
8. writes the encrypted vault.
Other commands:
```sh
./ssh_config.sh list
./ssh_config.sh update packaging-server
./ssh_config.sh remove packaging-server
./ssh_config.sh rotate-key
```
Secret prompts require an interactive terminal. Passwords and passphrases are never passed as command-line arguments.
## Vault
The default paths are:
```text
${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/pi-ssh/
├── hosts.enc
└── vault.key
```
On POSIX systems the directory is mode `700` and both files are mode `600`. `hosts.enc` is encrypted and authenticated with AES-256-GCM; `vault.key` contains the adjacent random 256-bit key. Writes use a fresh IV and an atomic temporary-file rename. Plaintext configuration is never written to a temporary file.
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:
- a private-key path plus optional passphrase; or
- the server password.
Private-key contents are not copied into the vault.
## Usage
Connect interactively:
```text
/ssh
/ssh packaging-server
/ssh packaging-server:/absolute/remote/path
/ssh status
/ssh off
```
Or at startup:
```sh
pi --ssh packaging-server
pi --ssh packaging-server:/absolute/remote/path
```
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.
## Runtime behavior
- 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.
- 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.
- RTK treats only `ssh_bash` as a Bash output-compaction alias; it does not rewrite remote commands or process remote search/read results.
### Adaptive remote search
`ssh_find` performs fixed-substring path matching with this backend order:
```text
fd → fdfind → git ls-files → find
```
`ssh_grep` defaults to literal, case-insensitive content matching with this backend order:
```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.
The remote host must provide `bash`. SFTP support is required for file tools.
## Permission-system integration
All remote operations enter the bundle's existing permission chain:
- `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;
- reviewer failures defer to the normal terminal prompt.
Permission evidence includes the configured host ID, resolved endpoint, port, remote cwd, and a bounded operation summary. It never includes passwords, passphrases, private-key contents, or the vault key. Remote paths are not normalized as local filesystem paths.
## Security notes
- Import only servers you control or trust.
- Verify host-key fingerprints through an independent channel before accepting them.
- 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.
## Development
```sh
npm test
```
Important files:
- `index.ts` — Pi extension and tool registration
- `src/ssh2-transport.ts` — persistent ssh2, exec, and SFTP transport
- `src/config.ts` — validated configuration types
- `src/vault.ts` — AES-GCM vault
- `src/import.ts``ssh -G` import helpers
- `scripts/ssh-config.mjs` — interactive configuration CLI
- `permission-integration.ts` — permission-system bridge
## Upstream and license
This maintained fork originates from `pansapiens/pi-ssh`; see [UPSTREAM.md](UPSTREAM.md). The pure ssh2 design also references the transport architecture in `@99percentpeople/pi-ssh-remote` without adopting its local-tool override model. Licensed under MIT; see [LICENSE](LICENSE).
+12
View File
@@ -0,0 +1,12 @@
# Upstream Source
This directory was imported from the source repository and is maintained directly in `my-pi`.
- Upstream: <https://github.com/pansapiens/pi-ssh>
- Initial snapshot: `e9a1059a0f37ab14b6a73ee608cb203edf803f31`
- Upstream version: `0.7.0`
- Snapshot date: 2026-06-23
Pure-`ssh2` connection management was designed with reference to `@99percentpeople/pi-ssh-remote` in <https://github.com/99percentpeople/pi-extensions> at commit `fe4c44f3b4d4b52bd2c31d6d4d5a833c4ad60d9d`. The local implementation keeps this fork's dedicated `ssh_*` tools and does not import that extension's local-tool override or OpenSSH fallback model.
The directory is not a Git submodule and intentionally contains no nested `.git`, `node_modules`, or generated build artifacts. Future upstream changes must be reviewed and ported from an explicit tag or commit without overwriting local modifications.
+118
View File
@@ -0,0 +1,118 @@
# pi-ssh extension specification
## Goal
Pi and its default tools remain local. Explicit collision-free tools perform selected operations on one configured remote server:
- `ssh_read`
- `ssh_write`
- `ssh_edit`
- `ssh_find`
- `ssh_grep`
- `ssh_bash`
The extension is for operating servers from local projects, not for replacing Pi's local workspace. It never auto-loads remote project instructions.
## Connection model
Runtime communication uses Node `ssh2` only. OpenSSH is not spawned during Pi sessions. A user must explicitly import a concrete OpenSSH alias before connecting:
```text
ssh_config.sh import <alias>
→ ssh -G <alias>
→ user chooses private-key or password auth
→ ssh2 obtains host key, user confirms fingerprint
→ ssh2 verifies authentication
→ encrypted vault is updated
```
The resulting host ID is the only runtime selector:
```text
/ssh <host-id>[:/absolute/path]
pi --ssh <host-id>[:/absolute/path]
```
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
Default paths:
```text
${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/pi-ssh/hosts.enc
${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/pi-ssh/vault.key
```
The directory is mode `700` and files are mode `600` on POSIX. A random adjacent 256-bit key encrypts and authenticates the complete validated configuration with AES-256-GCM. Each write uses a fresh IV and atomic replacement; plaintext is never written to a temporary file.
The adjacent-key design prevents casual disclosure of `hosts.enc` alone but does not protect against code able to read both files as the same local user.
The encrypted payload includes endpoint, authentication data, host groups, default cwd, and a pinned SHA256 host-key fingerprint. Private keys remain in their original paths; only their path and optional passphrase are stored.
## Authentication
Supported methods:
- password;
- single-password keyboard-interactive;
- OpenSSH private key;
- encrypted private key with passphrase.
Passwords, passphrases, private-key contents, vault key, and decrypted configuration must never enter command arguments, logs, permission evidence, system prompts, or Pi session entries.
## Host-key verification
Import displays the observed host-key algorithm and SHA256 fingerprint for explicit confirmation. Runtime `hostVerifier` must compare the server key with the pinned fingerprint and fail closed on any mismatch. Key changes require explicit host update.
## Transport
One persistent `ssh2.Client` belongs to the active host. Connection loss fails closed and no operation is automatically replayed.
### 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.
### Files
SFTP implements file operations:
- read and access checks;
- recursive directory creation;
- remote write through a temporary file;
- atomic OpenSSH rename extension when available;
- safe direct-write fallback when SFTP v3 cannot replace an existing target.
Remote paths map from Pi's local factory cwd into the selected remote cwd, but permission-system access extractors prevent those paths from entering local path normalization.
### Search
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 1200; each returned line is capped.
- relative paths resolve against remote cwd and `~/` against remote home.
- user strings are single-quoted as shell arguments and NUL/newline input is rejected.
- capability detection occurs only within the reviewed search call.
- search output is normalized by `pi-ssh` and excluded from RTK compaction.
Direct search commands remain denied through `ssh_bash`; structured search tools are the authoritative remote search surface.
## Permission boundary
There is one permission gate: `pi-permission-system`.
- `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.
- All asks enter the configured authorizer chain.
- Permission service absence or bridge registration failure must not install a permissive fallback.
Evidence includes configured host ID, endpoint, port, remote cwd, and a bounded operation summary, never credentials.
## Session and UI
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.
The system prompt states that default tools are local and `ssh_*` tools are remote. It does not include remote file content.
+470
View File
@@ -0,0 +1,470 @@
import { homedir } from "node:os";
import { posix as posixPath } from "node:path";
import type { CustomEntry, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import {
createBashTool,
createEditTool,
createReadTool,
createWriteTool,
type BashOperations,
type EditOperations,
type ReadOperations,
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 { loadVault } from "./src/vault.ts";
import { Ssh2Transport, type RemoteTransport } from "./src/ssh2-transport.ts";
import type { PiSshConfig, SshHostConfig } from "./src/config.ts";
import {
runRemoteFind,
runRemoteGrep,
type RemoteFindInput,
type RemoteGrepInput,
} from "./src/remote-search.ts";
interface SshStoredConfig {
hostId: string;
remoteCwd: string;
remoteHome: string;
}
interface SshConnection {
hostId: string;
remote: string;
port: number;
remoteCwd: string;
remoteHome: string;
localCwd: string;
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;
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 {
readFile: (absolutePath) => transport.readFile(mapLocalPathToRemote(absolutePath, connection)),
access: (absolutePath) => transport.ensureReadable(mapLocalPathToRemote(absolutePath, connection)),
detectImageMimeType: async (absolutePath) => {
try {
return await transport.detectImageMimeType(mapLocalPathToRemote(absolutePath, connection));
} catch {
return null;
}
},
};
}
function createRemoteWriteOps(connection: SshConnection, transport: RemoteTransport): WriteOperations {
return {
mkdir: (absoluteDir) => transport.mkdir(mapLocalPathToRemote(absoluteDir, connection)),
writeFile: (absolutePath, content) =>
transport.writeFile(mapLocalPathToRemote(absolutePath, connection), Buffer.from(content, "utf8")),
};
}
function createRemoteEditOps(connection: SshConnection, transport: RemoteTransport): EditOperations {
const read = createRemoteReadOps(connection, transport);
const write = createRemoteWriteOps(connection, transport);
return {
readFile: read.readFile,
writeFile: write.writeFile,
access: (absolutePath) => transport.ensureReadableWritable(mapLocalPathToRemote(absolutePath, connection)),
};
}
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;
if (requested === "~") return remoteHome;
if (requested.startsWith("~/")) return posixPath.join(remoteHome, requested.slice(2));
return requested;
}
async function captureChecked(transport: RemoteTransport, command: string, cwd = "."): Promise<string> {
const result = await transport.capture(command, cwd, 20);
if (result.exitCode !== 0) {
const message = result.output.toString("utf8").trim();
throw new Error(message || `remote command failed with exit code ${result.exitCode}`);
}
return result.output.toString("utf8").trim();
}
async function connectSelection(
selection: HostSelection,
localCwd: string,
localHome: string,
): 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");
const requestedPath = resolveRequestedPath(selection, host, remoteHome, remotePwd);
const remoteCwd = await captureChecked(transport, "pwd", requestedPath);
return {
connection: {
hostId: selection.hostId,
remote: `${selection.hostId} [${host.user}@${host.hostName}]`,
port: host.port,
remoteCwd,
remoteHome,
localCwd,
localHome,
},
transport,
};
} catch (error) {
await transport.dispose();
throw error;
}
}
function pickerEntries(config: PiSshConfig): Array<{ value: string; hostId: string }> {
const groupByHost = new Map<string, string>();
for (const group of Object.values(config.groups ?? {})) {
for (const hostId of group.hosts) if (!groupByHost.has(hostId)) groupByHost.set(hostId, group.label);
}
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();
const localRead = createReadTool(localCwd);
const localWrite = createWriteTool(localCwd);
const localEdit = createEditTool(localCwd);
const localBash = createBashTool(localCwd);
let connection: SshConnection | null = null;
let transport: Ssh2Transport | null = null;
const getConnection = () => connection;
installSshPermissionIntegration(pi, getConnection, {
getPermissionsService,
permissionsReadyChannel: PERMISSIONS_READY_CHANNEL,
});
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.`);
}
return { connection, transport };
};
const activateConnection = async (
nextConnection: SshConnection,
nextTransport: Ssh2Transport,
ctx: ExtensionContext,
options: { persist: boolean; verb: string },
): Promise<void> => {
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");
}
};
const deactivateConnection = async (ctx: ExtensionContext): Promise<void> => {
if (transport) await transport.dispose();
transport = null;
connection = null;
if (ctx.hasUI) ctx.ui.setStatus("pi-ssh", undefined);
};
pi.registerTool({
...localRead,
name: "ssh_read",
label: "ssh_read",
description: `Read a file through the active SSH2 connection. ${localRead.description}`,
async execute(id, params, signal, onUpdate) {
const active = requireSsh("ssh_read");
return createReadTool(localCwd, { operations: createRemoteReadOps(active.connection, active.transport) })
.execute(id, params, signal, onUpdate);
},
});
pi.registerTool({
...localWrite,
name: "ssh_write",
label: "ssh_write",
description: `Write a file through the active SSH2 connection. ${localWrite.description}`,
async execute(id, params, signal, onUpdate) {
const active = requireSsh("ssh_write");
return createWriteTool(localCwd, { operations: createRemoteWriteOps(active.connection, active.transport) })
.execute(id, params, signal, onUpdate);
},
});
pi.registerTool({
...localEdit,
name: "ssh_edit",
label: "ssh_edit",
description: `Edit a file through the active SSH2 connection. ${localEdit.description}`,
async execute(id, params, signal, onUpdate) {
const active = requireSsh("ssh_edit");
return createEditTool(localCwd, { operations: createRemoteEditOps(active.connection, active.transport) })
.execute(id, params, signal, onUpdate);
},
});
pi.registerTool({
name: "ssh_find",
label: "ssh_find",
description: "Find remote files by a fixed filename/path substring using fd, git ls-files, or find. Results are bounded and require an active SSH2 connection.",
parameters: {
type: "object",
properties: {
pattern: { type: "string", description: "Fixed substring to match in remote file paths" },
path: { type: "string", description: "Remote root path; defaults to the active remote cwd" },
caseSensitive: { type: "boolean", description: "Use case-sensitive matching (default: false)" },
includeHidden: { type: "boolean", description: "Include hidden paths (default: false)" },
limit: { type: "integer", minimum: 1, maximum: 200, description: "Maximum results (default: 50)" },
},
required: ["pattern"],
additionalProperties: false,
},
async execute(_id, params, signal) {
const active = requireSsh("ssh_find");
const result = await runRemoteFind(
active.transport,
params as RemoteFindInput,
active.connection.remoteCwd,
active.connection.remoteHome,
signal,
);
return {
content: [{ type: "text", text: result.text }],
details: { backend: result.backend, matchCount: result.matchCount, truncated: result.truncated },
};
},
});
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.",
parameters: {
type: "object",
properties: {
pattern: { type: "string", description: "Text or regular expression to search for" },
path: { type: "string", description: "Remote root path; defaults to the active remote cwd" },
literal: { type: "boolean", description: "Treat pattern as fixed text (default: true)" },
caseSensitive: { type: "boolean", description: "Use case-sensitive matching (default: false)" },
include: { type: "string", description: "Optional file glob such as *.ts" },
limit: { type: "integer", minimum: 1, maximum: 200, description: "Maximum result lines (default: 50)" },
},
required: ["pattern"],
additionalProperties: false,
},
async execute(_id, params, signal) {
const active = requireSsh("ssh_grep");
const result = await runRemoteGrep(
active.transport,
params as RemoteGrepInput,
active.connection.remoteCwd,
active.connection.remoteHome,
signal,
);
return {
content: [{ type: "text", text: result.text }],
details: { backend: result.backend, matchCount: result.matchCount, truncated: result.truncated },
};
},
});
pi.registerTool({
...localBash,
name: "ssh_bash",
label: "ssh_bash",
description: `Run a shell command through the active SSH2 connection. ${localBash.description}`,
async execute(id, params, signal, onUpdate) {
const active = requireSsh("ssh_bash");
return createBashTool(localCwd, { operations: createRemoteBashOps(active.transport) })
.execute(id, params, signal, onUpdate);
},
});
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<unknown>).customType === "pi-ssh-config") {
stored = (entry as CustomEntry<SshStoredConfig>).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;
});
pi.on("user_bash", () => transport ? { operations: createRemoteBashOps(transport) } : undefined);
pi.on("before_agent_start", async (event) => {
if (!connection) return;
const guidance =
`\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_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}` };
});
}
+60
View File
@@ -0,0 +1,60 @@
{
"name": "pi-ssh",
"version": "0.8.0",
"description": "Explicit remote SSH tools for Pi using a pure ssh2 transport and encrypted host vault",
"type": "module",
"private": false,
"main": "index.ts",
"files": [
"index.ts",
"permission-integration.ts",
"src",
"scripts",
"README.md",
"CHANGELOG.md",
"extension-spec.md",
"UPSTREAM.md",
"LICENSE"
],
"keywords": [
"pi-package",
"pi",
"pi-coding-agent",
"theme",
"ssh",
"dark-mode",
"ghostty"
],
"author": "Helmut Januschka",
"contributors": [
"Iliya Anastasov <ilia.anastasov@gmail.com>",
"Furkan Bilgin <info@furkanbilgin.net>",
"Tim Smith",
"Ignas (fairusage)"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/pansapiens/pi-ssh.git"
},
"bugs": {
"url": "https://github.com/pansapiens/pi-ssh/issues"
},
"homepage": "https://github.com/pansapiens/pi-ssh#readme",
"scripts": {
"test": "node --test test/*.test.ts"
},
"pi": {
"extensions": [
"./index.ts"
]
},
"dependencies": {
"jiti": "2.7.0",
"ssh2": "1.17.0"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"@gotgenes/pi-permission-system": "*"
}
}
+156
View File
@@ -0,0 +1,156 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type { PermissionsService } from "@gotgenes/pi-permission-system";
export interface SshPermissionConnection {
remote: string;
port?: number;
remoteCwd: string;
}
export interface SshPermissionIntegrationDependencies {
getPermissionsService: () => PermissionsService | undefined;
permissionsReadyChannel: string;
warn?: (message: string) => void;
}
type PermissionIntegrationApi = Pick<ExtensionAPI, "events" | "on">;
type ToolInput = Record<string, unknown>;
const REMOTE_FILE_TOOLS = ["ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"] as const;
const REMOTE_TOOLS = [...REMOTE_FILE_TOOLS, "ssh_bash"] as const;
function inline(value: string, limit = 240): string {
const normalized = value.replace(/\s+/g, " ").trim();
return normalized.length > limit ? `${normalized.slice(0, limit)}` : normalized;
}
function stringField(input: ToolInput, key: string): string | undefined {
const value = input[key];
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function countLines(value: string): number {
return value.length === 0 ? 0 : value.split(/\r\n|\r|\n/).length;
}
function formatTarget(connection: SshPermissionConnection | null): string {
if (connection === null) return "an inactive SSH connection";
const port = connection.port === undefined ? "" : `:${connection.port}`;
return `SSH target '${inline(connection.remote)}${port}' in remote cwd '${inline(connection.remoteCwd)}'`;
}
export function formatSshPermissionInput(
toolName: string,
input: ToolInput,
connection: SshPermissionConnection | null,
): string {
const target = formatTarget(connection);
const path = stringField(input, "path");
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}`);
if (typeof input.limit === "number") details.push(`limit ${input.limit}`);
return `${target}; read ${details.join(", ")}`;
}
if (toolName === "ssh_write") {
const content = stringField(input, "content") ?? "";
return `${target}; write remote path '${inline(path ?? "<unspecified>")}' (${countLines(content)} lines, ${content.length} characters)`;
}
if (toolName === "ssh_edit") {
const oldText = stringField(input, "oldText") ?? "";
const newText = stringField(input, "newText") ?? "";
return `${target}; edit remote path '${inline(path ?? "<unspecified>")}' (replace ${countLines(oldText)} lines with ${countLines(newText)} lines)`;
}
if (toolName === "ssh_find" || toolName === "ssh_grep") {
const pattern = inline(stringField(input, "pattern") ?? "<unspecified>");
const operation = toolName === "ssh_find" ? "find remote files" : "search remote file contents";
const details = [
`under '${inline(path ?? ".")}'`,
`for '${pattern}'`,
`limit ${typeof input.limit === "number" ? input.limit : 50}`,
];
const include = stringField(input, "include");
if (include) details.push(`file glob '${inline(include)}'`);
return `${target}; ${operation} ${details.join(", ")}`;
}
if (toolName === "ssh_bash") {
return `${target}; execute the separately displayed remote shell command`;
}
return target;
}
/**
* Register pi-ssh's permission previews and remote-path semantics with the
* bundle's published permission service.
*
* Remote paths must not enter the local `path` / `external_directory` gates:
* those gates resolve against the local cwd and filesystem. Returning
* `undefined` from an explicit extractor disables the default `input.path`
* convention, leaving the dedicated ssh_* policy surfaces authoritative.
*/
export function installSshPermissionIntegration(
pi: PermissionIntegrationApi,
getConnection: () => SshPermissionConnection | null,
dependencies: SshPermissionIntegrationDependencies,
): () => void {
const getPermissionsService = dependencies.getPermissionsService;
const warn = dependencies.warn ?? ((message: string) => console.warn(`[pi-ssh] ${message}`));
let registeredService: PermissionsService | undefined;
let disposers: Array<() => void> = [];
const unregister = (): void => {
for (const dispose of disposers.splice(0).reverse()) {
try {
dispose();
} catch (error) {
warn(`failed to unregister permission integration: ${error instanceof Error ? error.message : String(error)}`);
}
}
registeredService = undefined;
};
const tryRegister = (): void => {
const service = getPermissionsService();
if (service === undefined || (service === registeredService && disposers.length > 0)) return;
unregister();
const pending: Array<() => void> = [];
try {
for (const toolName of REMOTE_TOOLS) {
pending.push(
service.registerToolInputFormatter(toolName, (input) =>
formatSshPermissionInput(toolName, input, getConnection()),
),
);
}
for (const toolName of REMOTE_FILE_TOOLS) {
pending.push(service.registerToolAccessExtractor(toolName, () => undefined));
}
disposers = pending;
registeredService = service;
} catch (error) {
for (const dispose of pending.reverse()) {
try {
dispose();
} catch {
// Best-effort rollback; the permission gate remains conservative.
}
}
warn(`failed to register permission integration: ${error instanceof Error ? error.message : String(error)}`);
}
};
pi.on("session_start", tryRegister);
pi.events.on(dependencies.permissionsReadyChannel, tryRegister);
pi.on("session_shutdown", unregister);
// Handles extension load after the permission service has already published.
tryRegister();
return unregister;
}
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env node
import { createJiti } from "jiti";
const jiti = createJiti(import.meta.url, { interopDefault: true });
await jiti.import("./ssh-config.mjs");
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import { stdin, stdout } from "node:process";
import readline from "node:readline/promises";
import {
effectiveValue,
effectiveValues,
listDirectSshAliases,
resolveOpenSshAlias,
} from "../src/import.ts";
import { expandUserPath } from "../src/config.ts";
import { probeHostKey, Ssh2Transport } from "../src/ssh2-transport.ts";
import { loadVaultOrEmpty, rotateVaultKey, saveVault } from "../src/vault.ts";
async function question(prompt) {
const rl = readline.createInterface({ input: stdin, output: stdout });
try {
return (await rl.question(prompt)).trim();
} finally {
rl.close();
}
}
async function hiddenQuestion(prompt) {
if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") {
throw new Error("secret input requires an interactive terminal");
}
stdout.write(prompt);
stdin.setRawMode(true);
stdin.resume();
let value = "";
try {
return await new Promise((resolve, reject) => {
const onData = (chunk) => {
const text = chunk.toString("utf8");
for (const char of text) {
if (char === "\u0003") {
stdin.off("data", onData);
stdout.write("\n");
reject(new Error("cancelled"));
return;
}
if (char === "\r" || char === "\n") {
stdin.off("data", onData);
stdout.write("\n");
resolve(value);
return;
}
if (char === "\u007f" || char === "\b") value = value.slice(0, -1);
else if (char >= " ") value += char;
}
};
stdin.on("data", onData);
});
} finally {
stdin.setRawMode(false);
stdin.pause();
}
}
async function confirm(prompt, defaultYes = false) {
const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
const value = (await question(`${prompt}${suffix}`)).toLowerCase();
return value ? value === "y" || value === "yes" : defaultYes;
}
function requireValue(config, key, alias) {
const value = effectiveValue(config, key);
if (!value) throw new Error(`ssh -G ${alias} did not provide ${key}`);
return value;
}
async function chooseAliases(arguments_) {
if (arguments_.length > 0) return arguments_;
const aliases = listDirectSshAliases();
if (aliases.length === 0) {
const explicit = await question("SSH Host alias to import: ");
if (!explicit) throw new Error("no SSH alias selected");
return [explicit];
}
console.log("Available SSH aliases:");
aliases.forEach((alias, index) => console.log(` ${index + 1}. ${alias}`));
const selection = await question("Select numbers or aliases (comma-separated): ");
const chosen = selection.split(",").map((value) => value.trim()).filter(Boolean).map((value) => {
const index = Number.parseInt(value, 10);
return Number.isInteger(index) && String(index) === value && aliases[index - 1] ? aliases[index - 1] : value;
});
if (chosen.length === 0) throw new Error("no SSH alias selected");
return [...new Set(chosen)];
}
async function importHost(config, alias) {
const effective = resolveOpenSshAlias(alias);
const proxyJump = effectiveValue(effective, "proxyjump");
const proxyCommand = effectiveValue(effective, "proxycommand");
if (proxyJump && proxyJump !== "none") throw new Error(`${alias} uses ProxyJump, which is not supported yet`);
if (proxyCommand && proxyCommand !== "none") throw new Error(`${alias} uses ProxyCommand, which is not supported yet`);
const hostName = requireValue(effective, "hostname", alias);
const user = requireValue(effective, "user", alias);
const port = Number.parseInt(requireValue(effective, "port", alias), 10);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`${alias} has an invalid port`);
console.log(`\n${alias}: ${user}@${hostName}:${port}`);
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 mode = modeInput || defaultMode;
let auth;
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}`);
const encrypted = await confirm("Does this private key require a passphrase?");
const passphrase = encrypted ? await hiddenQuestion("Private key passphrase: ") : undefined;
if (encrypted && !passphrase) throw new Error("private key passphrase cannot be empty");
auth = { type: "private-key", identityFile, ...(passphrase ? { passphrase } : {}) };
} else if (mode === "password") {
const password = await hiddenQuestion("Server password: ");
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");
}
console.log("Obtaining SSH host key fingerprint...");
const hostKey = await probeHostKey(hostName, port, user);
console.log(`Host key: ${hostKey.algorithm} ${hostKey.fingerprint}`);
if (!await confirm("Trust and pin this host key?")) throw new Error(`host key for ${alias} was not trusted`);
const label = await question(`Display label (${config.hosts[alias]?.label ?? alias}): `) || config.hosts[alias]?.label || alias;
const host = { label, sourceAlias: alias, hostName, user, port, auth, hostKey };
const transport = new Ssh2Transport(host);
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");
host.defaultCwd = remoteCwd;
console.log(`Connected successfully; default cwd: ${remoteCwd}`);
} finally {
await transport.dispose();
}
config.hosts[alias] = host;
const groupId = await question("Group id (optional): ");
if (groupId) {
const groupLabel = config.groups?.[groupId]?.label || await question(`Group label (${groupId}): `) || groupId;
config.groups ??= {};
const hosts = new Set(config.groups[groupId]?.hosts ?? []);
hosts.add(alias);
config.groups[groupId] = { label: groupLabel, hosts: [...hosts] };
}
}
async function main() {
const [command = "list", ...arguments_] = process.argv.slice(2);
if (command === "list") {
const config = loadVaultOrEmpty();
const entries = Object.entries(config.hosts);
if (entries.length === 0) {
console.log("No pi-ssh hosts configured.");
return;
}
for (const [id, host] of entries) console.log(`${id}\t${host.label ?? id}\t${host.user}@${host.hostName}:${host.port}\t${host.auth.type}`);
return;
}
if (command === "rotate-key") {
rotateVaultKey();
console.log("Rotated pi-ssh vault key.");
return;
}
if (command === "remove") {
const id = arguments_[0];
if (!id) throw new Error("usage: ssh_config.sh remove <host-id>");
const config = loadVaultOrEmpty();
if (!config.hosts[id]) throw new Error(`unknown pi-ssh host: ${id}`);
delete config.hosts[id];
for (const group of Object.values(config.groups ?? {})) group.hosts = group.hosts.filter((host) => host !== id);
saveVault(config);
console.log(`Removed ${id}.`);
return;
}
if (command !== "import" && command !== "update") {
throw new Error("usage: ssh_config.sh [list|import [alias...]|update <alias>|remove <id>|rotate-key]");
}
const config = loadVaultOrEmpty();
const aliases = command === "update"
? [arguments_[0] || (() => { throw new Error("usage: ssh_config.sh update <alias>"); })()]
: await chooseAliases(arguments_);
for (const alias of aliases) await importHost(config, alias);
saveVault(config);
console.log(`Saved ${aliases.length} host(s) to the encrypted pi-ssh vault.`);
}
main().catch((error) => {
console.error(`pi-ssh config: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
});
+171
View File
@@ -0,0 +1,171 @@
import { homedir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";
export interface PasswordAuthConfig {
type: "password";
password: string;
method?: "auto" | "password" | "keyboard-interactive";
}
export interface PrivateKeyAuthConfig {
type: "private-key";
identityFile: string;
passphrase?: string;
}
export type SshAuthConfig = PasswordAuthConfig | PrivateKeyAuthConfig;
export interface SshHostConfig {
label?: string;
sourceAlias?: string;
hostName: string;
user: string;
port: number;
defaultCwd?: string;
auth: SshAuthConfig;
hostKey: {
algorithm: string;
fingerprint: string;
};
}
export interface SshGroupConfig {
label: string;
hosts: string[];
}
export interface PiSshConfig {
version: 1;
hosts: Record<string, SshHostConfig>;
groups?: Record<string, SshGroupConfig>;
}
export interface VaultPaths {
directory: string;
encryptedConfig: string;
key: string;
}
const HOST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
export function resolveVaultPaths(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
home = homedir(),
): VaultPaths {
const base = platform === "win32"
? env.APPDATA || join(home, "AppData", "Roaming")
: env.XDG_CONFIG_HOME || join(home, ".config");
const directory = join(base, "my-pi", "pi-ssh");
return {
directory,
encryptedConfig: join(directory, "hosts.enc"),
key: join(directory, "vault.key"),
};
}
export function expandUserPath(value: string, home = homedir()): string {
if (value === "~") return home;
if (value.startsWith("~/")) return join(home, value.slice(2));
return isAbsolute(value) ? value : resolve(home, value);
}
function record(value: unknown, name: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`${name} must be an object`);
}
return value as Record<string, unknown>;
}
function nonEmptyString(value: unknown, name: string): string {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${name} must be a non-empty string`);
}
return value;
}
function optionalString(value: unknown, name: string): string | undefined {
return value === undefined ? undefined : nonEmptyString(value, name);
}
function validateAuth(value: unknown, name: string): SshAuthConfig {
const auth = record(value, name);
if (auth.type === "password") {
const method = auth.method;
if (method !== undefined && method !== "auto" && method !== "password" && method !== "keyboard-interactive") {
throw new Error(`${name}.method must be auto, password, or keyboard-interactive`);
}
return {
type: "password",
password: nonEmptyString(auth.password, `${name}.password`),
...(method === undefined ? {} : { method }),
};
}
if (auth.type === "private-key") {
return {
type: "private-key",
identityFile: nonEmptyString(auth.identityFile, `${name}.identityFile`),
...(auth.passphrase === undefined ? {} : { passphrase: nonEmptyString(auth.passphrase, `${name}.passphrase`) }),
};
}
throw new Error(`${name}.type must be password or private-key`);
}
function validateHost(value: unknown, name: string): SshHostConfig {
const host = record(value, name);
const port = host.port;
if (!Number.isInteger(port) || (port as number) < 1 || (port as number) > 65535) {
throw new Error(`${name}.port must be an integer from 1 to 65535`);
}
const hostKey = record(host.hostKey, `${name}.hostKey`);
const fingerprint = nonEmptyString(hostKey.fingerprint, `${name}.hostKey.fingerprint`);
if (!fingerprint.startsWith("SHA256:")) {
throw new Error(`${name}.hostKey.fingerprint must use SHA256 format`);
}
return {
...(host.label === undefined ? {} : { label: optionalString(host.label, `${name}.label`) }),
...(host.sourceAlias === undefined ? {} : { sourceAlias: optionalString(host.sourceAlias, `${name}.sourceAlias`) }),
hostName: nonEmptyString(host.hostName, `${name}.hostName`),
user: nonEmptyString(host.user, `${name}.user`),
port: port as number,
...(host.defaultCwd === undefined ? {} : { defaultCwd: optionalString(host.defaultCwd, `${name}.defaultCwd`) }),
auth: validateAuth(host.auth, `${name}.auth`),
hostKey: {
algorithm: nonEmptyString(hostKey.algorithm, `${name}.hostKey.algorithm`),
fingerprint,
},
};
}
export function validatePiSshConfig(value: unknown): PiSshConfig {
const root = record(value, "config");
if (root.version !== 1) throw new Error("config.version must be 1");
const hostsValue = record(root.hosts, "config.hosts");
const hosts: Record<string, SshHostConfig> = {};
for (const [id, host] of Object.entries(hostsValue)) {
if (!HOST_ID_PATTERN.test(id)) throw new Error(`invalid host id: ${id}`);
hosts[id] = validateHost(host, `config.hosts.${id}`);
}
let groups: Record<string, SshGroupConfig> | undefined;
if (root.groups !== undefined) {
groups = {};
for (const [id, value] of Object.entries(record(root.groups, "config.groups"))) {
if (!HOST_ID_PATTERN.test(id)) throw new Error(`invalid group id: ${id}`);
const group = record(value, `config.groups.${id}`);
if (!Array.isArray(group.hosts) || group.hosts.some((host) => typeof host !== "string" || !hosts[host])) {
throw new Error(`config.groups.${id}.hosts must reference configured hosts`);
}
groups[id] = {
label: nonEmptyString(group.label, `config.groups.${id}.label`),
hosts: [...new Set(group.hosts as string[])],
};
}
}
return { version: 1, hosts, ...(groups === undefined ? {} : { groups }) };
}
export function emptyPiSshConfig(): PiSshConfig {
return { version: 1, hosts: {} };
}
+53
View File
@@ -0,0 +1,53 @@
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
export type EffectiveSshConfig = Map<string, string[]>;
export function parseSshG(text: string): EffectiveSshConfig {
const values: EffectiveSshConfig = new Map();
for (const line of text.split(/\r?\n/)) {
const separator = line.indexOf(" ");
if (separator < 1) continue;
const key = line.slice(0, separator).trim().toLowerCase();
const value = line.slice(separator + 1).trim();
if (!key || !value) continue;
const current = values.get(key) ?? [];
current.push(value);
values.set(key, current);
}
return values;
}
export function resolveOpenSshAlias(alias: string): EffectiveSshConfig {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(alias)) throw new Error(`invalid SSH alias: ${alias}`);
const output = execFileSync("ssh", ["-G", "--", alias], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
return parseSshG(output);
}
export function effectiveValue(config: EffectiveSshConfig, key: string): string | undefined {
return config.get(key.toLowerCase())?.at(-1);
}
export function effectiveValues(config: EffectiveSshConfig, key: string): string[] {
return config.get(key.toLowerCase()) ?? [];
}
export function listDirectSshAliases(configPath = join(homedir(), ".ssh", "config")): string[] {
if (!existsSync(configPath)) return [];
const aliases: string[] = [];
const text = readFileSync(configPath, "utf8");
for (const line of text.split(/\r?\n/)) {
const match = /^\s*Host\s+(.+?)\s*$/i.exec(line);
if (!match) continue;
for (const alias of match[1].split(/\s+/)) {
if (!alias || alias.includes("*") || alias.includes("?") || alias.startsWith("!")) continue;
if (!aliases.includes(alias)) aliases.push(alias);
}
}
return aliases;
}
+243
View File
@@ -0,0 +1,243 @@
import { posix as posixPath } from "node:path";
import type { RemoteTransport } from "./ssh2-transport.ts";
export interface RemoteFindInput {
pattern: string;
path?: string;
caseSensitive?: boolean;
includeHidden?: boolean;
limit?: number;
}
export interface RemoteGrepInput {
pattern: string;
path?: string;
literal?: boolean;
caseSensitive?: boolean;
include?: string;
limit?: number;
}
export interface RemoteSearchResult {
text: string;
backend: string;
matchCount: number;
truncated: boolean;
}
const MARKER = "__PI_SSH_SEARCH_BACKEND__:";
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200;
const MAX_LINE_CHARS = 800;
const MAX_CAPTURE_CHARS = 512_000;
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function validateField(value: unknown, name: string, optional = false): string | undefined {
if (value === undefined && optional) return undefined;
if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
if (/[\0\r\n]/u.test(value)) throw new Error(`${name} must not contain NUL or newline characters`);
return value;
}
function normalizeLimit(value: unknown): number {
if (value === undefined) return DEFAULT_LIMIT;
if (!Number.isInteger(value) || (value as number) < 1 || (value as number) > MAX_LIMIT) {
throw new Error(`limit must be an integer from 1 to ${MAX_LIMIT}`);
}
return value as number;
}
export function resolveRemoteSearchPath(path: string | undefined, remoteCwd: string, remoteHome: string): string {
const value = path ?? ".";
validateField(value, "path");
if (value === "~") return remoteHome;
if (value.startsWith("~/")) return posixPath.normalize(posixPath.join(remoteHome, value.slice(2)));
if (value.startsWith("/")) return posixPath.normalize(value);
return posixPath.normalize(posixPath.join(remoteCwd, value));
}
const STATUS_HELPER = [
`pi_ssh_accept_status() {`,
` local actual="$1" accepted`,
` shift`,
` for accepted in "$@"; do`,
` if [ "$actual" -eq "$accepted" ]; then return 0; fi`,
` done`,
` return "$actual"`,
`}`,
].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";
const fdHidden = input.includeHidden ? "--hidden" : "";
const grepCase = input.caseSensitive ? "" : "-i";
const hiddenFilter = input.includeHidden ? "cat" : "grep -Ev '(^|/)\\.'";
const take = limit + 1;
return [
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}`,
' 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}`,
' 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}`,
' 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}`,
' 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 $?',
`fi`,
].join("\n");
}
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";
const insensitive = input.caseSensitive ? "" : "-i";
const rgGlob = include ? `-g ${shellQuote(include)}` : "";
const gitPath = include ? `-- ${shellQuote(include)}` : "";
const findName = include ? `-name ${shellQuote(include)}` : "";
const take = limit + 1;
return [
STATUS_HELPER,
`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}`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 1 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-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}`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 1 141 || 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}`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
`fi`,
].join("\n");
}
export function buildRemoteFindCommand(input: RemoteFindInput, root: string): { command: string; limit: number } {
const limit = normalizeLimit(input.limit);
validateField(root, "resolved path");
return { command: findPipeline(input, root, limit), limit };
}
export function buildRemoteGrepCommand(input: RemoteGrepInput, root: string): { command: string; limit: number } {
const limit = normalizeLimit(input.limit);
validateField(root, "resolved path");
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)}`;
}
export function formatRemoteSearchOutput(
raw: string,
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 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 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.`,
backend,
matchCount: rows.length,
truncated,
};
}
async function runRemoteSearch(
transport: RemoteTransport,
command: string,
root: string,
limit: number,
kind: "find" | "grep",
signal?: AbortSignal,
): Promise<RemoteSearchResult> {
const chunks: 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");
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);
throw new Error(`remote ${kind} failed${result.exitCode === null ? "" : ` with exit code ${result.exitCode}`}${detail ? `: ${detail}` : ""}`);
}
return formatRemoteSearchOutput(output, root, limit, kind);
}
export function runRemoteFind(
transport: RemoteTransport,
input: RemoteFindInput,
remoteCwd: string,
remoteHome: string,
signal?: AbortSignal,
): Promise<RemoteSearchResult> {
const root = resolveRemoteSearchPath(input.path, remoteCwd, remoteHome);
const built = buildRemoteFindCommand(input, root);
return runRemoteSearch(transport, built.command, root, built.limit, "find", signal);
}
export function runRemoteGrep(
transport: RemoteTransport,
input: RemoteGrepInput,
remoteCwd: string,
remoteHome: string,
signal?: AbortSignal,
): Promise<RemoteSearchResult> {
const root = resolveRemoteSearchPath(input.path, remoteCwd, remoteHome);
const built = buildRemoteGrepCommand(input, root);
return runRemoteSearch(transport, built.command, root, built.limit, "grep", signal);
}
+48
View File
@@ -0,0 +1,48 @@
declare module "ssh2" {
import type { EventEmitter } from "node:events";
import type { Stats } from "node:fs";
export interface ConnectConfig {
host: string;
port?: number;
username: string;
password?: string;
privateKey?: Buffer | string;
passphrase?: string;
tryKeyboard?: boolean;
readyTimeout?: number;
keepaliveInterval?: number;
keepaliveCountMax?: number;
hostVerifier?: (key: Buffer) => boolean;
}
export interface ClientChannel extends EventEmitter {
stderr: EventEmitter;
close(): void;
signal(signal: string): void;
}
export interface SFTPWrapper {
readFile(path: string, callback: (error: Error | undefined, data: Buffer) => void): void;
writeFile(path: string, data: Buffer, callback: (error?: Error) => void): void;
open(path: string, flags: string, callback: (error: Error | undefined, handle: Buffer) => void): void;
close(handle: Buffer, callback: (error?: Error) => void): void;
mkdir(path: string, callback: (error?: Error) => void): void;
stat(path: string, callback: (error: Error | undefined, stats: Stats) => void): void;
rename(oldPath: string, newPath: string, callback: (error?: Error) => void): void;
unlink(path: string, callback: (error?: Error) => void): void;
end(): void;
ext_openssh_rename?(oldPath: string, newPath: string, callback: (error?: Error) => void): void;
}
export class Client extends EventEmitter {
connect(config: ConnectConfig): this;
exec(
command: string,
callback: (error: Error | undefined, channel: ClientChannel) => void,
): void;
sftp(callback: (error: Error | undefined, sftp: SFTPWrapper) => void): void;
end(): this;
destroy(): this;
}
}
+363
View File
@@ -0,0 +1,363 @@
/// <reference path="./ssh2-shim.d.ts" />
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { posix as posixPath } from "node:path";
import { Client, type ClientChannel, type ConnectConfig, type SFTPWrapper } from "ssh2";
import { expandUserPath, type SshHostConfig } from "./config.ts";
export interface RemoteExecOptions {
onData: (data: Buffer) => void;
signal?: AbortSignal;
timeout?: number;
}
export interface RemoteTransport {
connect(): Promise<void>;
dispose(): Promise<void>;
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }>;
capture(command: string, cwd?: string, timeout?: number): Promise<{ exitCode: number | null; output: Buffer }>;
readFile(remotePath: string): Promise<Buffer>;
ensureReadable(remotePath: string): Promise<void>;
ensureReadableWritable(remotePath: string): Promise<void>;
detectImageMimeType(remotePath: string): Promise<string | null>;
mkdir(remoteDir: string): Promise<void>;
writeFile(remotePath: string, content: Buffer): Promise<void>;
}
const DEFAULT_TIMEOUT_SECONDS = 300;
class CommandQueue {
private tail: Promise<void> = Promise.resolve();
enqueue<T>(task: () => Promise<T>): Promise<T> {
const run = this.tail.then(task, task);
this.tail = run.then(() => undefined, () => undefined);
return run;
}
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function keyAlgorithm(key: Buffer): string {
if (key.length < 4) return "unknown";
const length = key.readUInt32BE(0);
if (length < 1 || length > key.length - 4) return "unknown";
return key.subarray(4, 4 + length).toString("ascii");
}
export function fingerprintHostKey(key: Buffer): { algorithm: string; fingerprint: string } {
return {
algorithm: keyAlgorithm(key),
fingerprint: `SHA256:${createHash("sha256").update(key).digest("base64").replace(/=+$/, "")}`,
};
}
function buildConnectConfig(host: SshHostConfig): ConnectConfig {
const config: ConnectConfig = {
host: host.hostName,
port: host.port,
username: host.user,
readyTimeout: 20_000,
keepaliveInterval: 15_000,
keepaliveCountMax: 3,
hostVerifier: (key) => fingerprintHostKey(key).fingerprint === host.hostKey.fingerprint,
};
if (host.auth.type === "password") {
config.password = host.auth.password;
config.tryKeyboard = host.auth.method !== "password";
} else {
config.privateKey = readFileSync(expandUserPath(host.auth.identityFile));
if (host.auth.passphrase) config.passphrase = host.auth.passphrase;
}
return config;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export class Ssh2Transport implements RemoteTransport {
private readonly client: Client;
private readonly queue = new CommandQueue();
private connected = false;
private disposed = false;
private disconnectError: Error | null = null;
private sftpClient: SFTPWrapper | null = null;
private readonly host: SshHostConfig;
constructor(host: SshHostConfig, client: Client = new Client()) {
this.host = host;
this.client = client;
}
async connect(): Promise<void> {
if (this.connected) return;
if (this.disposed) throw new Error("SSH2 transport is disposed");
await new Promise<void>((resolve, reject) => {
let settled = false;
const succeed = () => {
if (settled) return;
settled = true;
this.connected = true;
resolve();
};
const fail = (error: unknown) => {
const normalized = error instanceof Error ? error : new Error(String(error));
this.disconnectError = normalized;
if (settled) return;
settled = true;
reject(normalized);
};
this.client.once("ready", succeed);
this.client.on("error", fail);
this.client.on("close", () => {
this.connected = false;
this.sftpClient = null;
if (!this.disposed && !this.disconnectError) this.disconnectError = new Error("SSH2 connection closed unexpectedly");
});
if (this.host.auth.type === "password" && this.host.auth.method !== "password") {
this.client.on("keyboard-interactive", (_name, _instructions, _language, prompts, finish) => {
finish(prompts.map(() => this.host.auth.type === "password" ? this.host.auth.password : ""));
});
}
try {
this.client.connect(buildConnectConfig(this.host));
} catch (error) {
fail(error);
}
});
}
async dispose(): Promise<void> {
this.disposed = true;
this.connected = false;
this.sftpClient = null;
this.client.end();
}
private assertConnected(): void {
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));
}
private async execUnqueued(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
this.assertConnected();
const remoteCommand = `cd -- ${shellQuote(cwd)} && bash -lc ${shellQuote(command)} </dev/null`;
return new Promise((resolve, reject) => {
let channel: ClientChannel | undefined;
let settled = false;
let timeoutHandle: NodeJS.Timeout | undefined;
const effectiveTimeout = options.timeout ?? DEFAULT_TIMEOUT_SECONDS;
const cleanup = () => {
if (timeoutHandle) clearTimeout(timeoutHandle);
if (options.signal) options.signal.removeEventListener("abort", onAbort);
};
const fail = (error: Error) => {
if (settled) return;
settled = true;
cleanup();
reject(error);
};
const onAbort = () => {
try {
channel?.signal("KILL");
channel?.close();
} catch {
// channel may already be closed
}
fail(new Error("SSH command aborted"));
};
if (options.signal?.aborted) {
fail(new Error("SSH command aborted"));
return;
}
options.signal?.addEventListener("abort", onAbort, { once: true });
if (effectiveTimeout > 0) {
timeoutHandle = setTimeout(() => {
try {
channel?.signal("KILL");
channel?.close();
} catch {
// channel may already be closed
}
fail(new Error(`SSH command timed out after ${effectiveTimeout}s`));
}, effectiveTimeout * 1000);
}
this.client.exec(remoteCommand, (error, stream) => {
if (error) {
fail(error);
return;
}
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.once("error", fail);
stream.once("close", (code: number | undefined) => {
if (settled) return;
settled = true;
cleanup();
resolve({ exitCode: typeof code === "number" ? code : null });
});
});
});
}
async capture(command: string, cwd = ".", timeout = 30): Promise<{ exitCode: number | null; output: Buffer }> {
const chunks: Buffer[] = [];
const result = await this.exec(command, cwd, { timeout, onData: (data) => chunks.push(data) });
return { exitCode: result.exitCode, output: Buffer.concat(chunks) };
}
private async sftp(): Promise<SFTPWrapper> {
this.assertConnected();
if (this.sftpClient) return this.sftpClient;
this.sftpClient = await new Promise<SFTPWrapper>((resolve, reject) => {
this.client.sftp((error, sftp) => error ? reject(error) : resolve(sftp));
});
return this.sftpClient;
}
async readFile(remotePath: string): Promise<Buffer> {
return this.queue.enqueue(async () => {
const sftp = await this.sftp();
return new Promise<Buffer>((resolve, reject) => {
sftp.readFile(remotePath, (error, data) => error ? reject(error) : resolve(data));
});
});
}
private async ensureOpen(remotePath: string, flags: "r" | "r+"): Promise<void> {
return this.queue.enqueue(async () => {
const sftp = await this.sftp();
await new Promise<void>((resolve, reject) => {
sftp.open(remotePath, flags, (error, handle) => {
if (error) {
reject(error);
return;
}
sftp.close(handle, (closeError) => closeError ? reject(closeError) : resolve());
});
});
});
}
ensureReadable(remotePath: string): Promise<void> {
return this.ensureOpen(remotePath, "r");
}
ensureReadableWritable(remotePath: string): Promise<void> {
return this.ensureOpen(remotePath, "r+");
}
async detectImageMimeType(remotePath: string): Promise<string | null> {
const content = await this.readFile(remotePath);
if (content.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) return "image/jpeg";
if (content.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
const prefix = content.subarray(0, 6).toString("ascii");
if (prefix === "GIF87a" || prefix === "GIF89a") return "image/gif";
if (content.subarray(0, 4).toString("ascii") === "RIFF" && content.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";
return null;
}
async mkdir(remoteDir: string): Promise<void> {
return this.queue.enqueue(() => this.mkdirUnqueued(remoteDir));
}
private async mkdirUnqueued(remoteDir: string): Promise<void> {
const sftp = await this.sftp();
const normalized = posixPath.normalize(remoteDir);
const segments = normalized.split("/").filter(Boolean);
let current = normalized.startsWith("/") ? "/" : "";
for (const segment of segments) {
current = current === "/" ? `/${segment}` : current ? `${current}/${segment}` : segment;
await new Promise<void>((resolve, reject) => {
sftp.mkdir(current, (error) => {
if (!error) {
resolve();
return;
}
sftp.stat(current, (statError) => statError ? reject(error) : resolve());
});
});
}
}
async writeFile(remotePath: string, content: Buffer): Promise<void> {
return this.queue.enqueue(async () => {
const sftp = await this.sftp();
await this.mkdirUnqueued(posixPath.dirname(remotePath));
const temporary = `${remotePath}.pi-ssh-${randomBytes(8).toString("hex")}.tmp`;
const write = (path: string, data: Buffer) => new Promise<void>((resolve, reject) => {
sftp.writeFile(path, data, (error) => error ? reject(error) : resolve());
});
const standardRename = (from: string, to: string) => new Promise<void>((resolve, reject) => {
sftp.rename(from, to, (error) => error ? reject(error) : resolve());
});
const atomicRename = typeof sftp.ext_openssh_rename === "function"
? (from: string, to: string) => new Promise<void>((resolve, reject) => {
sftp.ext_openssh_rename?.(from, to, (error) => error ? reject(error) : resolve());
})
: undefined;
try {
await write(temporary, content);
if (atomicRename) {
await atomicRename(temporary, remotePath);
} else {
try {
await standardRename(temporary, remotePath);
} catch {
// SFTP v3 rename commonly refuses to replace an existing target.
// Fall back to a direct write without deleting the existing file first.
await write(remotePath, content);
await new Promise<void>((resolve) => sftp.unlink(temporary, () => resolve()));
}
}
} catch (error) {
await new Promise<void>((resolve) => sftp.unlink(temporary, () => resolve()));
throw new Error(`remote write failed: ${errorMessage(error)}`);
}
});
}
}
export async function probeHostKey(
hostName: string,
port: number,
user: string,
client: Client = new Client(),
timeoutMs = 15_000,
): Promise<{ algorithm: string; fingerprint: string }> {
return new Promise((resolve, reject) => {
let observed: { algorithm: string; fingerprint: string } | undefined;
const timer = setTimeout(() => {
client.destroy();
reject(new Error("timed out while obtaining SSH host key"));
}, timeoutMs);
client.on("error", (error) => {
clearTimeout(timer);
client.destroy();
observed ? resolve(observed) : reject(error);
});
client.on("close", () => {
clearTimeout(timer);
if (observed) resolve(observed);
});
client.connect({
host: hostName,
port,
username: user,
readyTimeout: Math.min(timeoutMs, 12_000),
hostVerifier: (key) => {
observed = fingerprintHostKey(key);
return false;
},
});
});
}
+183
View File
@@ -0,0 +1,183 @@
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import {
chmodSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { basename, join } from "node:path";
import {
emptyPiSshConfig,
resolveVaultPaths,
validatePiSshConfig,
type PiSshConfig,
type VaultPaths,
} from "./config.ts";
const FORMAT = "my-pi-ssh-v1";
const KEY_BYTES = 32;
const IV_BYTES = 12;
interface EncryptedEnvelope {
format: typeof FORMAT;
iv: string;
tag: string;
ciphertext: string;
}
function assertOwnerOnly(path: string): void {
if (process.platform === "win32") return;
const mode = statSync(path).mode & 0o777;
if ((mode & 0o077) !== 0) {
throw new Error(`${basename(path)} permissions must be 600 or stricter`);
}
}
function ensureDirectory(paths: VaultPaths): void {
mkdirSync(paths.directory, { recursive: true, mode: 0o700 });
if (process.platform !== "win32") chmodSync(paths.directory, 0o700);
}
function parseKey(text: string): Buffer {
const normalized = text.trim();
const key = Buffer.from(normalized, "base64");
if (key.length !== KEY_BYTES || key.toString("base64") !== normalized) {
throw new Error("vault.key is invalid");
}
return key;
}
function readKey(paths: VaultPaths): Buffer {
if (!existsSync(paths.key)) throw new Error("pi-ssh vault key is missing; run ssh_config.sh import");
assertOwnerOnly(paths.key);
return parseKey(readFileSync(paths.key, "utf8"));
}
function getOrCreateKey(paths: VaultPaths): Buffer {
ensureDirectory(paths);
if (existsSync(paths.key)) return readKey(paths);
const key = randomBytes(KEY_BYTES);
writeFileSync(paths.key, `${key.toString("base64")}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
if (process.platform !== "win32") chmodSync(paths.key, 0o600);
return key;
}
function parseEnvelope(text: string): EncryptedEnvelope {
let value: unknown;
try {
value = JSON.parse(text);
} catch {
throw new Error("pi-ssh encrypted configuration is invalid JSON");
}
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("pi-ssh encrypted configuration is invalid");
}
const envelope = value as Record<string, unknown>;
for (const field of ["iv", "tag", "ciphertext"] as const) {
if (typeof envelope[field] !== "string" || envelope[field].length === 0) {
throw new Error(`pi-ssh encrypted configuration is missing ${field}`);
}
}
if (envelope.format !== FORMAT) throw new Error("unsupported pi-ssh encrypted configuration format");
return envelope as unknown as EncryptedEnvelope;
}
export function encryptConfig(config: PiSshConfig, key: Buffer): string {
if (key.length !== KEY_BYTES) throw new Error("pi-ssh vault key must be 32 bytes");
const validated = validatePiSshConfig(config);
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv("aes-256-gcm", key, iv);
cipher.setAAD(Buffer.from(FORMAT, "utf8"));
const plaintext = Buffer.from(JSON.stringify(validated), "utf8");
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const envelope: EncryptedEnvelope = {
format: FORMAT,
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
ciphertext: ciphertext.toString("base64"),
};
return `${JSON.stringify(envelope, null, 2)}\n`;
}
export function decryptConfig(encrypted: string, key: Buffer): PiSshConfig {
if (key.length !== KEY_BYTES) throw new Error("pi-ssh vault key must be 32 bytes");
const envelope = parseEnvelope(encrypted);
try {
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(envelope.iv, "base64"));
decipher.setAAD(Buffer.from(FORMAT, "utf8"));
decipher.setAuthTag(Buffer.from(envelope.tag, "base64"));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(envelope.ciphertext, "base64")),
decipher.final(),
]).toString("utf8");
return validatePiSshConfig(JSON.parse(plaintext));
} catch (error) {
if (error instanceof SyntaxError) throw new Error("decrypted pi-ssh configuration is invalid JSON");
throw new Error("pi-ssh configuration could not be decrypted or failed integrity verification");
}
}
export function loadVault(paths: VaultPaths = resolveVaultPaths()): PiSshConfig {
if (!existsSync(paths.encryptedConfig)) {
throw new Error("pi-ssh is not configured; run ssh_config.sh import");
}
assertOwnerOnly(paths.encryptedConfig);
const key = readKey(paths);
return decryptConfig(readFileSync(paths.encryptedConfig, "utf8"), key);
}
export function loadVaultOrEmpty(paths: VaultPaths = resolveVaultPaths()): PiSshConfig {
return existsSync(paths.encryptedConfig) ? loadVault(paths) : emptyPiSshConfig();
}
export function saveVault(config: PiSshConfig, paths: VaultPaths = resolveVaultPaths()): void {
const key = getOrCreateKey(paths);
const encrypted = encryptConfig(config, key);
const temp = join(paths.directory, `.hosts.enc.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
try {
writeFileSync(temp, encrypted, { encoding: "utf8", mode: 0o600, flag: "wx" });
if (process.platform !== "win32") chmodSync(temp, 0o600);
renameSync(temp, paths.encryptedConfig);
if (process.platform !== "win32") chmodSync(paths.encryptedConfig, 0o600);
} finally {
if (existsSync(temp)) unlinkSync(temp);
}
}
export function rotateVaultKey(paths: VaultPaths = resolveVaultPaths()): void {
const config = loadVault(paths);
const newKey = randomBytes(KEY_BYTES);
const suffix = `${process.pid}.${randomBytes(6).toString("hex")}`;
const keyTemp = join(paths.directory, `.vault.key.${suffix}.tmp`);
const configTemp = join(paths.directory, `.hosts.enc.${suffix}.tmp`);
const keyBackup = join(paths.directory, `.vault.key.${suffix}.backup`);
const configBackup = join(paths.directory, `.hosts.enc.${suffix}.backup`);
let committed = false;
try {
writeFileSync(keyTemp, `${newKey.toString("base64")}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
writeFileSync(configTemp, encryptConfig(config, newKey), { encoding: "utf8", mode: 0o600, flag: "wx" });
renameSync(paths.key, keyBackup);
renameSync(paths.encryptedConfig, configBackup);
renameSync(keyTemp, paths.key);
renameSync(configTemp, paths.encryptedConfig);
committed = true;
} finally {
if (!committed) {
if (existsSync(keyBackup)) {
if (existsSync(paths.key)) unlinkSync(paths.key);
renameSync(keyBackup, paths.key);
}
if (existsSync(configBackup)) {
if (existsSync(paths.encryptedConfig)) unlinkSync(paths.encryptedConfig);
renameSync(configBackup, paths.encryptedConfig);
}
}
for (const path of [keyTemp, configTemp, keyBackup, configBackup]) {
if (existsSync(path)) unlinkSync(path);
}
}
}
+63
View File
@@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import test from "node:test";
import { validatePiSshConfig } from "../src/config.ts";
import { 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", () => {
const parsed = parseSshG([
"host packaging-server",
"hostname 192.0.2.20",
"user builder",
"port 2222",
"identityfile ~/.ssh/first",
"identityfile ~/.ssh/second",
"proxyjump none",
].join("\n"));
assert.equal(effectiveValue(parsed, "hostname"), "192.0.2.20");
assert.equal(effectiveValue(parsed, "port"), "2222");
assert.deepEqual(effectiveValues(parsed, "identityfile"), ["~/.ssh/first", "~/.ssh/second"]);
});
test("validates password and private-key host definitions", () => {
const password = validatePiSshConfig({
version: 1,
hosts: {
build: {
hostName: "build.example.test",
user: "builder",
port: 22,
auth: { type: "password", password: "secret" },
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:value" },
},
},
groups: { builders: { label: "Builders", hosts: ["build"] } },
});
assert.equal(password.hosts.build.auth.type, "password");
assert.throws(() => validatePiSshConfig({
version: 1,
hosts: {
build: {
hostName: "build.example.test",
user: "builder",
port: 22,
auth: { type: "password", password: "" },
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:value" },
},
},
}), /password/);
});
test("formats SSH host keys as pinned SHA256 fingerprints", () => {
const algorithm = Buffer.from("ssh-ed25519", "ascii");
const length = Buffer.alloc(4);
length.writeUInt32BE(algorithm.length);
const key = Buffer.concat([length, algorithm, Buffer.from("public-key-fixture")]);
const expected = createHash("sha256").update(key).digest("base64").replace(/=+$/, "");
assert.deepEqual(fingerprintHostKey(key), {
algorithm: "ssh-ed25519",
fingerprint: `SHA256:${expected}`,
});
});
+35
View File
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import test from "node:test";
import { resolveVaultPaths, type PiSshConfig } from "../src/config.ts";
import { saveVault } from "../src/vault.ts";
const fixture: PiSshConfig = {
version: 1,
hosts: {
packaging: {
label: "Packaging",
hostName: "192.0.2.15",
user: "builder",
port: 22,
auth: { type: "password", password: "never-print-this" },
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:fixture" },
},
},
};
test("the configuration CLI lists hosts without revealing credentials", () => {
const home = mkdtempSync(join(tmpdir(), "pi-ssh-cli-"));
const configHome = join(home, "config");
saveVault(fixture, resolveVaultPaths({ XDG_CONFIG_HOME: configHome }, process.platform, home));
const result = spawnSync(resolve("../ssh_config.sh"), ["list"], {
encoding: "utf8",
env: { ...process.env, HOME: home, XDG_CONFIG_HOME: configHome },
});
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /packaging\s+Packaging\s+builder@192\.0\.2\.15:22\s+password/);
assert.doesNotMatch(`${result.stdout}${result.stderr}`, /never-print-this/);
});
+10
View File
@@ -0,0 +1,10 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const extensionSource = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
test("does not discover or inject remote project instruction files", () => {
assert.doesNotMatch(extensionSource, /AGENTS\.md|CLAUDE\.md/);
assert.doesNotMatch(extensionSource, /loadRemoteContext|Remote Project Context/);
});
+101
View File
@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { PermissionsService } from "@gotgenes/pi-permission-system";
import {
formatSshPermissionInput,
installSshPermissionIntegration,
type SshPermissionConnection,
} from "../permission-integration.ts";
function makeService() {
const formatters = new Map<string, (input: Record<string, unknown>) => string | undefined>();
const extractors = new Map<string, (input: Record<string, unknown>) => string | undefined>();
const service = {
registerToolInputFormatter(name: string, formatter: (input: Record<string, unknown>) => string | undefined) {
formatters.set(name, formatter);
return () => formatters.delete(name);
},
registerToolAccessExtractor(name: string, extractor: (input: Record<string, unknown>) => string | undefined) {
extractors.set(name, extractor);
return () => extractors.delete(name);
},
} as unknown as PermissionsService;
return { service, formatters, extractors };
}
function makePi() {
const hooks = new Map<string, Array<() => void>>();
const eventHooks = new Map<string, Array<() => void>>();
const add = (map: Map<string, Array<() => void>>, name: string, handler: () => void) => {
map.set(name, [...(map.get(name) ?? []), handler]);
};
return {
api: {
on: (name: string, handler: () => void) => add(hooks, name, handler),
events: { on: (name: string, handler: () => void) => add(eventHooks, name, handler) },
},
emit: (name: string) => hooks.get(name)?.forEach((handler) => handler()),
emitEvent: (name: string) => eventHooks.get(name)?.forEach((handler) => handler()),
};
}
const connection: SshPermissionConnection = {
remote: "packaging-server",
port: 2222,
remoteCwd: "/srv/build",
};
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",
);
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)",
);
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'",
);
});
test("registers previews and disables local path extraction for remote file tools", () => {
const { service, formatters, extractors } = makeService();
const pi = makePi();
const dispose = installSshPermissionIntegration(
pi.api as never,
() => connection,
{ getPermissionsService: () => service, permissionsReadyChannel: "permissions:ready" },
);
assert.deepEqual([...formatters.keys()], ["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/);
dispose();
assert.equal(formatters.size, 0);
assert.equal(extractors.size, 0);
});
test("registers when the permission service becomes ready and cleans up on shutdown", () => {
const { service, formatters, extractors } = makeService();
const pi = makePi();
let published: PermissionsService | undefined;
installSshPermissionIntegration(pi.api as never, () => connection, {
getPermissionsService: () => published,
permissionsReadyChannel: "permissions:ready",
});
assert.equal(formatters.size, 0);
published = service;
pi.emitEvent("permissions:ready");
assert.equal(formatters.size, 6);
assert.equal(extractors.size, 5);
pi.emit("session_shutdown");
assert.equal(formatters.size, 0);
assert.equal(extractors.size, 0);
});
+181
View File
@@ -0,0 +1,181 @@
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
buildRemoteFindCommand,
buildRemoteGrepCommand,
formatRemoteSearchOutput,
resolveRemoteSearchPath,
runRemoteFind,
runRemoteGrep,
} from "../src/remote-search.ts";
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
class SearchTransport implements RemoteTransport {
command = "";
cwd = "";
private readonly output: string;
private readonly exitCode: number | null;
constructor(output: string, exitCode: number | null = 0) {
this.output = output;
this.exitCode = exitCode;
}
connect(): Promise<void> { return Promise.resolve(); }
dispose(): Promise<void> { return Promise.resolve(); }
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
this.command = command;
this.cwd = cwd;
options.onData(Buffer.from(this.output));
return Promise.resolve({ exitCode: this.exitCode });
}
capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); }
readFile(): Promise<Buffer> { throw new Error("not used"); }
ensureReadable(): Promise<void> { throw new Error("not used"); }
ensureReadableWritable(): Promise<void> { throw new Error("not used"); }
detectImageMimeType(): Promise<string | null> { throw new Error("not used"); }
mkdir(): Promise<void> { throw new Error("not used"); }
writeFile(): Promise<void> { throw new Error("not used"); }
}
test("resolves 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");
});
test("builds bounded capability-adaptive commands with shell-quoted user input", () => {
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/);
assert.match(find.command, /head -n 13/);
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/);
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", () => {
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",
"/srv/app",
2,
"grep",
);
assert.equal(result.backend, "git-grep");
assert.equal(result.matchCount, 2);
assert.equal(result.truncated, true);
assert.match(result.text, /\/srv\/app\/src\/a\.ts:2: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");
assert.equal(transport.cwd, "/srv/app");
assert.match(transport.command, /command -v fd/);
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,
);
await assert.rejects(
runRemoteGrep(transport, { 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__/);
return true;
},
);
});
test("adaptive commands preserve no-match success and propagate real backend errors", async () => {
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/);
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 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);
const invalidRegex = buildRemoteGrepCommand({ pattern: "[", literal: false }, root);
const invalidProcess = spawnSync("/bin/bash", ["-c", invalidRegex.command], { cwd: root, encoding: "utf8", env: environment });
assert.notEqual(invalidProcess.status, 0);
assert.match(`${invalidProcess.stdout}${invalidProcess.stderr}`, /git-grep|fatal|regular expression/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 });
assert.notEqual(missingProcess.status, 0);
assert.match(`${missingProcess.stdout}${missingProcess.stderr}`, /find|No such file|not found/i);
const transport = new SearchTransport(execute(grep.command));
const throughTransport = await runRemoteGrep(transport, { pattern: "TODO", include: "*.ts" }, root, root);
assert.equal(throughTransport.matchCount, 1);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("propagates fd and ripgrep failures while preserving ripgrep no-match", () => {
const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-backends-"));
try {
const bin = join(root, "bin");
mkdirSync(bin);
const fd = join(bin, "fd");
const rg = join(bin, "rg");
writeFileSync(fd, "#!/bin/sh\necho 'fd exploded' >&2\nexit 3\n", { mode: 0o755 });
writeFileSync(rg, "#!/bin/sh\necho 'rg exploded' >&2\nexit 2\n", { mode: 0o755 });
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 });
assert.equal(failedFind.status, 3);
assert.match(failedFind.stdout, /fd exploded/);
const grep = buildRemoteGrepCommand({ pattern: "anything" }, root);
const failedGrep = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, encoding: "utf8", env: environment });
assert.equal(failedGrep.status, 2);
assert.match(failedGrep.stdout, /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 });
assert.equal(noMatch.status, 0);
assert.equal(formatRemoteSearchOutput(noMatch.stdout, root, grep.limit, "grep").matchCount, 0);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+359
View File
@@ -0,0 +1,359 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import type { Client, ClientChannel, ConnectConfig, SFTPWrapper } from "ssh2";
import type { SshHostConfig } from "../src/config.ts";
import { fingerprintHostKey, probeHostKey, Ssh2Transport } from "../src/ssh2-transport.ts";
function fixtureKey(label = "fixture"): Buffer {
const algorithm = Buffer.from("ssh-ed25519");
const length = Buffer.alloc(4);
length.writeUInt32BE(algorithm.length);
return Buffer.concat([length, algorithm, Buffer.from(label)]);
}
class FakeChannel extends EventEmitter {
stderr = new EventEmitter();
closeCalls = 0;
signals: string[] = [];
close(): void { this.closeCalls += 1; }
signal(value: string): void { this.signals.push(value); }
}
type ConnectAction = (config: ConnectConfig, client: FakeClient) => void;
type ExecAction = (command: string, callback: (error: Error | undefined, channel: ClientChannel) => void) => void;
class FakeClient extends EventEmitter {
connectConfig?: ConnectConfig;
command?: string;
channel?: FakeChannel;
sftpCalls = 0;
ended = false;
destroyed = false;
sftpValue?: SFTPWrapper;
connectAction?: ConnectAction;
execAction?: ExecAction;
connect(config: ConnectConfig): this {
this.connectConfig = config;
queueMicrotask(() => {
if (this.connectAction) this.connectAction(config, this);
else this.emit("ready");
});
return this;
}
exec(command: string, callback: (error: Error | undefined, channel: ClientChannel) => void): void {
this.command = command;
if (this.execAction) {
this.execAction(command, callback);
return;
}
const channel = new FakeChannel();
this.channel = channel;
callback(undefined, channel as ClientChannel);
queueMicrotask(() => {
channel.emit("data", Buffer.from("stdout\n"));
channel.stderr.emit("data", Buffer.from("stderr\n"));
channel.emit("close", 7);
});
}
sftp(callback: (error: Error | undefined, sftp: SFTPWrapper) => void): void {
this.sftpCalls += 1;
if (!this.sftpValue) callback(new Error("SFTP unavailable"), undefined as unknown as SFTPWrapper);
else callback(undefined, this.sftpValue);
}
end(): this {
this.ended = true;
this.emit("close");
return this;
}
destroy(): this {
this.destroyed = true;
return this;
}
}
class FakeSftp {
readonly files = new Map<string, Buffer>();
readonly directories = new Set<string>(["/"]);
readonly openCalls: Array<{ path: string; flags: string }> = [];
readonly writes: string[] = [];
readonly renames: Array<{ from: string; to: string; atomic: boolean }> = [];
readonly unlinks: string[] = [];
failStandardRename = false;
ext_openssh_rename?: (from: string, to: string, callback: (error?: Error) => void) => void;
constructor(atomicRename = true) {
if (atomicRename) {
this.ext_openssh_rename = (from, to, callback) => {
this.renames.push({ from, to, atomic: true });
const value = this.files.get(from);
if (!value) { callback(new Error("source missing")); return; }
this.files.set(to, value);
this.files.delete(from);
callback();
};
}
}
readFile(path: string, callback: (error: Error | undefined, data: Buffer) => void): void {
const value = this.files.get(path);
if (!value) callback(new Error(`missing file: ${path}`), Buffer.alloc(0));
else callback(undefined, Buffer.from(value));
}
writeFile(path: string, data: Buffer, callback: (error?: Error) => void): void {
this.writes.push(path);
this.files.set(path, Buffer.from(data));
callback();
}
open(path: string, flags: string, callback: (error: Error | undefined, handle: Buffer) => void): void {
this.openCalls.push({ path, flags });
if (!this.files.has(path)) callback(new Error(`cannot open: ${path}`), Buffer.alloc(0));
else callback(undefined, Buffer.from(path));
}
close(_handle: Buffer, callback: (error?: Error) => void): void { callback(); }
mkdir(path: string, callback: (error?: Error) => void): void {
if (this.directories.has(path)) { callback(new Error("already exists")); return; }
this.directories.add(path);
callback();
}
stat(path: string, callback: (error: Error | undefined, stats: object) => void): void {
if (this.directories.has(path)) callback(undefined, {});
else callback(new Error(`missing directory: ${path}`), {});
}
rename(from: string, to: string, callback: (error?: Error) => void): void {
this.renames.push({ from, to, atomic: false });
if (this.failStandardRename) { callback(new Error("replace unsupported")); return; }
const value = this.files.get(from);
if (!value) { callback(new Error("source missing")); return; }
this.files.set(to, value);
this.files.delete(from);
callback();
}
unlink(path: string, callback: (error?: Error) => void): void {
this.unlinks.push(path);
this.files.delete(path);
callback();
}
}
function passwordHost(method: "auto" | "password" | "keyboard-interactive" = "auto"): SshHostConfig {
return {
hostName: "host.example.test",
user: "builder",
port: 2222,
auth: { type: "password", password: "secret", method },
hostKey: fingerprintHostKey(fixtureKey()),
};
}
async function connectedTransport(
sftp?: FakeSftp,
host: SshHostConfig = passwordHost(),
): Promise<{ fake: FakeClient; transport: Ssh2Transport }> {
const fake = new FakeClient();
if (sftp) fake.sftpValue = sftp as unknown as SFTPWrapper;
const transport = new Ssh2Transport(host, fake as unknown as Client);
await transport.connect();
return { fake, transport };
}
test("connects with password auth, pins the host key, and streams exec output", async () => {
const { fake, transport } = await connectedTransport();
assert.equal(fake.connectConfig?.host, "host.example.test");
assert.equal(fake.connectConfig?.username, "builder");
assert.equal(fake.connectConfig?.password, "secret");
assert.equal(fake.connectConfig?.tryKeyboard, true);
assert.equal(fake.connectConfig?.hostVerifier?.(fixtureKey()), true);
assert.equal(fake.connectConfig?.hostVerifier?.(fixtureKey("wrong")), false);
const output: Buffer[] = [];
const result = await transport.exec("printf ok", "/srv/build", {
onData: (data) => output.push(data),
timeout: 5,
});
assert.equal(result.exitCode, 7);
assert.equal(Buffer.concat(output).toString("utf8"), "stdout\nstderr\n");
assert.match(fake.command ?? "", /^cd -- '\/srv\/build' && bash -lc 'printf ok' <\/dev\/null$/);
await transport.dispose();
assert.equal(fake.ended, true);
});
test("supports password-only and keyboard-interactive authentication", async () => {
const passwordOnly = await connectedTransport(undefined, passwordHost("password"));
assert.equal(passwordOnly.fake.connectConfig?.tryKeyboard, false);
await passwordOnly.transport.dispose();
const interactive = await connectedTransport(undefined, passwordHost("keyboard-interactive"));
let answers: string[] = [];
interactive.fake.emit(
"keyboard-interactive",
"name",
"instructions",
"",
[{ prompt: "Password:" }, { prompt: "Again:" }],
(values: string[]) => { answers = values; },
);
assert.deepEqual(answers, ["secret", "secret"]);
await interactive.transport.dispose();
});
test("loads private-key authentication and forwards the passphrase", async () => {
const root = mkdtempSync(join(tmpdir(), "pi-ssh-key-"));
try {
const identityFile = join(root, "id_test");
writeFileSync(identityFile, "PRIVATE KEY FIXTURE", { mode: 0o600 });
const host: SshHostConfig = {
...passwordHost(),
auth: { type: "private-key", identityFile, passphrase: "key-secret" },
};
const { fake, transport } = await connectedTransport(undefined, host);
assert.equal(Buffer.from(fake.connectConfig?.privateKey ?? "").toString("utf8"), "PRIVATE KEY FIXTURE");
assert.equal(fake.connectConfig?.passphrase, "key-secret");
assert.equal(fake.connectConfig?.password, undefined);
assert.equal(fake.connectConfig?.tryKeyboard, undefined);
await transport.dispose();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
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"));
sftp.files.set("/srv/app/image.png", Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
const { fake, transport } = await connectedTransport(sftp);
assert.equal((await transport.readFile("/srv/app/a.txt")).toString("utf8"), "hello");
await transport.ensureReadable("/srv/app/a.txt");
await transport.ensureReadableWritable("/srv/app/a.txt");
assert.deepEqual(sftp.openCalls, [
{ path: "/srv/app/a.txt", flags: "r" },
{ path: "/srv/app/a.txt", flags: "r+" },
]);
assert.equal(await transport.detectImageMimeType("/srv/app/image.png"), "image/png");
assert.equal(await transport.detectImageMimeType("/srv/app/a.txt"), null);
assert.equal(fake.sftpCalls, 1);
await transport.dispose();
});
test("creates parent directories and atomically renames remote writes", async () => {
const sftp = new FakeSftp(true);
const { transport } = await connectedTransport(sftp);
await transport.writeFile("/srv/app/output.txt", Buffer.from("new content"));
assert.equal(sftp.directories.has("/srv"), true);
assert.equal(sftp.directories.has("/srv/app"), true);
assert.equal(sftp.files.get("/srv/app/output.txt")?.toString("utf8"), "new content");
assert.equal(sftp.renames.length, 1);
assert.equal(sftp.renames[0]?.atomic, true);
assert.equal([...sftp.files.keys()].some((path) => path.includes(".pi-ssh-")), false);
await transport.dispose();
});
test("falls back to direct overwrite when SFTP v3 rename cannot replace", async () => {
const sftp = new FakeSftp(false);
sftp.directories.add("/srv");
sftp.directories.add("/srv/app");
sftp.files.set("/srv/app/output.txt", Buffer.from("old"));
sftp.failStandardRename = true;
const { transport } = await connectedTransport(sftp);
await transport.writeFile("/srv/app/output.txt", Buffer.from("replacement"));
assert.equal(sftp.files.get("/srv/app/output.txt")?.toString("utf8"), "replacement");
assert.equal(sftp.renames.length, 1);
assert.equal(sftp.renames[0]?.atomic, false);
assert.equal(sftp.unlinks.length, 1);
assert.equal([...sftp.files.keys()].some((path) => path.includes(".pi-ssh-")), false);
await transport.dispose();
});
test("aborts and times out commands by closing the active channel", async () => {
const abortClient = new FakeClient();
const abortChannel = new FakeChannel();
abortClient.execAction = (_command, callback) => {
abortClient.channel = abortChannel;
callback(undefined, abortChannel as ClientChannel);
};
const abortTransport = new Ssh2Transport(passwordHost(), abortClient as unknown as Client);
await abortTransport.connect();
const controller = new AbortController();
const aborted = abortTransport.exec("sleep 10", "/srv", { onData() {}, signal: controller.signal, timeout: 0 });
await new Promise<void>((resolve) => setImmediate(resolve));
controller.abort();
await assert.rejects(aborted, /SSH command aborted/);
assert.deepEqual(abortChannel.signals, ["KILL"]);
assert.equal(abortChannel.closeCalls, 1);
await abortTransport.dispose();
const timeoutClient = new FakeClient();
const timeoutChannel = new FakeChannel();
timeoutClient.execAction = (_command, callback) => {
timeoutClient.channel = timeoutChannel;
callback(undefined, timeoutChannel as ClientChannel);
};
const timeoutTransport = new Ssh2Transport(passwordHost(), timeoutClient as unknown as Client);
await timeoutTransport.connect();
await assert.rejects(
timeoutTransport.exec("sleep 10", "/srv", { onData() {}, timeout: 0.01 }),
/timed out after 0.01s/,
);
assert.deepEqual(timeoutChannel.signals, ["KILL"]);
assert.equal(timeoutChannel.closeCalls, 1);
await timeoutTransport.dispose();
});
test("fails closed after the SSH connection closes", async () => {
const sftp = new FakeSftp();
sftp.files.set("/srv/a.txt", Buffer.from("a"));
const { fake, transport } = await connectedTransport(sftp);
fake.emit("close");
await assert.rejects(transport.readFile("/srv/a.txt"), /SSH2 connection closed unexpectedly/);
await transport.dispose();
});
test("probes and fingerprints a host key without authenticating", async () => {
const fake = new FakeClient();
const key = fixtureKey("probe");
fake.connectAction = (config, client) => {
assert.equal(config.host, "probe.example.test");
assert.equal(config.port, 2200);
assert.equal(config.username, "probe-user");
assert.equal(config.hostVerifier?.(key), false);
client.emit("close");
};
const result = await probeHostKey("probe.example.test", 2200, "probe-user", fake as unknown as Client);
assert.deepEqual(result, fingerprintHostKey(key));
});
test("rejects host-key probing errors before a key is observed", async () => {
const fake = new FakeClient();
fake.connectAction = (_config, client) => client.emit("error", new Error("network unavailable"));
await assert.rejects(
probeHostKey("probe.example.test", 22, "probe-user", fake as unknown as Client),
/network unavailable/,
);
assert.equal(fake.destroyed, true);
});
test("times out host-key probing when the server never responds", async () => {
const fake = new FakeClient();
fake.connectAction = () => {};
await assert.rejects(
probeHostKey("probe.example.test", 22, "probe-user", fake as unknown as Client, 5),
/timed out while obtaining SSH host key/,
);
assert.equal(fake.destroyed, true);
assert.equal(fake.connectConfig?.readyTimeout, 5);
});
+81
View File
@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import { randomBytes } from "node:crypto";
import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { mkdtempSync } from "node:fs";
import type { PiSshConfig, VaultPaths } from "../src/config.ts";
import { decryptConfig, encryptConfig, loadVault, rotateVaultKey, saveVault } from "../src/vault.ts";
function fixture(): PiSshConfig {
return {
version: 1,
hosts: {
packaging: {
label: "Packaging",
hostName: "192.0.2.10",
user: "builder",
port: 22,
auth: { type: "password", password: "server-secret", method: "auto" },
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:fixture" },
},
},
};
}
function paths(): VaultPaths {
const directory = mkdtempSync(join(tmpdir(), "pi-ssh-vault-"));
return {
directory,
encryptedConfig: join(directory, "hosts.enc"),
key: join(directory, "vault.key"),
};
}
test("encrypts and authenticates the complete configuration", () => {
const key = randomBytes(32);
const encrypted = encryptConfig(fixture(), key);
assert.doesNotMatch(encrypted, /server-secret|192\.0\.2\.10|builder/);
assert.deepEqual(decryptConfig(encrypted, key), fixture());
const envelope = JSON.parse(encrypted);
envelope.ciphertext = `${envelope.ciphertext.slice(0, -2)}AA`;
assert.throws(() => decryptConfig(JSON.stringify(envelope), key), /integrity verification/);
assert.throws(() => decryptConfig(encrypted, randomBytes(32)), /integrity verification/);
});
test("writes an owner-only key and encrypted config without plaintext", () => {
const target = paths();
saveVault(fixture(), target);
assert.deepEqual(loadVault(target), fixture());
assert.doesNotMatch(readFileSync(target.encryptedConfig, "utf8"), /server-secret/);
assert.equal(Buffer.from(readFileSync(target.key, "utf8").trim(), "base64").length, 32);
if (process.platform !== "win32") {
assert.equal(statSync(target.directory).mode & 0o777, 0o700);
assert.equal(statSync(target.encryptedConfig).mode & 0o777, 0o600);
assert.equal(statSync(target.key).mode & 0o777, 0o600);
}
});
test("fails closed when vault files are missing or too broadly readable", () => {
const target = paths();
assert.throws(() => loadVault(target), /not configured/);
mkdirSync(target.directory, { recursive: true });
writeFileSync(target.key, `${randomBytes(32).toString("base64")}\n`, { mode: 0o600 });
assert.throws(() => loadVault(target), /not configured/);
saveVault(fixture(), target);
if (process.platform !== "win32") {
chmodSync(target.encryptedConfig, 0o644);
assert.throws(() => loadVault(target), /permissions/);
}
});
test("rotates the adjacent key while preserving the encrypted configuration", () => {
const target = paths();
saveVault(fixture(), target);
const oldKey = readFileSync(target.key, "utf8");
rotateVaultKey(target);
assert.notEqual(readFileSync(target.key, "utf8"), oldKey);
assert.deepEqual(loadVault(target), fixture());
});