mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: add pure ssh2 remote operations
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
.pi/
|
||||
*.log
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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 1–200; 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
@@ -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}` };
|
||||
});
|
||||
}
|
||||
@@ -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": "*"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Executable
+5
@@ -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");
|
||||
Executable
+203
@@ -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;
|
||||
});
|
||||
@@ -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: {} };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Vendored
+48
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
Reference in New Issue
Block a user