fix: harden ssh script shell compatibility

This commit is contained in:
Codex
2026-05-30 02:15:31 +00:00
parent 5c88f1df58
commit b2435d406b
4 changed files with 40 additions and 17 deletions
+2 -1
View File
@@ -27,7 +27,7 @@ export function rootHelp(): unknown {
{ command: "ssh <route> upload <local-file> <remote-file> | ssh <route> download <remote-file> <local-file>", description: "Transfer whole files through SSH passthrough with remote temp files, byte-count checks, SHA-256 verification, and client-side chunk fallback." },
{ command: "ssh <providerId> apply-patch-v1 [tool args...] < patch.diff", description: "Fallback to the injected legacy remote apply_patch helper directly over SSH passthrough and stream the patch from local stdin." },
{ command: "ssh <providerId> py [script-args...] < script.py", description: "Run remote Python from local stdin through SSH passthrough without nested shell quoting; extra args become script argv." },
{ command: "ssh <providerId> script [--shell sh|bash] [script-args...] <<'SCRIPT' ...", description: "Run a remote shell script from local stdin using shell -s; the default sh inherits provider proxy env, so --shell bash is only for bash-specific syntax." },
{ command: "ssh <providerId> script [--shell sh|bash] [script-args...] <<'SCRIPT' ...", description: "Run a remote shell script from local stdin using shell -s; default sh inherits provider proxy env and gets the portable printf helper used by shell/script." },
{ command: "ssh <providerId> skills [--scope all|wsl|windows] [--limit N]", description: "Discover WSL/Linux and, for WSL providers, Windows skill directories in one SSH passthrough call." },
{ command: "ssh <providerId> find <path...> [--max-depth N] [--type d|f|l] [--contains TEXT] [--iname PATTERN] [--limit N] [--sort]", description: "Run a structured remote find command without nested shell quoting or parentheses." },
{ command: "ssh <providerId> glob [--root DIR] [--pattern PATTERN] [--contains TEXT] [--type any|f|d] [--limit N] [--sort]", description: "Run remote glob matching through the injected helper without shell glob expansion." },
@@ -189,6 +189,7 @@ export function sshHelp(): unknown {
"For non-interactive remote commands, prefer argv for a single process and script/stdin for shell logic.",
"For one-line remote shell logic without a heredoc, use `script -- '<command && command>'`; outer shell operators written outside tran, such as `tran G14:/repo sed ... && sed ...`, are parsed by the local shell before UniDesk starts and therefore cannot be redirected by the CLI. The explicit `shell '<command>'` operation remains available for the same sh -c path.",
"When a one-line shell command is easier to type through the script path, `script -- '<command && command>'` runs that single string through the remote shell without waiting for stdin. When `script --` is followed by multiple tokens, it stays a direct argv form for commands such as `tran D601:/work script -- sed -n '1,20p' file`.",
"script and shell helper modes inject a tiny POSIX-compatible printf wrapper before user shell text, so portable printf headings such as `printf \"--- section ---\\n\"` work consistently under dash/sh and bash. Direct argv commands are unchanged.",
"For arbitrary stdin streams into a workload command, use a workload route plus `exec --stdin -- <command> ...`; this keeps the route as location-only and avoids heredoc/base64/tar shell wrapping.",
"`apply-patch` is the default remote text patch entry and uses the v2 local line-based patch engine with remote read/write operations, including Windows routes such as `D601:win/c/test`, so long Unicode/Chinese lines and pure insertion hunks avoid the legacy remote shell hunk parser.",
"`upload` and `download` are the default whole-file transfer entries for non-text and generated files. They write through remote temp files, verify byte count and SHA-256 on both sides, and fall back from a single stdin payload to bounded client-side chunks before treating provider-gateway limits as a server-side problem.",
+16 -7
View File
@@ -88,6 +88,7 @@ const windowsCmdExeNativePath = "C:\\Windows\\System32\\cmd.exe";
const defaultSshSlowWarningMs = 10_000;
const defaultSshRuntimeTimeoutMs = 60_000;
const maxSshRuntimeTimeoutMs = 60_000;
export const sshShellCompatibilityPrelude = 'printf(){ if [ "${1+x}" = x ] && [ "$1" = "-v" ] && [ -n "${BASH_VERSION:-}" ]; then command printf "$@"; return $?; fi; if [ "${1+x}" = x ] && [ "$1" = "--" ]; then shift; fi; command printf -- "$@"; }';
const k3sResourceKindAliases = new Set(["pod", "po", "pods", "deployment", "deploy", "deployments", "statefulset", "sts", "daemonset", "ds", "job", "jobs"]);
const legacyK3sOperationRouteSegments = new Set([
"guard",
@@ -1326,7 +1327,7 @@ function parseK3sControlPlaneOperation(route: ParsedSshRoute, args: string[]): P
}
if (operation === "shell") {
const parsed = parseShellStringOperationArgs(args.slice(1), `ssh ${route.providerId}:k3s shell`);
return { remoteCommand: shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, parsed.shell, "-c", parsed.command]), requiresStdin: false, invocationKind: "helper" };
return { remoteCommand: shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, parsed.shell, "-c", shellScriptWithCompatibility(parsed.command)]), requiresStdin: false, invocationKind: "helper" };
}
if (operation === "guard") {
if (args.length > 1) throw new Error(`ssh ${route.providerId}:k3s guard does not accept extra arguments`);
@@ -1362,7 +1363,7 @@ function parseK3sTargetOperation(route: ParsedSshRoute, args: string[]): ParsedS
if (operation === "script") return buildK3sScriptOperation([...targetArgs, ...operationArgs]);
if (operation === "shell") {
const parsed = parseShellStringOperationArgs(operationArgs, `ssh ${route.raw} shell`);
return { remoteCommand: buildK3sExecCommand([...targetArgs, "--", parsed.shell, "-c", parsed.command]), requiresStdin: false, invocationKind: "helper" };
return { remoteCommand: buildK3sExecCommand([...targetArgs, "--", parsed.shell, "-c", shellScriptWithCompatibility(parsed.command)]), requiresStdin: false, invocationKind: "helper" };
}
if (operation === "logs") return { remoteCommand: buildK3sLogsCommand([...targetArgs, ...operationArgs]), requiresStdin: false, invocationKind: "helper" };
if (operation === "argv") return { remoteCommand: buildK3sExecCommand([...targetArgs, ...k3sRouteCommandArgs(operationArgs)]), requiresStdin: false, invocationKind: "argv" };
@@ -1520,7 +1521,7 @@ function buildK3sScriptOperation(args: string[]): ParsedSshArgs {
if (parsed.shell === null && parsed.command.length > 0) {
return { remoteCommand: buildK3sInlineScriptCommand(parsed), requiresStdin: false, invocationKind: "helper" };
}
return { remoteCommand: buildK3sStdinScriptCommand(parsed), requiresStdin: true, invocationKind: "helper" };
return { remoteCommand: buildK3sStdinScriptCommand(parsed), requiresStdin: true, invocationKind: "helper", stdinPrefix: shellScriptStdinPrefix() };
}
function buildK3sStdinScriptCommand(parsed: K3sTargetOptions): string {
@@ -1546,7 +1547,7 @@ function buildK3sInlineScriptCommand(parsed: K3sTargetOptions): string {
if (parsed.command.length === 0) throw new Error("ssh k3s script -- requires a command");
if (parsed.tty) throw new Error("ssh k3s script does not support --tty; stdin is reserved for the script body");
if (parsed.stdin) throw new Error("ssh k3s script -- does not accept --stdin");
const command = parsed.command.length === 1 ? ["sh", "-c", parsed.command[0] ?? ""] : parsed.command;
const command = parsed.command.length === 1 ? ["sh", "-c", shellScriptWithCompatibility(parsed.command[0] ?? "")] : parsed.command;
if (parsed.namespace === null && parsed.resource === null) {
if (parsed.container !== null) throw new Error("ssh k3s script without a workload does not accept --container");
if (parsed.workspace !== null) throw new Error("ssh k3s script without a workload does not accept --workdir");
@@ -1770,6 +1771,14 @@ function k3sScriptShell(value: string, option: string): string {
return value;
}
function shellScriptWithCompatibility(command: string): string {
return `${sshShellCompatibilityPrelude}\n${command}`;
}
function shellScriptStdinPrefix(): string {
return `${sshShellCompatibilityPrelude}\n`;
}
function buildShellCommand(args: string[]): ParsedSshArgs {
let shell = "sh";
const scriptArgs: string[] = [];
@@ -1802,15 +1811,15 @@ function buildShellCommand(args: string[]): ParsedSshArgs {
}
if (directArgvMode) {
if (scriptArgs.length === 0) throw new Error("ssh script -- requires a command");
if (scriptArgs.length === 1) return { remoteCommand: shellArgv([shell, "-c", scriptArgs[0] ?? ""]), requiresStdin: false, invocationKind: "helper" };
if (scriptArgs.length === 1) return { remoteCommand: shellArgv([shell, "-c", shellScriptWithCompatibility(scriptArgs[0] ?? "")]), requiresStdin: false, invocationKind: "helper" };
return { remoteCommand: shellArgv(scriptArgs), requiresStdin: false, invocationKind: "argv" };
}
return { remoteCommand: shellArgv([shell, "-s", "--", ...scriptArgs]), requiresStdin: true, invocationKind: "helper" };
return { remoteCommand: shellArgv([shell, "-s", "--", ...scriptArgs]), requiresStdin: true, invocationKind: "helper", stdinPrefix: shellScriptStdinPrefix() };
}
function buildShellStringCommand(args: string[]): ParsedSshArgs {
const parsed = parseShellStringOperationArgs(args, "ssh shell");
return { remoteCommand: shellArgv([parsed.shell, "-c", parsed.command]), requiresStdin: false, invocationKind: "helper" };
return { remoteCommand: shellArgv([parsed.shell, "-c", shellScriptWithCompatibility(parsed.command)]), requiresStdin: false, invocationKind: "helper" };
}
function podApplyPatchStdinWrapper(): { prefix: string; suffix: string } {