fix(ssh): dispatch transfer snippets through verified transferShell

The previous fix gated on a verified `transferShell` but still let
OpenSSH hand the snippet to whatever `$SHELL` happens to be on the
remote. On a fish/csh/tcsh host the new gate would accept the host,
then fail anyway because the login shell can't parse `if [ ... ]; then
...` (P1 from PR #3722 review).

Each transfer command (read, write, stat, list) is now wrapped in
`<transferShell> -c '...'` via a shared `wrapInPosixShell` helper, so
the snippet is parsed by the same shell OMP's capability probe verified
can run it. `ensurePosixRemote` returns the verified shell so each
call site can do the wrap. `ssh-executor.ts`'s identical Windows-compat
helper is consolidated onto the same primitive (`buildCompatCommand` /
`quoteForCompatShell` removed).

Stays POSIX-clean across all four call sites; `-c` (not `-lc`) since
the snippets only call absolute builtins and don't need login-profile
setup. Capability *probing* still uses `-lc` to mirror the user env.

Test additions: one case asserts every dispatch starts with
`bash -c '...'` and embeds the original POSIX snippet (read/write/stat/list)
when transferShell is bash and login shell is unknown; another covers
the `sh -c` happy path.
This commit is contained in:
roboomp
2026-06-28 11:52:44 +00:00
parent c4ed1614eb
commit 207734e915
5 changed files with 65 additions and 45 deletions
+2 -13
View File
@@ -4,6 +4,7 @@ import { OutputSink } from "../session/streaming-output";
import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../tools/output-meta";
import { buildRemoteCommand, ensureConnection, ensureHostInfo, type SSHConnectionTarget } from "./connection-manager";
import { hasSshfs, mountRemote } from "./sshfs-mount";
import { wrapInPosixShell } from "./utils";
export interface SSHExecutorOptions {
/** Timeout in milliseconds */
@@ -78,18 +79,6 @@ function createAbortWaiter(
return { promise, cleanup: () => signal.removeEventListener("abort", onAbort) };
}
function quoteForCompatShell(command: string): string {
if (command.length === 0) {
return "''";
}
const escaped = command.replace(/'/g, "'\\''");
return `'${escaped}'`;
}
function buildCompatCommand(shell: "bash" | "sh", command: string): string {
return `${shell} -c ${quoteForCompatShell(command)}`;
}
export async function executeSSH(
host: SSHConnectionTarget,
command: string,
@@ -108,7 +97,7 @@ export async function executeSSH(
if (options?.compatEnabled) {
const info = await ensureHostInfo(host);
if (info.compatShell) {
resolvedCommand = buildCompatCommand(info.compatShell, command);
resolvedCommand = wrapInPosixShell(info.compatShell, command);
} else {
logger.warn("SSH compat enabled without detected compat shell", { host: host.name });
}