fix(pi-ssh): harden remote execution and search

This commit is contained in:
云服务部-叶林立
2026-08-24 23:04:29 +08:00
parent a7891f18bf
commit aff91b0972
30 changed files with 1063 additions and 222 deletions
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## Unreleased
- Keep the SSH transport persistent while making `ssh_bash` explicitly stateless: every call starts in the active remote cwd and local Pi cwd/environment metadata can no longer leak into remote execution.
- Add reviewed, sequential `ssh_cd` workspace changes for persistent remote cwd selection without a hidden interactive shell; dependent remote calls are explicitly deferred until the change succeeds.
- Clarify remote shell prompt metadata and add regression coverage for cwd isolation, atomic workspace changes, failure preservation, and subsequent shell/file/search path resolution.
- Make `ssh_connect` a sequential reviewed state transition so establishing or replacing the active connection cannot overlap dependent remote operations.
- Separate remote-search execution cwd from its absolute target root, including regression coverage for single-file `ssh_grep` targets.
- Resolve relative and `~/` remote paths to absolute targets in permission previews while retaining the original request and excluding remote paths from local filesystem gates.
- Replace unbounded HOME/cwd capture with cancellable, bounded, random-marker probes that accept one absolute POSIX path and preserve prior state on failure.
- Allow up to four independent SSH exec channels while retaining serialized SFTP operations; queued exec cancellation is immediate.
- Report bounded imported host ID alternatives for unknown IDs and add actionable resolved-root guidance for 30-second remote-search timeouts.
- Normalize `ssh_grep` hidden-file, basename-glob, and portable ERE behavior across ripgrep, Git, and fallback backends; reject `~user` paths.
- Treat fallback grep exit 1 as a genuine no-match without hiding find or grep errors, separate backend stderr, and use bounded NUL-delimited grep records for newline-safe filenames.
## 0.9.0 - 2026-08-21
- Add reviewed, agent-callable `ssh_connect` for explicitly imported hosts.
+18 -9
View File
@@ -3,6 +3,7 @@
`pi-ssh` keeps Pi and its local tools on the local machine while exposing explicit remote tools over a persistent Node `ssh2` connection:
- `ssh_connect`
- `ssh_cd`
- `ssh_read`
- `ssh_write`
- `ssh_edit`
@@ -14,7 +15,7 @@ The extension does not override Pi's local `read`, `write`, `edit`, `find`, `gre
## Architecture
Runtime connections are pure `ssh2`; the extension does not spawn OpenSSH and does not require `sshpass`, `ControlMaster`, or passwordless login. Remote file operations use SFTP and remote shell commands use an SSH exec channel.
Runtime connections are pure `ssh2`; the extension does not spawn OpenSSH and does not require `sshpass`, `ControlMaster`, or passwordless login. Remote file operations use SFTP and remote shell commands use an SSH exec channel. The SSH transport persists, but commands intentionally use fresh non-interactive Bash processes rather than a hidden stateful PTY.
Hosts must be explicitly imported before use. OpenSSH remains only an import source: the configuration helper runs `ssh -G <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.
@@ -88,20 +89,24 @@ The model first calls:
ssh_connect({ hostId: "packaging-server" })
```
It may set `remotePath` to an absolute path, `~`, or a path beginning with `~/`. After the reviewed connection succeeds, the model uses the other `ssh_*` tools to complete the requested work. Only imported host IDs are accepted; arbitrary `user@host` targets are rejected. A new connection replaces the previous active connection, and session shutdown disconnects it.
It may set `remotePath` to an absolute path, `~`, or a path beginning with `~/`. The model calls sequential `ssh_connect` as a separate step and waits for the reviewed connection to succeed before using other `ssh_*` tools. `ssh_cd` explicitly changes the active remote workspace for subsequent shell, relative file, and relative search operations; it accepts absolute paths, paths relative to the current remote cwd, and `~/` paths relative to remote HOME. The model must also call `ssh_cd` as a separate step and wait for its successful result before issuing dependent remote operations. Only imported host IDs are accepted; arbitrary `user@host` targets are rejected. Unknown IDs report a bounded list of available imported IDs without exposing credentials. A new connection replaces the previous active connection, and session shutdown disconnects it.
## Runtime behavior
- `ssh_connect` is the only runtime connection surface; it is agent-callable and permission-reviewed.
- One persistent `ssh2` client is used for the active host.
- Each `ssh_bash` call opens an exec channel and runs under `bash -lc` in the selected remote cwd.
- `ssh_connect` is the only runtime connection surface; it is agent-callable, permission-reviewed, sequential, and cancellable so connection replacement cannot overlap another tool call or remain stuck after user cancellation.
- One persistent `ssh2` client is used for the active host; no interactive shell or PTY state is retained.
- `ssh_cd` is a sequential state transition: it validates a remote directory and updates the active workspace without reconnecting. Dependent tool calls must wait for it to succeed.
- HOME/cwd probes use bounded random-marker framing, require one absolute POSIX path, and preserve the previous state on malformed output, timeout, or cancellation.
- Independent exec channels use bounded concurrency of four; shared SFTP operations remain serialized.
- Each `ssh_bash` call opens a fresh exec channel and runs under `bash -lc` in the active remote cwd. A command-local `cd` is temporary, and `cd`, `export`, alias, function, or other shell state inside one call does not persist to the next call.
- SFTP provides remote reads and writes.
- Writes use a temporary remote file and prefer OpenSSH's atomic rename SFTP extension when the server supports it.
- A pinned SHA256 host-key mismatch fails closed.
- Connection loss fails closed; the extension does not silently replay a command.
- Remote `AGENTS.md` and `CLAUDE.md` files are never discovered or injected.
- `ssh_find` and `ssh_grep` perform capability detection inside each approved call and return at most 200 bounded result lines.
- `ssh_find` and `ssh_grep` perform capability detection inside each approved call and return at most 200 bounded result lines. Search targets are always separate from the execution cwd, so `ssh_grep` can target a single remote file without attempting to enter it as a directory. A 30-second timeout reports the resolved root and instructs the model to narrow it with `ssh_find`.
- RTK treats only `ssh_bash` as a Bash output-compaction alias; it does not rewrite remote commands or process remote search/read results.
- Permission previews display resolved absolute remote paths together with the original relative or `~/` request; remote paths never enter local filesystem gates.
### Adaptive remote search
@@ -111,13 +116,15 @@ It may set `remotePath` to an absolute path, `~`, or a path beginning with `~/`.
fd → fdfind → git ls-files → find
```
`ssh_grep` defaults to literal, case-insensitive content matching with this backend order:
`ssh_grep` defaults to literal, case-insensitive content matching with this backend order. Hidden paths are excluded unless `includeHidden` is true, and `include` is a basename-only glob such as `*.ts` (globs containing `/` are rejected):
```text
ripgrep → git grep → find + grep
```
No backend is installed or uploaded. The tools use what the server already provides, report the chosen backend and truncation state, and cap `limit` at 200. A genuine no-match result succeeds with zero rows; invalid regexes, missing roots, and backend failures remain errors even though output passes through bounded `head`/`cut` stages. Narrow `path` and `pattern` when truncated rather than increasing the limit. Direct `find`, `fd`, `grep`, and `rg` commands remain forbidden through `ssh_bash`; use the structured search tools instead. Regex mode (`literal: false`) follows the selected backend's regex dialect.
No backend is installed or uploaded. The tools use what the server already provides, report the chosen backend and truncation state, and cap `limit` at 200. `literal: false` accepts the portable POSIX ERE subset shared by the backends; Git and fallback grep are explicitly run in ERE mode. Grep results use bounded NUL-delimited path/line/content records, so newline-containing filenames cannot corrupt match counts or truncation. Backend stderr is kept out of result rows and is reported as a warning on success or failure detail on error.
A genuine no-match result—including the `find + grep` fallback—succeeds with zero rows; invalid regexes, missing roots, and backend failures remain errors. Search paths accept absolute paths, relative paths, `~`, and `~/...`; `~user` expansion is rejected. Narrow `path` and `pattern` when truncated rather than increasing the limit. Direct `find`, `fd`, `grep`, and `rg` commands remain forbidden through `ssh_bash`; use the structured search tools instead.
The remote host must provide `bash`. SFTP support is required for file tools.
@@ -126,7 +133,7 @@ The remote host must provide `bash`. SFTP support is required for file tools.
All remote operations enter the bundle's existing permission chain:
- `ssh_connect` starts as `ask`, so AutoReview can verify that the direct user request names the requested imported host;
- `ssh_read`, `ssh_write`, `ssh_edit`, `ssh_find`, and `ssh_grep` start as `ask`;
- `ssh_cd`, `ssh_read`, `ssh_write`, `ssh_edit`, `ssh_find`, and `ssh_grep` start as `ask`;
- `ssh_bash` uses the full deterministic Bash policy and `decisionFloor: "ask"`;
- deterministic hard denies remain denies;
- asks enter the configured `auto-review` authorizer;
@@ -153,6 +160,8 @@ Important files:
- `index.ts` — Pi extension and tool registration
- `src/ssh2-transport.ts` — persistent ssh2, exec, and SFTP transport
- `src/remote-bash.ts` — stateless Bash adapter pinned to the active remote cwd
- `src/remote-cwd.ts` — explicit remote workspace path resolution
- `src/config.ts` — validated configuration types
- `src/vault.ts` — AES-GCM vault
- `src/import.ts``ssh -G` import helpers
+16 -8
View File
@@ -5,6 +5,7 @@
Pi and its default tools remain local. Explicit collision-free tools perform selected operations on one configured remote server:
- `ssh_connect`
- `ssh_cd`
- `ssh_read`
- `ssh_write`
- `ssh_edit`
@@ -33,7 +34,7 @@ The resulting host ID is the only runtime selector. When a direct user request n
ssh_connect({ hostId: "<host-id>", remotePath?: "/absolute/or/~/path" })
```
`ssh_connect` is the only runtime connection surface. It is a model tool governed by `pi-permission-system`; there is no `/ssh` command, `--ssh` flag, session-resume reconnect, or user `!` remote-shell override.
`ssh_connect` is the only runtime connection surface. It is a sequential, cancellable model tool governed by `pi-permission-system`: the agent calls it as a separate step and waits for success before dependent remote tools, so establishing or replacing a connection cannot overlap another tool call. Unknown IDs return at most ten sorted imported IDs and never endpoint credentials. There is no `/ssh` command, `--ssh` flag, session-resume reconnect, or user `!` remote-shell override.
Arbitrary `user@host`, port overrides, ProxyJump, and ProxyCommand are not supported in the first pure-ssh2 version. Unsupported imported configuration is rejected rather than ignored.
@@ -69,11 +70,13 @@ Import displays the observed host-key algorithm and SHA256 fingerprint for expli
## Transport
One persistent `ssh2.Client` belongs to the active host. Connection loss fails closed and no operation is automatically replayed.
One persistent `ssh2.Client` belongs to the active host. The transport persists, but there is no persistent interactive shell or PTY. Connection loss fails closed and no operation is automatically replayed.
### Shell
`ssh_bash` opens exec channels. Commands run through `bash -lc` after changing to the selected remote cwd. Output streams through the normal Pi Bash operations callback. Abort or timeout closes the channel without reconnecting or replaying.
`ssh_bash` opens a fresh exec channel and non-interactive Bash process for every call. Commands run through `bash -lc` after changing to the active remote cwd. The cwd supplied by Pi's local Bash factory is ignored at the transport adapter boundary; only connection state may select the remote cwd. A command-local `cd` is intentionally temporary; shell-local `cd`, exports, aliases, functions, and other process state do not persist across calls. Output streams through the normal Pi Bash operations callback. Abort or timeout closes the channel without reconnecting or replaying.
`ssh_cd` is the explicit persistent workspace operation and declares Pi's sequential execution mode so it cannot overlap sibling tool calls. It resolves absolute paths, paths relative to the active remote cwd, and `~/` paths relative to remote HOME; validates the directory by executing `pwd` from it; and updates connection state without reconnecting. The model calls it as a separate step and waits for success before issuing subsequent relative file/search operations or `ssh_bash` calls that depend on the updated cwd.
### Files
@@ -92,10 +95,13 @@ Remote paths map from Pi's local factory cwd into the selected remote cwd, but p
Search tools execute one capability-adaptive, bounded shell pipeline inside the already approved tool call. They never install or upload binaries.
- `ssh_find`: `fd``fdfind``git ls-files``find`; fixed filename/path substring semantics.
- `ssh_grep`: `rg``git grep``find -exec grep`; literal case-insensitive semantics by default.
- each backend emits at most `limit + 1` lines so truncation is explicit; public `limit` is 1200; each returned line is capped.
- relative paths resolve against remote cwd and `~/` against remote home.
- `ssh_grep`: `rg``git grep``find + grep`; literal case-insensitive semantics by default, optional hidden-path inclusion, basename-only include globs, and a portable POSIX ERE subset when literal mode is disabled.
- each backend emits at most `limit + 1` NUL-delimited path/line/content records so truncation is explicit and newline filenames remain one result; public `limit` is 1200; each returned line is capped.
- relative paths resolve against remote cwd and `~/` against remote home; other `~user` forms are rejected.
- the search target root is passed to the backend as an absolute argument, while the search process executes from the active remote cwd; a single-file `ssh_grep` target is never used as a process cwd.
- searches time out after 30 seconds with the resolved root and explicit guidance to narrow it before retrying.
- user strings are single-quoted as shell arguments and NUL/newline input is rejected.
- backend stdout contains only parseable results; stderr is captured independently for warnings and failure diagnostics.
- capability detection occurs only within the reviewed search call.
- search output is normalized by `pi-ssh` and excluded from RTK compaction.
@@ -106,7 +112,7 @@ Direct search commands remain denied through `ssh_bash`; structured search tools
There is one permission gate: `pi-permission-system`.
- `ssh_connect` defaults to `ask`; its preview resolves the imported host ID to the non-secret endpoint, port, and requested/default cwd before connection.
- `ssh_read`, `ssh_write`, `ssh_edit`, `ssh_find`, and `ssh_grep` default to `ask`.
- `ssh_cd`, `ssh_read`, `ssh_write`, `ssh_edit`, `ssh_find`, and `ssh_grep` default to `ask`; remote path extractors disable local path normalization for all six tools, while previews resolve relative and `~/` requests against the active remote cwd/HOME and display both requested and absolute paths.
- `ssh_bash` is a Bash-semantic `shellTools` alias with `decisionFloor: "ask"`.
- Bash hard denies remain denies.
- All asks enter the configured authorizer chain.
@@ -114,8 +120,10 @@ There is one permission gate: `pi-permission-system`.
Evidence includes configured host ID, endpoint, port, remote cwd, and a bounded operation summary, never credentials.
Connection HOME/cwd discovery and `ssh_cd` validation use a bounded random-marker probe over a cancellable exec channel. The parser accepts exactly one framed absolute POSIX path; startup banners, malformed/multiline values, overflow, timeout, or cancellation cannot update connection state. Independent exec operations use at most four concurrent SSH channels, while SFTP operations remain serialized.
## Session lifecycle
Connections are created only by an approved `ssh_connect` call after session start. They are not persisted or automatically resumed. Connecting another imported host disposes the previous client, and session shutdown disposes the active client.
The system prompt states that default tools are local and `ssh_*` tools are remote after a connection becomes active. It does not include remote file content.
The system prompt states that default tools are local, `ssh_*` tools are remote, and each `ssh_bash` call starts a fresh non-interactive shell after a connection becomes active. It directs persistent workspace changes through `ssh_cd` and does not include remote file content.
+68 -48
View File
@@ -6,7 +6,6 @@ import {
createEditTool,
createReadTool,
createWriteTool,
type BashOperations,
type EditOperations,
type ReadOperations,
type WriteOperations,
@@ -17,19 +16,27 @@ import {
type SshPermissionConnection,
} from "./permission-integration.ts";
import {
getConfiguredHost,
parseConnectInput,
SSH_CONNECT_TOOL_METADATA,
type HostSelection,
} from "./src/agent-connection.ts";
import { loadVault } from "./src/vault.ts";
import { Ssh2Transport, type RemoteTransport } from "./src/ssh2-transport.ts";
import type { PiSshConfig, SshHostConfig } from "./src/config.ts";
import type { SshHostConfig } from "./src/config.ts";
import {
runRemoteFind,
runRemoteGrep,
type RemoteFindInput,
type RemoteGrepInput,
} from "./src/remote-search.ts";
import { createRemoteBashOps } from "./src/remote-bash.ts";
import {
changeRemoteCwd,
mapLocalPathToRemote,
SSH_CD_EXECUTION_MODE,
} from "./src/remote-cwd.ts";
import { probeRemotePath } from "./src/remote-probe.ts";
interface SshConnection {
@@ -43,17 +50,6 @@ interface SshConnection {
}
function mapLocalPathToRemote(path: string, connection: SshConnection): string {
if (path === connection.localCwd) return connection.remoteCwd;
if (path.startsWith(`${connection.localCwd}/`)) {
return `${connection.remoteCwd}${path.slice(connection.localCwd.length)}`;
}
if (path === connection.localHome) return connection.remoteHome;
if (path.startsWith(`${connection.localHome}/`)) {
return `${connection.remoteHome}${path.slice(connection.localHome.length)}`;
}
return path;
}
function createRemoteReadOps(connection: SshConnection, transport: RemoteTransport): ReadOperations {
return {
@@ -87,17 +83,7 @@ function createRemoteEditOps(connection: SshConnection, transport: RemoteTranspo
};
}
function createRemoteBashOps(transport: RemoteTransport): BashOperations {
return {
exec: (command, cwd, { onData, signal, timeout }) => transport.exec(command, cwd, { onData, signal, timeout }),
};
}
function getConfiguredHost(config: PiSshConfig, hostId: string): SshHostConfig {
const host = config.hosts[hostId];
if (!host) throw new Error(`unknown pi-ssh host '${hostId}'; run ssh_config.sh import ${hostId}`);
return host;
}
function resolveRequestedPath(selection: HostSelection, host: SshHostConfig, remoteHome: string, remotePwd: string): string {
const requested = selection.remotePath ?? host.defaultCwd ?? remotePwd;
@@ -106,30 +92,22 @@ function resolveRequestedPath(selection: HostSelection, host: SshHostConfig, rem
return requested;
}
async function captureChecked(transport: RemoteTransport, command: string, cwd = "."): Promise<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,
signal?: AbortSignal,
): Promise<{ connection: SshConnection; transport: Ssh2Transport }> {
const config = loadVault();
const host = getConfiguredHost(config, selection.hostId);
const transport = new Ssh2Transport(host);
try {
await transport.connect();
const remoteHome = await captureChecked(transport, 'printf "%s" "$HOME"');
const remotePwd = await captureChecked(transport, "pwd");
if (!remoteHome || !remotePwd) throw new Error("remote HOME/cwd probe returned empty output");
await transport.connect(signal);
const remoteHome = await probeRemotePath(transport, "home", ".", signal);
const remotePwd = await probeRemotePath(transport, "cwd", ".", signal);
const requestedPath = resolveRequestedPath(selection, host, remoteHome, remotePwd);
const remoteCwd = await captureChecked(transport, "pwd", requestedPath);
const remoteCwd = await probeRemotePath(transport, "cwd", requestedPath, signal);
return {
connection: {
hostId: selection.hostId,
@@ -169,7 +147,7 @@ export default function piSshExtension(pi: ExtensionAPI): void {
const localRead = createReadTool(localCwd);
const localWrite = createWriteTool(localCwd);
const localEdit = createEditTool(localCwd);
const localBash = createBashTool(localCwd);
const localBash = createBashTool(localCwd, { exposeSessionEnvironment: false });
let connection: SshConnection | null = null;
let transport: Ssh2Transport | null = null;
@@ -199,9 +177,13 @@ export default function piSshExtension(pi: ExtensionAPI): void {
pi.registerTool({
...SSH_CONNECT_TOOL_METADATA,
async execute(_id, params) {
async execute(_id, params, signal) {
const selection = parseConnectInput(params as Record<string, unknown>);
const connected = await connectSelection(selection, localCwd, localHome);
const connected = await connectSelection(selection, localCwd, localHome, signal);
if (signal?.aborted) {
await connected.transport.dispose();
throw new Error("SSH connection aborted");
}
await activateConnection(connected.connection, connected.transport);
const text = `Connected to ${connected.connection.remote}:${connected.connection.remoteCwd} (port ${connected.connection.port}).`;
return {
@@ -216,6 +198,37 @@ export default function piSshExtension(pi: ExtensionAPI): void {
},
});
pi.registerTool({
name: "ssh_cd",
label: "ssh_cd",
description: "Change the active SSH workspace directory as a reviewed, persistent state transition. Call ssh_cd separately and wait for it to succeed before issuing ssh_bash or relative remote file/search operations that depend on the new directory.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
minLength: 1,
description: "Remote directory: absolute, relative to the active remote cwd, or ~/ relative to remote HOME",
},
},
required: ["path"],
additionalProperties: false,
},
executionMode: SSH_CD_EXECUTION_MODE,
async execute(_id, params, signal) {
const active = requireSsh("ssh_cd");
const changed = await changeRemoteCwd(
active.connection,
(params as { path: string }).path,
(requestedCwd) => probeRemotePath(active.transport, "cwd", requestedCwd, signal),
);
return {
content: [{ type: "text", text: `Remote cwd changed from ${changed.previousCwd} to ${changed.remoteCwd}. Dependent remote tools may now run.` }],
details: changed,
};
},
});
pi.registerTool({
...localRead,
name: "ssh_read",
@@ -287,15 +300,16 @@ export default function piSshExtension(pi: ExtensionAPI): void {
pi.registerTool({
name: "ssh_grep",
label: "ssh_grep",
description: "Search remote file contents using ripgrep, git grep, or grep. Literal case-insensitive matching is the default; results are bounded and require an active SSH2 connection.",
description: "Search remote file contents using ripgrep, git grep, or grep with consistent hidden-file, basename-glob, and portable POSIX ERE semantics. Literal case-insensitive matching is the default; results are bounded and require an active SSH2 connection.",
parameters: {
type: "object",
properties: {
pattern: { type: "string", description: "Text or regular expression to search for" },
pattern: { type: "string", description: "Text to search for, or a portable POSIX ERE when literal is false" },
path: { type: "string", description: "Remote root path; defaults to the active remote cwd" },
literal: { type: "boolean", description: "Treat pattern as fixed text (default: true)" },
literal: { type: "boolean", description: "Treat pattern as fixed text; false uses portable POSIX ERE syntax (default: true)" },
caseSensitive: { type: "boolean", description: "Use case-sensitive matching (default: false)" },
include: { type: "string", description: "Optional file glob such as *.ts" },
include: { type: "string", description: "Optional basename-only glob such as *.ts; / is not allowed" },
includeHidden: { type: "boolean", description: "Search hidden files and directories (default: false)" },
limit: { type: "integer", minimum: 1, maximum: 200, description: "Maximum result lines (default: 50)" },
},
required: ["pattern"],
@@ -321,11 +335,15 @@ export default function piSshExtension(pi: ExtensionAPI): void {
...localBash,
name: "ssh_bash",
label: "ssh_bash",
description: `Run a shell command through the active SSH2 connection. ${localBash.description}`,
description: "Run a non-search shell command on the active SSH server. Every call starts a fresh non-interactive Bash process in the active remote cwd; cd, exported variables, aliases, and other shell state do not persist. For a persistent workspace change, call ssh_cd separately and wait for success before calling ssh_bash. Use command-local cd only for an intentionally temporary directory change. Relative paths are remote. Use ssh_find or ssh_grep instead of find, fd, grep, or rg.",
promptSnippet: undefined,
promptGuidelines: undefined,
async execute(id, params, signal, onUpdate) {
const active = requireSsh("ssh_bash");
return createBashTool(localCwd, { operations: createRemoteBashOps(active.transport) })
.execute(id, params, signal, onUpdate);
return createBashTool(active.connection.remoteCwd, {
operations: createRemoteBashOps(active.connection, active.transport),
exposeSessionEnvironment: false,
}).execute(id, params, signal, onUpdate);
},
});
@@ -341,9 +359,11 @@ export default function piSshExtension(pi: ExtensionAPI): void {
`\n\n# Remote SSH connection\n\n` +
`An SSH2 connection to configured host ${connection.remote} on port ${connection.port} is active. ` +
`The default read/write/edit/bash/find/grep tools act on the LOCAL machine. ` +
`Use ssh_read, ssh_write, ssh_edit, ssh_find, ssh_grep, and ssh_bash for explicit remote operations. ` +
`Use ssh_cd, ssh_read, ssh_write, ssh_edit, ssh_find, ssh_grep, and ssh_bash for explicit remote operations. ` +
`Use ssh_find before ssh_grep to narrow remote searches; both tools return bounded results and choose the fastest available remote backend. ` +
`Remote operations are rooted at ${connection.remoteCwd}; relative paths resolve against that directory.`;
`Remote operations are rooted at ${connection.remoteCwd}; relative paths resolve against that directory. ` +
`For a persistent workspace change, call ssh_cd as a separate step and wait for its successful result before issuing dependent remote tool calls. ` +
`Each ssh_bash call starts a fresh non-interactive shell, so use command-local cd only for an intentionally temporary change; cd, exports, aliases, and other shell state inside one command do not persist to the next call.`;
return { systemPrompt: `${event.systemPrompt}${guidance}` };
});
}
+30 -7
View File
@@ -1,10 +1,12 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type { PermissionsService } from "@gotgenes/pi-permission-system";
import { resolveRemoteCwd } from "./src/remote-cwd.ts";
export interface SshPermissionConnection {
remote: string;
port?: number;
remoteCwd: string;
remoteHome?: string;
}
export interface SshPermissionIntegrationDependencies {
@@ -17,8 +19,8 @@ export interface SshPermissionIntegrationDependencies {
type PermissionIntegrationApi = Pick<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 = ["ssh_connect", ...REMOTE_FILE_TOOLS, "ssh_bash"] as const;
const REMOTE_PATH_TOOLS = ["ssh_cd", "ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"] as const;
const REMOTE_TOOLS = ["ssh_connect", ...REMOTE_PATH_TOOLS, "ssh_bash"] as const;
function inline(value: string, limit = 240): string {
const normalized = value.replace(/\s+/g, " ").trim();
@@ -40,6 +42,23 @@ function formatTarget(connection: SshPermissionConnection | null): string {
return `SSH target '${inline(connection.remote)}${port}' in remote cwd '${inline(connection.remoteCwd)}'`;
}
function remotePathDetail(
requested: string,
connection: SshPermissionConnection | null,
label = "remote path",
): string {
let resolved: string | undefined;
if (connection?.remoteHome) {
try {
resolved = resolveRemoteCwd(requested, connection.remoteCwd, connection.remoteHome);
} catch {
// Execution performs authoritative validation; preserve the raw request.
}
}
if (!resolved || resolved === requested) return `${label} '${inline(requested)}'`;
return `${label} '${inline(resolved)}' (requested '${inline(requested)}')`;
}
export function formatSshPermissionInput(
toolName: string,
input: ToolInput,
@@ -55,8 +74,12 @@ export function formatSshPermissionInput(
return `requested imported SSH host '${hostId}'${remotePath ? ` in remote cwd '${inline(remotePath)}'` : ""}; establish a persistent SSH2 connection`;
}
if (toolName === "ssh_cd") {
return `${target}; change the active ${path ? remotePathDetail(path, connection, "remote cwd to") : "remote cwd to '<unspecified>'"}`;
}
if (toolName === "ssh_read") {
const details = path ? [`remote path '${inline(path)}'`] : ["an unspecified remote path"];
const details = path ? [remotePathDetail(path, connection)] : ["an unspecified remote path"];
if (typeof input.offset === "number") details.push(`offset ${input.offset}`);
if (typeof input.limit === "number") details.push(`limit ${input.limit}`);
return `${target}; read ${details.join(", ")}`;
@@ -64,20 +87,20 @@ export function formatSshPermissionInput(
if (toolName === "ssh_write") {
const content = stringField(input, "content") ?? "";
return `${target}; write remote path '${inline(path ?? "<unspecified>")}' (${countLines(content)} lines, ${content.length} characters)`;
return `${target}; write ${path ? remotePathDetail(path, connection) : "remote 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)`;
return `${target}; edit ${path ? remotePathDetail(path, connection) : "remote 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 ?? ".")}'`,
remotePathDetail(path ?? ".", connection, "under"),
`for '${pattern}'`,
`limit ${typeof input.limit === "number" ? input.limit : 50}`,
];
@@ -140,7 +163,7 @@ export function installSshPermissionIntegration(
}),
);
}
for (const toolName of REMOTE_FILE_TOOLS) {
for (const toolName of REMOTE_PATH_TOOLS) {
pending.push(service.registerToolAccessExtractor(toolName, () => undefined));
}
disposers = pending;
+3 -4
View File
@@ -10,6 +10,7 @@ import {
} from "../src/import.ts";
import { expandUserPath } from "../src/config.ts";
import { probeHostKey, Ssh2Transport } from "../src/ssh2-transport.ts";
import { probeRemotePath } from "../src/remote-probe.ts";
import { loadVaultOrEmpty, rotateVaultKey, saveVault } from "../src/vault.ts";
async function question(prompt) {
@@ -136,10 +137,8 @@ async function importHost(config, alias) {
try {
console.log("Testing SSH2 authentication...");
await transport.connect();
const result = await transport.capture('printf "%s\\n%s" "$HOME" "$(pwd)"', ".", 20);
if (result.exitCode !== 0) throw new Error("remote HOME/cwd probe failed");
const [remoteHome, remoteCwd] = result.output.toString("utf8").trim().split(/\r?\n/, 2);
if (!remoteHome || !remoteCwd) throw new Error("remote HOME/cwd probe returned incomplete output");
await probeRemotePath(transport, "home");
const remoteCwd = await probeRemotePath(transport, "cwd");
host.defaultCwd = remoteCwd;
console.log(`Connected successfully; default cwd: ${remoteCwd}`);
} finally {
+17 -1
View File
@@ -1,3 +1,5 @@
import type { PiSshConfig, SshHostConfig } from "./config.ts";
export interface HostSelection {
hostId: string;
remotePath?: string;
@@ -6,7 +8,7 @@ export interface HostSelection {
export const SSH_CONNECT_TOOL_METADATA = {
name: "ssh_connect",
label: "ssh_connect",
description: "Establish a persistent SSH2 connection to an explicitly imported host. Use this when the user names a remote server as part of a concrete task; the connection request is reviewed before any network connection is opened.",
description: "Establish a persistent SSH2 connection to an explicitly imported host as a sequential state transition. Use this as a separate step when the user names a remote server as part of a concrete task, and wait for success before calling dependent ssh_* tools; the connection request is reviewed before any network connection is opened.",
parameters: {
type: "object",
properties: {
@@ -16,6 +18,7 @@ export const SSH_CONNECT_TOOL_METADATA = {
required: ["hostId"],
additionalProperties: false,
},
executionMode: "sequential",
} as const;
export function parseConnectInput(input: Record<string, unknown>): HostSelection {
@@ -31,3 +34,16 @@ export function parseConnectInput(input: Record<string, unknown>): HostSelection
}
return { hostId, remotePath };
}
export function getConfiguredHost(config: PiSshConfig, hostId: string): SshHostConfig {
const host = config.hosts[hostId];
if (host) return host;
const hostIds = Object.keys(config.hosts).sort();
if (hostIds.length === 0) {
throw new Error(`unknown pi-ssh host '${hostId}'; no hosts are imported; run ssh_config.sh import <alias>`);
}
const shown = hostIds.slice(0, 10);
const remaining = hostIds.length - shown.length;
const available = `${shown.join(", ")}${remaining > 0 ? `, … (+${remaining} more)` : ""}`;
throw new Error(`unknown pi-ssh host '${hostId}'; available imported host IDs: ${available}`);
}
+27
View File
@@ -0,0 +1,27 @@
import type { BashOperations } from "@earendil-works/pi-coding-agent";
import type { RemoteTransport } from "./ssh2-transport.ts";
export interface RemoteBashConnection {
remoteCwd: string;
}
/**
* Adapt Pi's Bash output machinery to the active remote workspace.
*
* The cwd supplied by Pi's Bash factory is deliberately ignored: it belongs
* to the factory's filesystem namespace and must never leak into SSH command
* execution. The connection's mutable remoteCwd is the sole shell base.
*/
export function createRemoteBashOps(
connection: RemoteBashConnection,
transport: RemoteTransport,
): BashOperations {
return {
exec: (command, _factoryCwd, { onData, signal, timeout }) => {
if (!connection.remoteCwd.startsWith("/")) {
throw new Error(`ssh_bash requires an absolute remote cwd, received '${connection.remoteCwd}'`);
}
return transport.exec(command, connection.remoteCwd, { onData, signal, timeout });
},
};
}
+59
View File
@@ -0,0 +1,59 @@
import { posix as posixPath } from "node:path";
export const SSH_CD_EXECUTION_MODE = "sequential" as const;
export interface RemoteWorkspaceConnection {
remoteCwd: string;
remoteHome: string;
}
export interface RemotePathMappingConnection extends RemoteWorkspaceConnection {
localCwd: string;
localHome: string;
}
export interface RemoteCwdChange {
previousCwd: string;
remoteCwd: string;
}
export function resolveRemoteCwd(path: string, currentCwd: string, remoteHome: string): string {
if (typeof path !== "string" || path.length === 0) {
throw new Error("path must be a non-empty string");
}
if (/[\0\r\n]/u.test(path)) {
throw new Error("path must not contain NUL or newline characters");
}
if (!currentCwd.startsWith("/") || !remoteHome.startsWith("/")) {
throw new Error("the active SSH connection has an invalid remote workspace");
}
if (path === "~") return posixPath.normalize(remoteHome);
if (path.startsWith("~/")) return posixPath.normalize(posixPath.join(remoteHome, path.slice(2)));
if (path.startsWith("~")) throw new Error("path supports only '~' or '~/' home expansion");
if (path.startsWith("/")) return posixPath.normalize(path);
return posixPath.normalize(posixPath.join(currentCwd, path));
}
export async function changeRemoteCwd(
connection: RemoteWorkspaceConnection,
path: string,
verifyDirectory: (requestedCwd: string) => Promise<string>,
): Promise<RemoteCwdChange> {
const previousCwd = connection.remoteCwd;
const requestedCwd = resolveRemoteCwd(path, previousCwd, connection.remoteHome);
const remoteCwd = await verifyDirectory(requestedCwd);
connection.remoteCwd = remoteCwd;
return { previousCwd, remoteCwd };
}
export function mapLocalPathToRemote(path: string, connection: RemotePathMappingConnection): string {
if (path === connection.localCwd) return connection.remoteCwd;
if (path.startsWith(`${connection.localCwd}/`)) {
return `${connection.remoteCwd}${path.slice(connection.localCwd.length)}`;
}
if (path === connection.localHome) return connection.remoteHome;
if (path.startsWith(`${connection.localHome}/`)) {
return `${connection.remoteHome}${path.slice(connection.localHome.length)}`;
}
return path;
}
+70
View File
@@ -0,0 +1,70 @@
import { randomBytes } from "node:crypto";
import { posix as posixPath } from "node:path";
import type { RemoteTransport } from "./ssh2-transport.ts";
const PROBE_TIMEOUT_SECONDS = 20;
const MAX_PROBE_OUTPUT_BYTES = 64 * 1024;
export type RemotePathProbe = "home" | "cwd";
function probeCommand(kind: RemotePathProbe, token: string): { command: string; start: string; end: string } {
const start = `__PI_SSH_PROBE_${token}_START__`;
const end = `__PI_SSH_PROBE_${token}_END__`;
const assign = kind === "home"
? "pi_ssh_probe_value=$HOME"
: "pi_ssh_probe_value=$(pwd -P) || exit $?";
return {
command: `${assign}\nprintf '%s%s%s' '${start}' "$pi_ssh_probe_value" '${end}'`,
start,
end,
};
}
export function parseRemotePathProbe(output: Buffer, start: string, end: string, kind: RemotePathProbe): string {
if (output.length > MAX_PROBE_OUTPUT_BYTES) {
throw new Error(`remote ${kind} probe output exceeded ${MAX_PROBE_OUTPUT_BYTES} bytes`);
}
const text = output.toString("utf8");
const startIndex = text.indexOf(start);
const endIndex = startIndex < 0 ? -1 : text.indexOf(end, startIndex + start.length);
if (startIndex < 0 || endIndex < 0 || text.indexOf(start, startIndex + start.length) >= 0) {
throw new Error(`remote ${kind} probe returned an invalid framed response`);
}
const value = text.slice(startIndex + start.length, endIndex);
if (!value.startsWith("/") || /[\0\r\n]/u.test(value)) {
throw new Error(`remote ${kind} probe did not return one absolute POSIX path`);
}
return posixPath.normalize(value);
}
export async function probeRemotePath(
transport: RemoteTransport,
kind: RemotePathProbe,
cwd = ".",
signal?: AbortSignal,
): Promise<string> {
const token = randomBytes(12).toString("hex");
const probe = probeCommand(kind, token);
const chunks: Buffer[] = [];
let captured = 0;
let overflow = false;
const result = await transport.exec(probe.command, cwd, {
signal,
timeout: PROBE_TIMEOUT_SECONDS,
onData(data) {
if (captured + data.length > MAX_PROBE_OUTPUT_BYTES) {
overflow = true;
return;
}
chunks.push(data);
captured += data.length;
},
});
const output = Buffer.concat(chunks);
if (overflow) throw new Error(`remote ${kind} probe output exceeded ${MAX_PROBE_OUTPUT_BYTES} bytes`);
if (result.exitCode !== 0) {
const detail = output.toString("utf8").replace(/\s+/gu, " ").trim().slice(0, 300);
throw new Error(`remote ${kind} probe failed${result.exitCode === null ? "" : ` with exit code ${result.exitCode}`}${detail ? `: ${detail}` : ""}`);
}
return parseRemotePathProbe(output, probe.start, probe.end, kind);
}
+145 -48
View File
@@ -15,6 +15,7 @@ export interface RemoteGrepInput {
literal?: boolean;
caseSensitive?: boolean;
include?: string;
includeHidden?: boolean;
limit?: number;
}
@@ -30,6 +31,8 @@ const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200;
const MAX_LINE_CHARS = 800;
const MAX_CAPTURE_CHARS = 512_000;
const SEARCH_TIMEOUT_SECONDS = 30;
const MAX_DIAGNOSTIC_CHARS = 16_000;
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
@@ -55,6 +58,7 @@ export function resolveRemoteSearchPath(path: string | undefined, remoteCwd: str
validateField(value, "path");
if (value === "~") return remoteHome;
if (value.startsWith("~/")) return posixPath.normalize(posixPath.join(remoteHome, value.slice(2)));
if (value.startsWith("~")) throw new Error("path supports only ~ or ~/... remote HOME expansion");
if (value.startsWith("/")) return posixPath.normalize(value);
return posixPath.normalize(posixPath.join(remoteCwd, value));
}
@@ -70,6 +74,27 @@ const STATUS_HELPER = [
`}`,
].join("\n");
const GREP_RECORD_HELPER = [
`pi_ssh_limit_colon_records() {`,
` local path match line content count=0`,
` while IFS= read -r -d '' path && IFS= read -r match; do`,
' line="${match%%:*}"',
' content="${match#*:}"',
' printf \'%s\\0%s\\0%s\\0\' "${path:0:' + MAX_LINE_CHARS + '}" "$line" "${content:0:' + MAX_LINE_CHARS + '}"',
` count=$((count + 1))`,
` if [ "$count" -ge "$PI_SSH_TAKE" ]; then return 0; fi`,
` done`,
`}`,
`pi_ssh_limit_git_records() {`,
` local path line content count=0`,
` while IFS= read -r -d '' path && IFS= read -r -d '' line && IFS= read -r content; do`,
' printf \'%s\\0%s\\0%s\\0\' "${path:0:' + MAX_LINE_CHARS + '}" "$line" "${content:0:' + MAX_LINE_CHARS + '}"',
` count=$((count + 1))`,
` if [ "$count" -ge "$PI_SSH_TAKE" ]; then return 0; fi`,
` done`,
`}`,
] .join("\n");
function findPipeline(input: RemoteFindInput, root: string, limit: number): string {
const pattern = validateField(input.pattern, "pattern") as string;
const fdCase = input.caseSensitive ? "--case-sensitive" : "--ignore-case";
@@ -81,24 +106,24 @@ function findPipeline(input: RemoteFindInput, root: string, limit: number): stri
STATUS_HELPER,
`if command -v fd >/dev/null 2>&1; then`,
` printf '${MARKER}fd\\n'`,
` fd --type f --color never --fixed-strings ${fdCase} ${fdHidden} --exclude .git --exclude node_modules -- ${shellQuote(pattern)} ${shellQuote(root)} 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
` fd --type f --color never --fixed-strings ${fdCase} ${fdHidden} --exclude .git --exclude node_modules -- ${shellQuote(pattern)} ${shellQuote(root)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
`elif command -v fdfind >/dev/null 2>&1; then`,
` printf '${MARKER}fdfind\\n'`,
` fdfind --type f --color never --fixed-strings ${fdCase} ${fdHidden} --exclude .git --exclude node_modules -- ${shellQuote(pattern)} ${shellQuote(root)} 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
` fdfind --type f --color never --fixed-strings ${fdCase} ${fdHidden} --exclude .git --exclude node_modules -- ${shellQuote(pattern)} ${shellQuote(root)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
`elif command -v git >/dev/null 2>&1 && git -C ${shellQuote(root)} rev-parse --is-inside-work-tree >/dev/null 2>&1; then`,
` printf '${MARKER}git-ls-files\\n'`,
` git -C ${shellQuote(root)} ls-files -co --exclude-standard 2>&1 | ${hiddenFilter} | grep -F ${grepCase} -- ${shellQuote(pattern)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
` git -C ${shellQuote(root)} ls-files -co --exclude-standard | ${hiddenFilter} | grep -F ${grepCase} -- ${shellQuote(pattern)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
' pi_ssh_accept_status "${statuses[1]}" 0 1 141 || exit $?',
' pi_ssh_accept_status "${statuses[2]}" 0 1 141 || exit $?',
`else`,
` printf '${MARKER}find\\n'`,
` find ${shellQuote(root)} -type f ! -path '*/.git/*' ! -path '*/node_modules/*' 2>&1 | ${hiddenFilter} | grep -F ${grepCase} -- ${shellQuote(pattern)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
` find ${shellQuote(root)} -type f ! -path '*/.git/*' ! -path '*/node_modules/*' | ${hiddenFilter} | grep -F ${grepCase} -- ${shellQuote(pattern)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
' pi_ssh_accept_status "${statuses[1]}" 0 1 141 || exit $?',
@@ -110,29 +135,64 @@ function findPipeline(input: RemoteFindInput, root: string, limit: number): stri
function grepPipeline(input: RemoteGrepInput, root: string, limit: number): string {
const pattern = validateField(input.pattern, "pattern") as string;
const include = validateField(input.include, "include", true);
const fixed = input.literal === false ? "" : "-F";
if (include?.includes("/")) throw new Error("include must be a basename glob without /");
const rgMode = input.literal === false ? "" : "-F";
const grepMode = input.literal === false ? "-E" : "-F";
const insensitive = input.caseSensitive ? "" : "-i";
const rgHidden = input.includeHidden
? "--hidden"
: "--glob '!.*' --glob '!**/.*' --glob '!**/.*/**'";
const rgGlob = include ? `-g ${shellQuote(include)}` : "";
const gitPath = include ? `-- ${shellQuote(include)}` : "";
const gitPathspecs = [
...(include ? [`:(glob)**/${include}`] : []),
...(input.includeHidden ? [] : [":(exclude,glob)**/.*", ":(exclude,glob)**/.*/**"]),
];
const gitPath = gitPathspecs.length > 0 ? `-- ${gitPathspecs.map(shellQuote).join(" ")}` : "";
const findName = include ? `-name ${shellQuote(include)}` : "";
const includeHidden = input.includeHidden ? 1 : 0;
const take = limit + 1;
return [
STATUS_HELPER,
GREP_RECORD_HELPER,
`PI_SSH_TAKE=${take}`,
`PI_SSH_ROOT=${shellQuote(root)}`,
`PI_SSH_INCLUDE_HIDDEN=${includeHidden}`,
`if command -v rg >/dev/null 2>&1; then`,
` printf '${MARKER}ripgrep\\n'`,
` rg --line-number --no-heading --color never --with-filename --max-columns 500 --max-columns-preview ${fixed} ${insensitive} ${rgGlob} --glob '!.git/**' --glob '!node_modules/**' -- ${shellQuote(pattern)} ${shellQuote(root)} 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
` rg --null --line-number --no-heading --color never --with-filename --max-columns ${MAX_LINE_CHARS} --max-columns-preview ${rgMode} ${insensitive} ${rgHidden} ${rgGlob} --glob '!.git/**' --glob '!node_modules/**' -- ${shellQuote(pattern)} ${shellQuote(root)} | pi_ssh_limit_colon_records`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 1 141 || exit $?',
' pi_ssh_accept_status "${statuses[1]}" 0 || exit $?',
`elif command -v git >/dev/null 2>&1 && git -C ${shellQuote(root)} rev-parse --is-inside-work-tree >/dev/null 2>&1; then`,
` printf '${MARKER}git-grep\\n'`,
` git -C ${shellQuote(root)} grep --untracked --exclude-standard -n -I ${fixed} ${insensitive} -e ${shellQuote(pattern)} ${gitPath} 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
` git -C ${shellQuote(root)} grep --untracked --exclude-standard -z -n -I ${grepMode} ${insensitive} -e ${shellQuote(pattern)} ${gitPath} | pi_ssh_limit_git_records`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 1 141 || exit $?',
' pi_ssh_accept_status "${statuses[1]}" 0 || exit $?',
`else`,
` printf '${MARKER}grep\\n'`,
` find ${shellQuote(root)} -type f ! -path '*/.git/*' ! -path '*/node_modules/*' ${findName} -exec grep -nH -I ${fixed} ${insensitive} -- ${shellQuote(pattern)} {} + 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
` find ${shellQuote(root)} -type f ! -path '*/.git/*' ! -path '*/node_modules/*' ${findName} -print0 | (`,
` while IFS= read -r -d '' file; do`,
` if [ "$PI_SSH_INCLUDE_HIDDEN" -eq 0 ]; then`,
` if [ -d "$PI_SSH_ROOT" ]; then`,
' relative=${file#"$PI_SSH_ROOT"/}',
` else`,
' relative=${file##*/}',
` fi`,
' case "$relative" in .*|*/.*) continue ;; esac',
` fi`,
` grep -n -I ${grepMode} ${insensitive} -- ${shellQuote(pattern)} "$file" | while IFS= read -r match; do`,
` printf '%s\\0%s\\n' "$file" "$match"`,
` done`,
' grep_statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${grep_statuses[0]}" 0 1 141 || exit $?',
' pi_ssh_accept_status "${grep_statuses[1]}" 0 1 141 || exit $?',
` done`,
` ) | pi_ssh_limit_colon_records`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
' pi_ssh_accept_status "${statuses[1]}" 0 141 || exit $?',
' pi_ssh_accept_status "${statuses[2]}" 0 || exit $?',
`fi`,
].join("\n");
}
@@ -149,30 +209,54 @@ export function buildRemoteGrepCommand(input: RemoteGrepInput, root: string): {
return { command: grepPipeline(input, root, limit), limit };
}
function prefixGitPath(line: string, root: string, grep: boolean): string {
if (line.startsWith("/") || line.startsWith("../")) return line;
if (!grep) return posixPath.join(root, line);
const separator = line.indexOf(":");
if (separator < 1) return line;
return `${posixPath.join(root, line.slice(0, separator))}${line.slice(separator)}`;
function prefixGitPath(path: string, root: string): string {
return path.startsWith("/") || path.startsWith("../") ? path : posixPath.join(root, path);
}
function visibleField(value: string): string {
return value.replace(/\r/gu, "\\r").replace(/\n/gu, "\\n");
}
function normalizeDiagnostic(raw: Buffer): string {
return raw
.toString("utf8")
.replace(/\0/gu, " ")
.replace(new RegExp(`${MARKER}[^\\n]*`, "gu"), "")
.replace(/\s+/gu, " ")
.trim()
.slice(0, 300);
}
export function formatRemoteSearchOutput(
raw: string,
raw: string | Buffer,
root: string,
limit: number,
kind: "find" | "grep",
): RemoteSearchResult {
const lines = raw.replace(/\r\n?/gu, "\n").split("\n");
const markerIndex = lines.findIndex((line) => line.startsWith(MARKER));
if (markerIndex < 0) throw new Error(`remote ${kind} did not report a search backend`);
const backend = lines[markerIndex].slice(MARKER.length).trim() || "unknown";
const sourceRows = lines.slice(markerIndex + 1).filter((line) => line.length > 0);
const output = Buffer.isBuffer(raw) ? raw.toString("utf8") : raw;
const markerIndex = output.indexOf(MARKER);
const markerEnd = markerIndex < 0 ? -1 : output.indexOf("\n", markerIndex);
if (markerIndex < 0 || markerEnd < 0) throw new Error(`remote ${kind} did not report a search backend`);
const backend = output.slice(markerIndex + MARKER.length, markerEnd).trim() || "unknown";
const payload = output.slice(markerEnd + 1);
let sourceRows: string[];
if (kind === "grep") {
const fields = payload.length === 0 ? [] : payload.split("\0");
if (fields.at(-1) === "") fields.pop();
if (fields.length % 3 !== 0) throw new Error("remote grep returned a malformed or truncated NUL-delimited result");
sourceRows = [];
for (let index = 0; index < fields.length; index += 3) {
const path = backend.startsWith("git-") ? prefixGitPath(fields[index], root) : fields[index];
sourceRows.push(`${visibleField(path)}:${visibleField(fields[index + 1])}:${visibleField(fields[index + 2])}`);
}
} else {
sourceRows = payload.replace(/\r\n?/gu, "\n").split("\n").filter((line) => line.length > 0);
if (backend.startsWith("git-")) sourceRows = sourceRows.map((line) => prefixGitPath(line, root));
}
const truncated = sourceRows.length > limit;
const rows = sourceRows.slice(0, limit).map((line) => {
const normalized = backend.startsWith("git-") ? prefixGitPath(line, root, kind === "grep") : line;
return normalized.length > MAX_LINE_CHARS ? `${normalized.slice(0, MAX_LINE_CHARS - 1)}` : normalized;
});
const rows = sourceRows.slice(0, limit).map((line) =>
line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS - 1)}` : line,
);
const header = `Remote ${kind}: ${rows.length} result${rows.length === 1 ? "" : "s"} (backend: ${backend}, root: ${root}, truncated: ${truncated ? "yes" : "no"})`;
return {
text: rows.length > 0 ? `${header}\n\n${rows.join("\n")}` : `${header}\n\nNo matches found.`,
@@ -185,37 +269,50 @@ export function formatRemoteSearchOutput(
async function runRemoteSearch(
transport: RemoteTransport,
command: string,
executionCwd: string,
root: string,
limit: number,
kind: "find" | "grep",
signal?: AbortSignal,
): Promise<RemoteSearchResult> {
const chunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let captured = 0;
const result = await transport.exec(command, root, {
signal,
timeout: 30,
onData(data) {
if (captured >= MAX_CAPTURE_CHARS) return;
const remaining = MAX_CAPTURE_CHARS - captured;
const chunk = data.length > remaining ? data.subarray(0, remaining) : data;
chunks.push(chunk);
captured += chunk.length;
},
});
const output = Buffer.concat(chunks).toString("utf8");
let stderrCaptured = 0;
const capture = (target: Buffer[], data: Buffer, stderr = false) => {
const current = stderr ? stderrCaptured : captured;
const maximum = stderr ? MAX_DIAGNOSTIC_CHARS : MAX_CAPTURE_CHARS;
if (current >= maximum) return;
const remaining = maximum - current;
const chunk = data.length > remaining ? data.subarray(0, remaining) : data;
target.push(chunk);
if (stderr) stderrCaptured += chunk.length;
else captured += chunk.length;
};
let result: { exitCode: number | null };
try {
result = await transport.exec(command, executionCwd, {
signal,
timeout: SEARCH_TIMEOUT_SECONDS,
onData(data) { capture(chunks, data); },
onStderr(data) { capture(stderrChunks, data, true); },
});
} catch (error) {
if (!signal?.aborted && /timed out/iu.test(error instanceof Error ? error.message : String(error))) {
throw new Error(`remote ${kind} timed out after ${SEARCH_TIMEOUT_SECONDS}s (root: ${root}); narrow the remote path with ssh_find before retrying`);
}
throw error;
}
const output = Buffer.concat(chunks);
const stderr = Buffer.concat(stderrChunks);
if (result.exitCode !== 0) {
const detail = output
.replace(/\r\n?/gu, "\n")
.split("\n")
.filter((line) => !line.startsWith(MARKER))
.join(" ")
.replace(/\s+/gu, " ")
.trim()
.slice(0, 300);
const detail = normalizeDiagnostic(stderr.length > 0 ? stderr : output);
throw new Error(`remote ${kind} failed${result.exitCode === null ? "" : ` with exit code ${result.exitCode}`}${detail ? `: ${detail}` : ""}`);
}
return formatRemoteSearchOutput(output, root, limit, kind);
const formatted = formatRemoteSearchOutput(output, root, limit, kind);
const warning = normalizeDiagnostic(stderr);
if (warning) formatted.text += `\n\nRemote warning: ${warning}`;
return formatted;
}
export function runRemoteFind(
@@ -227,7 +324,7 @@ export function runRemoteFind(
): Promise<RemoteSearchResult> {
const root = resolveRemoteSearchPath(input.path, remoteCwd, remoteHome);
const built = buildRemoteFindCommand(input, root);
return runRemoteSearch(transport, built.command, root, built.limit, "find", signal);
return runRemoteSearch(transport, built.command, remoteCwd, root, built.limit, "find", signal);
}
export function runRemoteGrep(
@@ -239,5 +336,5 @@ export function runRemoteGrep(
): Promise<RemoteSearchResult> {
const root = resolveRemoteSearchPath(input.path, remoteCwd, remoteHome);
const built = buildRemoteGrepCommand(input, root);
return runRemoteSearch(transport, built.command, root, built.limit, "grep", signal);
return runRemoteSearch(transport, built.command, remoteCwd, root, built.limit, "grep", signal);
}
+93 -10
View File
@@ -7,12 +7,13 @@ import { expandUserPath, type SshHostConfig } from "./config.ts";
export interface RemoteExecOptions {
onData: (data: Buffer) => void;
onStderr?: (data: Buffer) => void;
signal?: AbortSignal;
timeout?: number;
}
export interface RemoteTransport {
connect(): Promise<void>;
connect(signal?: AbortSignal): 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 }>;
@@ -36,6 +37,66 @@ class CommandQueue {
}
}
interface SemaphoreWaiter {
resolve: (release: () => void) => void;
reject: (error: Error) => void;
signal?: AbortSignal;
onAbort?: () => void;
}
export class AsyncSemaphore {
private active = 0;
private readonly waiters: SemaphoreWaiter[] = [];
private readonly limit: number;
constructor(limit: number) {
if (!Number.isInteger(limit) || limit < 1) throw new Error("semaphore limit must be a positive integer");
this.limit = limit;
}
acquire(signal?: AbortSignal): Promise<() => void> {
if (signal?.aborted) return Promise.reject(new Error("SSH command aborted"));
if (this.active < this.limit) {
this.active += 1;
return Promise.resolve(this.createRelease());
}
return new Promise((resolve, reject) => {
const waiter: SemaphoreWaiter = { resolve, reject, signal };
waiter.onAbort = () => {
const index = this.waiters.indexOf(waiter);
if (index >= 0) this.waiters.splice(index, 1);
reject(new Error("SSH command aborted"));
};
signal?.addEventListener("abort", waiter.onAbort, { once: true });
this.waiters.push(waiter);
});
}
private createRelease(): () => void {
let released = false;
return () => {
if (released) return;
released = true;
this.active -= 1;
this.dispatch();
};
}
private dispatch(): void {
while (this.active < this.limit) {
const waiter = this.waiters.shift();
if (!waiter) return;
if (waiter.onAbort) waiter.signal?.removeEventListener("abort", waiter.onAbort);
if (waiter.signal?.aborted) {
waiter.reject(new Error("SSH command aborted"));
continue;
}
this.active += 1;
waiter.resolve(this.createRelease());
}
}
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
@@ -80,7 +141,8 @@ function errorMessage(error: unknown): string {
export class Ssh2Transport implements RemoteTransport {
private readonly client: Client;
private readonly queue = new CommandQueue();
private readonly sftpQueue = new CommandQueue();
private readonly execSemaphore = new AsyncSemaphore(4);
private connected = false;
private disposed = false;
private disconnectError: Error | null = null;
@@ -92,14 +154,17 @@ export class Ssh2Transport implements RemoteTransport {
this.client = client;
}
async connect(): Promise<void> {
async connect(signal?: AbortSignal): Promise<void> {
if (this.connected) return;
if (this.disposed) throw new Error("SSH2 transport is disposed");
if (signal?.aborted) throw new Error("SSH connection aborted");
await new Promise<void>((resolve, reject) => {
let settled = false;
const cleanup = () => signal?.removeEventListener("abort", onAbort);
const succeed = () => {
if (settled) return;
settled = true;
cleanup();
this.connected = true;
resolve();
};
@@ -108,8 +173,17 @@ export class Ssh2Transport implements RemoteTransport {
this.disconnectError = normalized;
if (settled) return;
settled = true;
cleanup();
reject(normalized);
};
const onAbort = () => {
try {
this.client.destroy();
} catch {
// client may already be closed
}
fail(new Error("SSH connection aborted"));
};
this.client.once("ready", succeed);
this.client.on("error", fail);
this.client.on("close", () => {
@@ -122,6 +196,7 @@ export class Ssh2Transport implements RemoteTransport {
finish(prompts.map(() => this.host.auth.type === "password" ? this.host.auth.password : ""));
});
}
signal?.addEventListener("abort", onAbort, { once: true });
try {
this.client.connect(buildConnectConfig(this.host));
} catch (error) {
@@ -141,8 +216,13 @@ export class Ssh2Transport implements RemoteTransport {
if (!this.connected) throw this.disconnectError ?? new Error("SSH2 connection is not active");
}
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
return this.queue.enqueue(() => this.execUnqueued(command, cwd, options));
async exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
const release = await this.execSemaphore.acquire(options.signal);
try {
return await this.execUnqueued(command, cwd, options);
} finally {
release();
}
}
private async execUnqueued(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
@@ -197,7 +277,10 @@ export class Ssh2Transport implements RemoteTransport {
}
channel = stream;
stream.on("data", (data: Buffer | string) => options.onData(Buffer.isBuffer(data) ? data : Buffer.from(data)));
stream.stderr.on("data", (data: Buffer | string) => options.onData(Buffer.isBuffer(data) ? data : Buffer.from(data)));
stream.stderr.on("data", (data: Buffer | string) => {
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
(options.onStderr ?? options.onData)(chunk);
});
stream.once("error", fail);
stream.once("close", (code: number | undefined) => {
if (settled) return;
@@ -225,7 +308,7 @@ export class Ssh2Transport implements RemoteTransport {
}
async readFile(remotePath: string): Promise<Buffer> {
return this.queue.enqueue(async () => {
return this.sftpQueue.enqueue(async () => {
const sftp = await this.sftp();
return new Promise<Buffer>((resolve, reject) => {
sftp.readFile(remotePath, (error, data) => error ? reject(error) : resolve(data));
@@ -234,7 +317,7 @@ export class Ssh2Transport implements RemoteTransport {
}
private async ensureOpen(remotePath: string, flags: "r" | "r+"): Promise<void> {
return this.queue.enqueue(async () => {
return this.sftpQueue.enqueue(async () => {
const sftp = await this.sftp();
await new Promise<void>((resolve, reject) => {
sftp.open(remotePath, flags, (error, handle) => {
@@ -267,7 +350,7 @@ export class Ssh2Transport implements RemoteTransport {
}
async mkdir(remoteDir: string): Promise<void> {
return this.queue.enqueue(() => this.mkdirUnqueued(remoteDir));
return this.sftpQueue.enqueue(() => this.mkdirUnqueued(remoteDir));
}
private async mkdirUnqueued(remoteDir: string): Promise<void> {
@@ -290,7 +373,7 @@ export class Ssh2Transport implements RemoteTransport {
}
async writeFile(remotePath: string, content: Buffer): Promise<void> {
return this.queue.enqueue(async () => {
return this.sftpQueue.enqueue(async () => {
const sftp = await this.sftp();
await this.mkdirUnqueued(posixPath.dirname(remotePath));
const temporary = `${remotePath}.pi-ssh-${randomBytes(8).toString("hex")}.tmp`;
+30 -1
View File
@@ -2,10 +2,13 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { parseConnectInput, SSH_CONNECT_TOOL_METADATA } from "../src/agent-connection.ts";
import { getConfiguredHost, parseConnectInput, SSH_CONNECT_TOOL_METADATA } from "../src/agent-connection.ts";
test("defines the reviewed agent-controlled SSH connection tool", () => {
assert.equal(SSH_CONNECT_TOOL_METADATA.name, "ssh_connect");
assert.equal(SSH_CONNECT_TOOL_METADATA.executionMode, "sequential");
assert.match(SSH_CONNECT_TOOL_METADATA.description, /separate step/);
assert.match(SSH_CONNECT_TOOL_METADATA.description, /wait for success/);
assert.deepEqual(SSH_CONNECT_TOOL_METADATA.parameters.required, ["hostId"]);
assert.ok(SSH_CONNECT_TOOL_METADATA.parameters.properties.remotePath);
assert.deepEqual(parseConnectInput({ hostId: " packaging-server " }), { hostId: "packaging-server" });
@@ -17,6 +20,32 @@ test("defines the reviewed agent-controlled SSH connection tool", () => {
assert.throws(() => parseConnectInput({ hostId: "packaging-server", remotePath: "relative" }), /remote path/);
});
test("unknown hosts report bounded imported host ID alternatives", () => {
const host = {
hostName: "example.test",
user: "builder",
port: 22,
auth: { type: "password" as const, password: "secret" },
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:fixture" },
};
assert.equal(getConfiguredHost({ version: 1, hosts: { "packaging-server": host } }, "packaging-server"), host);
assert.throws(
() => getConfiguredHost({ version: 1, hosts: { "packaging-server": host, "build-server": host } }, "connect-packaging-server"),
/available imported host IDs: build-server, packaging-server/,
);
const manyHosts = Object.fromEntries(Array.from({ length: 12 }, (_, index) => [`host-${String(index).padStart(2, "0")}`, host]));
assert.throws(
() => getConfiguredHost({ version: 1, hosts: manyHosts }, "missing"),
(error: unknown) => {
assert.match(String(error), /host-00, host-01, host-02/);
assert.match(String(error), /… \(\+2 more\)/);
assert.doesNotMatch(String(error), /example\.test|secret/);
return true;
},
);
assert.throws(() => getConfiguredHost({ version: 1, hosts: {} }, "missing"), /no hosts are imported/);
});
test("removes manual and implicit SSH connection surfaces", async () => {
const source = await readFile(new URL("../index.ts", import.meta.url), "utf8");
assert.match(source, /\.\.\.SSH_CONNECT_TOOL_METADATA/);
+8
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { validatePiSshConfig } from "../src/config.ts";
import { effectiveValue, effectiveValues, parseSshG } from "../src/import.ts";
@@ -61,3 +62,10 @@ test("formats SSH host keys as pinned SHA256 fingerprints", () => {
fingerprint: `SHA256:${expected}`,
});
});
test("configuration import validates remote HOME and cwd with framed probes", async () => {
const source = await readFile(new URL("../scripts/ssh-config.mjs", import.meta.url), "utf8");
assert.match(source, /probeRemotePath\(transport, "home"\)/);
assert.match(source, /probeRemotePath\(transport, "cwd"\)/);
assert.doesNotMatch(source, /transport\.capture\(/);
});
+18 -8
View File
@@ -43,6 +43,7 @@ const connection: SshPermissionConnection = {
remote: "packaging-server",
port: 2222,
remoteCwd: "/srv/build",
remoteHome: "/home/builder",
};
test("formats reviewed connection requests without exposing credentials", () => {
@@ -59,19 +60,27 @@ test("formats reviewed connection requests without exposing credentials", () =>
test("formats the SSH target and bounded operation details", () => {
assert.equal(
formatSshPermissionInput("ssh_read", { path: "src/main.ts", offset: 5, limit: 20 }, connection),
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; read remote path 'src/main.ts', offset 5, limit 20",
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; read remote path '/srv/build/src/main.ts' (requested 'src/main.ts'), offset 5, limit 20",
);
assert.equal(
formatSshPermissionInput("ssh_write", { path: "dist/a.txt", content: "one\ntwo" }, connection),
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; write remote path 'dist/a.txt' (2 lines, 7 characters)",
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; write remote path '/srv/build/dist/a.txt' (requested 'dist/a.txt') (2 lines, 7 characters)",
);
assert.equal(
formatSshPermissionInput("ssh_grep", { pattern: "TODO", path: "src", include: "*.ts", limit: 25 }, connection),
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; search remote file contents under 'src', for 'TODO', limit 25, file glob '*.ts'",
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; search remote file contents under '/srv/build/src' (requested 'src'), for 'TODO', limit 25, file glob '*.ts'",
);
assert.equal(
formatSshPermissionInput("ssh_cd", { path: "../release" }, connection),
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; change the active remote cwd to '/srv/release' (requested '../release')",
);
assert.match(
formatSshPermissionInput("ssh_read", { path: "~/logs/app.log" }, connection),
/remote path '\/home\/builder\/logs\/app\.log' \(requested '~\/logs\/app\.log'\)/,
);
});
test("registers previews and disables local path extraction for remote file tools", () => {
test("registers previews and disables local path extraction for remote path tools", () => {
const { service, formatters, extractors } = makeService();
const pi = makePi();
const dispose = installSshPermissionIntegration(
@@ -84,10 +93,11 @@ test("registers previews and disables local path extraction for remote file tool
},
);
assert.deepEqual([...formatters.keys()], ["ssh_connect", "ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep", "ssh_bash"]);
assert.deepEqual([...extractors.keys()], ["ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"]);
assert.deepEqual([...formatters.keys()], ["ssh_connect", "ssh_cd", "ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep", "ssh_bash"]);
assert.deepEqual([...extractors.keys()], ["ssh_cd", "ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"]);
assert.equal(extractors.get("ssh_read")?.({ path: "/remote/secret" }), undefined);
assert.equal(extractors.get("ssh_grep")?.({ path: "/remote/src" }), undefined);
assert.equal(extractors.get("ssh_cd")?.({ path: "/remote/release" }), undefined);
assert.match(formatters.get("ssh_bash")?.({ command: "git push" }) ?? "", /packaging-server:2222/);
assert.match(formatters.get("ssh_connect")?.({ hostId: "packaging-server" }) ?? "", /establish a persistent SSH2 connection/);
@@ -108,8 +118,8 @@ test("registers when the permission service becomes ready and cleans up on shutd
published = service;
pi.emitEvent("permissions:ready");
assert.equal(formatters.size, 7);
assert.equal(extractors.size, 5);
assert.equal(formatters.size, 8);
assert.equal(extractors.size, 6);
pi.emit("session_shutdown");
assert.equal(formatters.size, 0);
+43
View File
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createRemoteBashOps } from "../src/remote-bash.ts";
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
class CapturingTransport implements RemoteTransport {
calls: Array<{ command: string; cwd: string }> = [];
connect(): Promise<void> { return Promise.resolve(); }
dispose(): Promise<void> { return Promise.resolve(); }
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
this.calls.push({ command, cwd });
options.onData(Buffer.from("ok\n"));
return Promise.resolve({ exitCode: 0 });
}
capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); }
readFile(): Promise<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("ssh_bash always executes from the active remote cwd", async () => {
const connection = { remoteCwd: "/srv/project" };
const transport = new CapturingTransport();
const operations = createRemoteBashOps(connection, transport);
await operations.exec("pwd", "/Users/local/project", { onData() {} });
assert.deepEqual(transport.calls, [{ command: "pwd", cwd: "/srv/project" }]);
connection.remoteCwd = "/opt/next-project";
await operations.exec("npm test", "/another/local/path", { onData() {} });
assert.deepEqual(transport.calls[1], { command: "npm test", cwd: "/opt/next-project" });
});
test("ssh_bash rejects an invalid non-absolute remote cwd", () => {
const operations = createRemoteBashOps({ remoteCwd: "relative/path" }, new CapturingTransport());
assert.throws(
() => operations.exec("pwd", "/Users/local/project", { onData() {} }),
/requires an absolute remote cwd/,
);
});
+95
View File
@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createRemoteBashOps } from "../src/remote-bash.ts";
import {
changeRemoteCwd,
mapLocalPathToRemote,
resolveRemoteCwd,
SSH_CD_EXECUTION_MODE,
} from "../src/remote-cwd.ts";
import { resolveRemoteSearchPath } from "../src/remote-search.ts";
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
class CapturingTransport implements RemoteTransport {
calls: Array<{ command: string; cwd: string }> = [];
connect(): Promise<void> { return Promise.resolve(); }
dispose(): Promise<void> { return Promise.resolve(); }
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
this.calls.push({ command, cwd });
options.onData(Buffer.from("ok\n"));
return Promise.resolve({ exitCode: 0 });
}
capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); }
readFile(): Promise<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 explicit remote workspace changes", () => {
assert.equal(resolveRemoteCwd("services/api", "/srv/project", "/home/build"), "/srv/project/services/api");
assert.equal(resolveRemoteCwd("../shared", "/srv/project", "/home/build"), "/srv/shared");
assert.equal(resolveRemoteCwd("/opt/app/../release", "/srv/project", "/home/build"), "/opt/release");
assert.equal(resolveRemoteCwd("~", "/srv/project", "/home/build"), "/home/build");
assert.equal(resolveRemoteCwd("~/jobs/app", "/srv/project", "/home/build"), "/home/build/jobs/app");
});
test("rejects invalid remote workspace paths", () => {
assert.throws(() => resolveRemoteCwd("", "/srv/project", "/home/build"), /non-empty/);
assert.throws(() => resolveRemoteCwd("bad\npath", "/srv/project", "/home/build"), /NUL or newline/);
assert.throws(() => resolveRemoteCwd("~other/project", "/srv/project", "/home/build"), /only '~' or '~\/'/);
});
test("ssh_cd is a sequential workspace transition", () => {
assert.equal(SSH_CD_EXECUTION_MODE, "sequential");
});
test("a validated workspace change drives subsequent remote tools", async () => {
const connection = {
remoteCwd: "/srv/project",
remoteHome: "/home/build",
localCwd: "/Users/local/project",
localHome: "/Users/local",
};
const verified: string[] = [];
const changed = await changeRemoteCwd(connection, "../release", async (requestedCwd) => {
verified.push(requestedCwd);
return requestedCwd;
});
assert.deepEqual(verified, ["/srv/release"]);
assert.deepEqual(changed, { previousCwd: "/srv/project", remoteCwd: "/srv/release" });
assert.equal(connection.remoteCwd, "/srv/release");
assert.equal(mapLocalPathToRemote("/Users/local/project/logs/build.log", connection), "/srv/release/logs/build.log");
assert.equal(resolveRemoteSearchPath(undefined, connection.remoteCwd, connection.remoteHome), "/srv/release");
const transport = new CapturingTransport();
const operations = createRemoteBashOps(connection, transport);
await operations.exec("npm test", "/Users/local/project", { onData() {} });
assert.deepEqual(transport.calls, [{ command: "npm test", cwd: "/srv/release" }]);
});
test("a failed workspace validation leaves the previous cwd active", async () => {
const connection = { remoteCwd: "/srv/project", remoteHome: "/home/build" };
await assert.rejects(
changeRemoteCwd(connection, "missing", async () => {
throw new Error("not a directory");
}),
/not a directory/,
);
assert.equal(connection.remoteCwd, "/srv/project");
});
test("successive workspace changes resolve from the latest confirmed cwd", async () => {
const connection = { remoteCwd: "/srv/project", remoteHome: "/home/build" };
const verify = async (requestedCwd: string) => requestedCwd;
await changeRemoteCwd(connection, "services/api", verify);
await changeRemoteCwd(connection, "../worker", verify);
assert.equal(connection.remoteCwd, "/srv/project/services/worker");
});
+57
View File
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseRemotePathProbe, probeRemotePath } from "../src/remote-probe.ts";
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
class ProbeTransport implements RemoteTransport {
command = "";
cwd = "";
connect(): Promise<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;
const start = command.match(/'(__PI_SSH_PROBE_[a-f0-9]+_START__)'/)?.[1];
const end = command.match(/'(__PI_SSH_PROBE_[a-f0-9]+_END__)'/)?.[1];
if (!start || !end) throw new Error("probe markers missing");
options.onData(Buffer.from(`login banner\n${start}/srv/project${end}\nlogout banner\n`));
return Promise.resolve({ exitCode: 0 });
}
capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); }
readFile(): Promise<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("extracts one framed absolute path while ignoring shell startup output", async () => {
const transport = new ProbeTransport();
assert.equal(await probeRemotePath(transport, "cwd", "/srv"), "/srv/project");
assert.equal(transport.cwd, "/srv");
assert.match(transport.command, /pwd -P/);
});
test("rejects unframed, relative, multiline, and oversized path probes", () => {
assert.throws(() => parseRemotePathProbe(Buffer.from("/srv"), "START", "END", "cwd"), /invalid framed/);
assert.throws(() => parseRemotePathProbe(Buffer.from("STARTrelativeEND"), "START", "END", "cwd"), /absolute POSIX path/);
assert.throws(() => parseRemotePathProbe(Buffer.from("START/srv\notherEND"), "START", "END", "cwd"), /absolute POSIX path/);
assert.throws(
() => parseRemotePathProbe(Buffer.alloc(64 * 1024 + 1), "START", "END", "cwd"),
/exceeded 65536 bytes/,
);
});
test("forwards cancellation to the probe exec call", async () => {
const controller = new AbortController();
controller.abort();
const transport = new ProbeTransport();
const original = transport.exec.bind(transport);
transport.exec = (command, cwd, options) => {
assert.equal(options.signal, controller.signal);
if (options.signal?.aborted) return Promise.reject(new Error("SSH command aborted"));
return original(command, cwd, options);
};
await assert.rejects(probeRemotePath(transport, "home", ".", controller.signal), /SSH command aborted/);
});
+147 -56
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
@@ -14,13 +14,20 @@ import {
} from "../src/remote-search.ts";
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
function grepProtocol(backend: string, rows: Array<[string, number, string]> = []): Buffer {
const fields = rows.flatMap(([path, line, content]) => [path, String(line), content]);
return Buffer.from(`__PI_SSH_SEARCH_BACKEND__:${backend}\n${fields.length > 0 ? `${fields.join("\0")}\0` : ""}`);
}
class SearchTransport implements RemoteTransport {
command = "";
cwd = "";
private readonly output: string;
private readonly output: Buffer;
private readonly stderr: Buffer;
private readonly exitCode: number | null;
constructor(output: string, exitCode: number | null = 0) {
this.output = output;
constructor(output: string | Buffer, exitCode: number | null = 0, stderr = "") {
this.output = Buffer.isBuffer(output) ? output : Buffer.from(output);
this.stderr = Buffer.from(stderr);
this.exitCode = exitCode;
}
connect(): Promise<void> { return Promise.resolve(); }
@@ -28,7 +35,8 @@ class SearchTransport implements RemoteTransport {
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
this.command = command;
this.cwd = cwd;
options.onData(Buffer.from(this.output));
options.onData(this.output);
if (this.stderr.length > 0) (options.onStderr ?? options.onData)(this.stderr);
return Promise.resolve({ exitCode: this.exitCode });
}
capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); }
@@ -40,14 +48,30 @@ class SearchTransport implements RemoteTransport {
writeFile(): Promise<void> { throw new Error("not used"); }
}
function toolPath(name: string): string {
return execFileSync("/bin/sh", ["-c", `command -v ${name}`], { encoding: "utf8" }).trim();
}
function toolBin(root: string, names: string[]): string {
const bin = join(root, `bin-${names.join("-")}`);
mkdirSync(bin);
for (const name of names) symlinkSync(toolPath(name), join(bin, name));
return bin;
}
function execute(command: string, cwd: string, path: string) {
return spawnSync("/bin/bash", ["-c", command], { cwd, env: { ...process.env, PATH: path } });
}
test("resolves remote search roots without using local filesystem semantics", () => {
assert.equal(resolveRemoteSearchPath(undefined, "/srv/app", "/home/build"), "/srv/app");
assert.equal(resolveRemoteSearchPath("src", "/srv/app", "/home/build"), "/srv/app/src");
assert.equal(resolveRemoteSearchPath("~/logs", "/srv/app", "/home/build"), "/home/build/logs");
assert.equal(resolveRemoteSearchPath("/var/log", "/srv/app", "/home/build"), "/var/log");
assert.throws(() => resolveRemoteSearchPath("~other/project", "/srv/app", "/home/build"), /only ~ or ~\//);
});
test("builds bounded capability-adaptive commands with shell-quoted user input", () => {
test("builds bounded capability-adaptive commands with unified grep semantics", () => {
const find = buildRemoteFindCommand({ pattern: "it's-app", limit: 12 }, "/srv/app dir");
assert.match(find.command, /command -v fd/);
assert.match(find.command, /git-ls-files/);
@@ -55,18 +79,20 @@ test("builds bounded capability-adaptive commands with shell-quoted user input",
assert.match(find.command, /'it'"'"'s-app'/);
assert.match(find.command, /'\/srv\/app dir'/);
const grep = buildRemoteGrepCommand({ pattern: "TODO", include: "*.ts", limit: 20 }, "/srv/app");
assert.match(grep.command, /command -v rg/);
assert.match(grep.command, /git-grep/);
assert.match(grep.command, /find .* -exec grep/);
assert.match(grep.command, /head -n 21/);
const grep = buildRemoteGrepCommand({ pattern: "needle+", literal: false, include: "*.ts", includeHidden: true, limit: 20 }, "/srv/app");
assert.match(grep.command, /rg --null/);
assert.match(grep.command, /git .* grep .* -z .* -E/);
assert.match(grep.command, /grep -n -I -E/);
assert.match(grep.command, /PI_SSH_TAKE=21/);
assert.match(grep.command, /--hidden/);
assert.throws(() => buildRemoteGrepCommand({ pattern: "x", include: "sub\/*.ts" }, "/srv/app"), /basename glob without \//);
assert.throws(() => buildRemoteGrepCommand({ pattern: "bad\npattern" }, "/srv/app"), /newline/);
assert.throws(() => buildRemoteFindCommand({ pattern: "x", limit: 201 }, "/srv/app"), /limit/);
});
test("normalizes git paths, truncates rows, and reports the backend", () => {
test("normalizes NUL-delimited git paths, escaped newlines, truncation, and backend", () => {
const result = formatRemoteSearchOutput(
"__PI_SSH_SEARCH_BACKEND__:git-grep\nsrc/a.ts:2:TODO\nsrc/b.ts:3:TODO\nsrc/c.ts:4:TODO\n",
grepProtocol("git-grep", [["src/a.ts", 2, "TODO"], ["src/line\nb.ts", 3, "TODO"], ["src/c.ts", 4, "TODO"]]),
"/srv/app",
2,
"grep",
@@ -75,25 +101,39 @@ test("normalizes git paths, truncates rows, and reports the backend", () => {
assert.equal(result.matchCount, 2);
assert.equal(result.truncated, true);
assert.match(result.text, /\/srv\/app\/src\/a\.ts:2:TODO/);
assert.match(result.text, /src\/line\\nb\.ts:3:TODO/);
assert.doesNotMatch(result.text, /src\/c\.ts/);
});
test("executes remote find through the structured transport with bounded output", async () => {
const transport = new SearchTransport("__PI_SSH_SEARCH_BACKEND__:fd\n/srv/app/a.ts\n");
const result = await runRemoteFind(transport, { pattern: "a", limit: 5 }, "/srv/app", "/home/build");
test("executes remote find from the active workspace while searching a separate root", async () => {
const transport = new SearchTransport("__PI_SSH_SEARCH_BACKEND__:fd\n/data/search/a.ts\n");
const result = await runRemoteFind(transport, { pattern: "a", path: "/data/search", limit: 5 }, "/srv/app", "/home/build");
assert.equal(transport.cwd, "/srv/app");
assert.match(transport.command, /command -v fd/);
assert.match(transport.command, /\/data\/search/);
assert.equal(result.matchCount, 1);
assert.match(result.text, /backend: fd/);
});
test("propagates backend failures without exposing the internal marker", async () => {
const transport = new SearchTransport(
"__PI_SSH_SEARCH_BACKEND__:git-grep\nfatal: invalid regular expression\n",
128,
);
test("searches a single remote file without treating the file as execution cwd", async () => {
const transport = new SearchTransport(grepProtocol("ripgrep", [["/var/log/application.log", 7, "ERROR"]]));
const result = await runRemoteGrep(transport, { pattern: "ERROR", path: "/var/log/application.log" }, "/srv/app", "/home/build");
assert.equal(transport.cwd, "/srv/app");
assert.match(transport.command, /\/var\/log\/application\.log/);
assert.equal(result.matchCount, 1);
assert.match(result.text, /application\.log:7:ERROR/);
});
test("keeps successful stderr warnings out of match rows and uses stderr for failures", async () => {
const warningTransport = new SearchTransport(grepProtocol("ripgrep"), 0, "warning: skipped socket\n");
const warningResult = await runRemoteGrep(warningTransport, { pattern: "ABSENT" }, "/srv/app", "/home/build");
assert.equal(warningResult.matchCount, 0);
assert.match(warningResult.text, /No matches found/);
assert.match(warningResult.text, /Remote warning: warning: skipped socket/);
const failed = new SearchTransport(grepProtocol("git-grep"), 128, "fatal: invalid regular expression\n");
await assert.rejects(
runRemoteGrep(transport, { pattern: "[", literal: false }, "/srv/app", "/home/build"),
runRemoteGrep(failed, { pattern: "[", literal: false }, "/srv/app", "/home/build"),
(error: unknown) => {
assert.match(String(error), /remote grep failed with exit code 128: fatal: invalid regular expression/);
assert.doesNotMatch(String(error), /__PI_SSH_SEARCH_BACKEND__/);
@@ -102,49 +142,100 @@ test("propagates backend failures without exposing the internal marker", async (
);
});
test("adaptive commands preserve no-match success and propagate real backend errors", async () => {
test("reports actionable bounded-search timeout guidance", async () => {
const transport = new SearchTransport("");
transport.exec = () => Promise.reject(new Error("SSH command timed out after 30s"));
await assert.rejects(
runRemoteGrep(transport, { pattern: "TODO", path: "/home/build" }, "/srv/app", "/home/build"),
/remote grep timed out after 30s \(root: \/home\/build\); narrow the remote path with ssh_find/,
);
});
test("forces git-grep and fallback no-match paths and preserves real errors", () => {
const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-"));
try {
mkdirSync(join(root, "src"));
writeFileSync(join(root, "src", "a.ts"), "const value = 'TODO';\n", "utf8");
execFileSync("/usr/bin/git", ["init", "-q", root]);
execFileSync("/usr/bin/git", ["-C", root, "add", "src/a.ts"]);
const environment = { ...process.env, PATH: "/usr/bin:/bin" };
const execute = (command: string) => execFileSync("/bin/bash", ["-c", command], {
cwd: root,
encoding: "utf8",
env: environment,
});
const find = buildRemoteFindCommand({ pattern: "a.ts", limit: 5 }, root);
const findResult = formatRemoteSearchOutput(execute(find.command), root, find.limit, "find");
assert.equal(findResult.backend, "git-ls-files");
assert.match(findResult.text, /src\/a\.ts/);
execFileSync(toolPath("git"), ["init", "-q", root]);
execFileSync(toolPath("git"), ["-C", root, "add", "src/a.ts"]);
const gitPath = toolBin(root, ["git"]);
const fallbackPath = toolBin(root, ["find", "grep"]);
const grep = buildRemoteGrepCommand({ pattern: "TODO", include: "*.ts", limit: 5 }, root);
const grepResult = formatRemoteSearchOutput(execute(grep.command), root, grep.limit, "grep");
assert.equal(grepResult.backend, "git-grep");
assert.match(grepResult.text, /src\/a\.ts:1:/);
const gitProcess = execute(grep.command, root, gitPath);
assert.equal(gitProcess.status, 0, gitProcess.stderr.toString());
const gitResult = formatRemoteSearchOutput(gitProcess.stdout, root, grep.limit, "grep");
assert.equal(gitResult.backend, "git-grep");
assert.equal(gitResult.matchCount, 1);
const fallbackProcess = execute(grep.command, root, fallbackPath);
assert.equal(fallbackProcess.status, 0, fallbackProcess.stderr.toString());
const fallbackResult = formatRemoteSearchOutput(fallbackProcess.stdout, root, grep.limit, "grep");
assert.equal(fallbackResult.backend, "grep");
assert.equal(fallbackResult.matchCount, 1);
const noMatch = buildRemoteGrepCommand({ pattern: "ABSENT", include: "*.ts" }, root);
const noMatchProcess = spawnSync("/bin/bash", ["-c", noMatch.command], { cwd: root, encoding: "utf8", env: environment });
assert.equal(noMatchProcess.status, 0);
assert.equal(formatRemoteSearchOutput(noMatchProcess.stdout, root, noMatch.limit, "grep").matchCount, 0);
for (const path of [gitPath, fallbackPath]) {
const process = execute(noMatch.command, root, path);
assert.equal(process.status, 0, process.stderr.toString());
assert.equal(formatRemoteSearchOutput(process.stdout, root, noMatch.limit, "grep").matchCount, 0);
}
const invalidRegex = buildRemoteGrepCommand({ pattern: "[", literal: false }, root);
const invalidProcess = spawnSync("/bin/bash", ["-c", invalidRegex.command], { cwd: root, encoding: "utf8", env: environment });
const invalidProcess = execute(invalidRegex.command, root, fallbackPath);
assert.notEqual(invalidProcess.status, 0);
assert.match(`${invalidProcess.stdout}${invalidProcess.stderr}`, /git-grep|fatal|regular expression/i);
assert.match(invalidProcess.stderr.toString(), /regular expression|bracket/i);
const missingRoot = join(root, "missing");
const missing = buildRemoteFindCommand({ pattern: "anything" }, missingRoot);
const missingProcess = spawnSync("/bin/bash", ["-c", missing.command], { cwd: root, encoding: "utf8", env: environment });
const missing = buildRemoteFindCommand({ pattern: "anything" }, join(root, "missing"));
const missingProcess = execute(missing.command, root, fallbackPath);
assert.notEqual(missingProcess.status, 0);
assert.match(`${missingProcess.stdout}${missingProcess.stderr}`, /find|No such file|not found/i);
assert.match(missingProcess.stderr.toString(), /find|No such file|not found/i);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
const transport = new SearchTransport(execute(grep.command));
const throughTransport = await runRemoteGrep(transport, { pattern: "TODO", include: "*.ts" }, root, root);
assert.equal(throughTransport.matchCount, 1);
test("rg, git-grep, and fallback agree on hidden files, basename globs, and portable ERE", (context) => {
let rg: string;
try { rg = toolPath("rg"); } catch { context.skip("rg is unavailable"); return; }
const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-matrix-"));
try {
mkdirSync(join(root, "sub"));
mkdirSync(join(root, ".secret"));
for (const path of ["visible.ts", "sub/nested.ts", ".hidden.ts", ".secret/deep.ts"]) {
writeFileSync(join(root, path), "needle\nneedlee\n", "utf8");
}
execFileSync(toolPath("git"), ["init", "-q", root]);
execFileSync(toolPath("git"), ["-C", root, "add", "."]);
const paths = [toolBin(root, ["rg"]), toolBin(root, ["git"]), toolBin(root, ["find", "grep"])];
assert.equal(toolPath("rg"), rg);
const runCounts = (input: Parameters<typeof buildRemoteGrepCommand>[0]) => paths.map((path) => {
const built = buildRemoteGrepCommand(input, root);
const process = execute(built.command, root, path);
assert.equal(process.status, 0, process.stderr.toString());
return formatRemoteSearchOutput(process.stdout, root, built.limit, "grep").matchCount;
});
assert.deepEqual(runCounts({ pattern: "needle", include: "*.ts" }), [4, 4, 4]);
assert.deepEqual(runCounts({ pattern: "needle", include: "*.ts", includeHidden: true }), [8, 8, 8]);
assert.deepEqual(runCounts({ pattern: "needle+", literal: false, include: "*.ts" }), [4, 4, 4]);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("fallback NUL protocol keeps newline filenames as one bounded result", () => {
const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-newline-"));
try {
const filename = "line\nbreak.ts";
writeFileSync(join(root, filename), "needle\n", "utf8");
const fallbackPath = toolBin(root, ["find", "grep"]);
const built = buildRemoteGrepCommand({ pattern: "needle", include: "*.ts" }, root);
const process = execute(built.command, root, fallbackPath);
assert.equal(process.status, 0, process.stderr.toString());
const result = formatRemoteSearchOutput(process.stdout, root, built.limit, "grep");
assert.equal(result.matchCount, 1);
assert.match(result.text, /line\\nbreak\.ts:1:needle/);
} finally {
rmSync(root, { recursive: true, force: true });
}
@@ -162,17 +253,17 @@ test("propagates fd and ripgrep failures while preserving ripgrep no-match", ()
const environment = { ...process.env, PATH: `${bin}:/usr/bin:/bin` };
const find = buildRemoteFindCommand({ pattern: "anything" }, root);
const failedFind = spawnSync("/bin/bash", ["-c", find.command], { cwd: root, encoding: "utf8", env: environment });
const failedFind = spawnSync("/bin/bash", ["-c", find.command], { cwd: root, env: environment });
assert.equal(failedFind.status, 3);
assert.match(failedFind.stdout, /fd exploded/);
assert.match(failedFind.stderr.toString(), /fd exploded/);
const grep = buildRemoteGrepCommand({ pattern: "anything" }, root);
const failedGrep = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, encoding: "utf8", env: environment });
const failedGrep = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, env: environment });
assert.equal(failedGrep.status, 2);
assert.match(failedGrep.stdout, /rg exploded/);
assert.match(failedGrep.stderr.toString(), /rg exploded/);
writeFileSync(rg, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
const noMatch = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, encoding: "utf8", env: environment });
const noMatch = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, env: environment });
assert.equal(noMatch.status, 0);
assert.equal(formatRemoteSearchOutput(noMatch.stdout, root, grep.limit, "grep").matchCount, 0);
} finally {
+62 -1
View File
@@ -182,12 +182,15 @@ test("connects with password auth, pins the host key, and streams exec output",
assert.equal(fake.connectConfig?.hostVerifier?.(fixtureKey("wrong")), false);
const output: Buffer[] = [];
const stderr: Buffer[] = [];
const result = await transport.exec("printf ok", "/srv/build", {
onData: (data) => output.push(data),
onStderr: (data) => stderr.push(data),
timeout: 5,
});
assert.equal(result.exitCode, 7);
assert.equal(Buffer.concat(output).toString("utf8"), "stdout\nstderr\n");
assert.equal(Buffer.concat(output).toString("utf8"), "stdout\n");
assert.equal(Buffer.concat(stderr).toString("utf8"), "stderr\n");
assert.match(fake.command ?? "", /^cd -- '\/srv\/build' && bash -lc 'printf ok' <\/dev\/null$/);
await transport.dispose();
assert.equal(fake.ended, true);
@@ -279,6 +282,64 @@ test("falls back to direct overwrite when SFTP v3 rename cannot replace", async
await transport.dispose();
});
test("aborts an in-progress SSH connection attempt", async () => {
const fake = new FakeClient();
fake.connectAction = () => {};
const transport = new Ssh2Transport(passwordHost(), fake as unknown as Client);
const controller = new AbortController();
const connecting = transport.connect(controller.signal);
await new Promise<void>((resolve) => setImmediate(resolve));
controller.abort();
await assert.rejects(connecting, /SSH connection aborted/);
assert.equal(fake.destroyed, true);
await transport.dispose();
});
test("allows four independent exec channels and bounds additional commands", async () => {
const fake = new FakeClient();
const channels: FakeChannel[] = [];
fake.execAction = (_command, callback) => {
const channel = new FakeChannel();
channels.push(channel);
callback(undefined, channel as ClientChannel);
};
const transport = new Ssh2Transport(passwordHost(), fake as unknown as Client);
await transport.connect();
const commands = Array.from({ length: 5 }, (_, index) =>
transport.exec(`command-${index}`, "/srv", { onData() {}, timeout: 0 }),
);
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(channels.length, 4);
channels[0]?.emit("close", 0);
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(channels.length, 5);
for (const channel of channels.slice(1)) channel.emit("close", 0);
await Promise.all(commands);
await transport.dispose();
});
test("cancels an exec while it is waiting for a concurrency slot", async () => {
const fake = new FakeClient();
const channels: FakeChannel[] = [];
fake.execAction = (_command, callback) => {
const channel = new FakeChannel();
channels.push(channel);
callback(undefined, channel as ClientChannel);
};
const transport = new Ssh2Transport(passwordHost(), fake as unknown as Client);
await transport.connect();
const active = Array.from({ length: 4 }, () => transport.exec("sleep", "/srv", { onData() {}, timeout: 0 }));
await new Promise<void>((resolve) => setImmediate(resolve));
const controller = new AbortController();
const queued = transport.exec("queued", "/srv", { onData() {}, timeout: 0, signal: controller.signal });
controller.abort();
await assert.rejects(queued, /SSH command aborted/);
assert.equal(channels.length, 4);
for (const channel of channels) channel.emit("close", 0);
await Promise.all(active);
await transport.dispose();
});
test("aborts and times out commands by closing the active channel", async () => {
const abortClient = new FakeClient();
const abortChannel = new FakeChannel();