mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
fix(pi-ssh): harden remote execution and search
This commit is contained in:
+68
-48
@@ -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}` };
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user