fix: preserve tran script command separator
This commit is contained in:
@@ -170,6 +170,7 @@ export function sshHelp(): unknown {
|
||||
notes: [
|
||||
"ssh --help and ssh <route> --help print this JSON help and never open an interactive session.",
|
||||
"For non-interactive remote commands, prefer argv for a single process and script/stdin for shell logic.",
|
||||
"When a short remote command is easier to type through the script path and needs dash-prefixed argv, write `script -- <command> [args...]`; this direct form does not wait for stdin, and the command-local `--` is preserved by local and remote `tran` parsing, so examples such as `tran D601:/work script -- sed -n '1,20p' file` do not require a heredoc just to pass `-n`.",
|
||||
"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 rejects low-context update hunks by default, reports the matched file:line for each hunk on stderr, and only accepts --allow-loose when the caller has manually reviewed an intentionally ambiguous insertion.",
|
||||
"script defaults to target /bin/sh and inherits provider proxy variables such as HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY; use --shell bash only for bash syntax such as pipefail, arrays, or [[ ... ]], not as a proxy workaround.",
|
||||
|
||||
@@ -161,8 +161,12 @@ export function extractRemoteCliOptions(argv: string[]): RemoteCliOptions {
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index] ?? "";
|
||||
if (arg === "--") {
|
||||
rest.push(...argv.slice(index + 1));
|
||||
break;
|
||||
if (rest.length === 0) {
|
||||
rest.push(...argv.slice(index + 1));
|
||||
break;
|
||||
}
|
||||
rest.push(arg);
|
||||
continue;
|
||||
}
|
||||
if (hostOptions.has(arg)) {
|
||||
options.host = requiredValue(argv, index, arg);
|
||||
|
||||
+15
-3
@@ -806,7 +806,7 @@ export function parseSshArgs(args: string[]): ParsedSshArgs {
|
||||
return { remoteCommand: buildPythonStdinCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" };
|
||||
}
|
||||
if (subcommand === "script" || subcommand === "sh") {
|
||||
return { remoteCommand: buildShellStdinCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" };
|
||||
return buildShellCommand(args.slice(1));
|
||||
}
|
||||
if (subcommand === "argv" || subcommand === "exec") {
|
||||
const toolArgs = args.slice(1);
|
||||
@@ -1446,10 +1446,12 @@ function k3sScriptShell(value: string, option: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildShellStdinCommand(args: string[]): string {
|
||||
function buildShellCommand(args: string[]): ParsedSshArgs {
|
||||
let shell = "sh";
|
||||
const scriptArgs: string[] = [];
|
||||
let afterDoubleDash = false;
|
||||
let directArgvMode = false;
|
||||
let shellOptionSeen = false;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index] ?? "";
|
||||
if (afterDoubleDash) {
|
||||
@@ -1457,18 +1459,28 @@ function buildShellStdinCommand(args: string[]): string {
|
||||
continue;
|
||||
}
|
||||
if (arg === "--") {
|
||||
if (!shellOptionSeen && scriptArgs.length === 0) {
|
||||
directArgvMode = true;
|
||||
scriptArgs.push(...args.slice(index + 1));
|
||||
break;
|
||||
}
|
||||
afterDoubleDash = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--shell") {
|
||||
shell = k3sScriptShell(k3sOptionValue(args, index, "ssh script --shell"), "ssh script --shell");
|
||||
shellOptionSeen = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("-")) throw new Error(`unsupported ssh script option: ${arg}`);
|
||||
scriptArgs.push(arg);
|
||||
}
|
||||
return shellArgv([shell, "-s", "--", ...scriptArgs]);
|
||||
if (directArgvMode) {
|
||||
if (scriptArgs.length === 0) throw new Error("ssh script -- requires a command");
|
||||
return { remoteCommand: shellArgv(scriptArgs), requiresStdin: false, invocationKind: "argv" };
|
||||
}
|
||||
return { remoteCommand: shellArgv([shell, "-s", "--", ...scriptArgs]), requiresStdin: true, invocationKind: "helper" };
|
||||
}
|
||||
|
||||
function podApplyPatchStdinWrapper(): { prefix: string; suffix: string } {
|
||||
|
||||
@@ -4,7 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { sshHelp } from "./src/help";
|
||||
import { providerTriageRecommendedCrossChecks } from "./src/provider-triage";
|
||||
import { remoteSshFrontendPlanForTest } from "./src/remote";
|
||||
import { extractRemoteCliOptions, remoteSshFrontendPlanForTest } from "./src/remote";
|
||||
import { formatSshFailureHint, formatSshRuntimeTimingHint, parseSshArgs, parseSshInvocation, remoteApplyPatchSource, sshFailureHint, sshRuntimeTimingHint } from "./src/ssh";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
@@ -77,6 +77,11 @@ export function runSshArgvGuidanceContract(): JsonRecord {
|
||||
assertCondition(script.remoteCommand === "'bash' '-s' '--' 'alpha beta'", "script helper must pass stdin to shell directly", script);
|
||||
assertCondition(script.requiresStdin === true, "script helper must require stdin", script);
|
||||
|
||||
const directScriptCommand = parseSshArgs(["script", "--", "sed", "-n", "1,2p", "file.txt"]);
|
||||
assertCondition(directScriptCommand.invocationKind === "argv", "script -- command form must run as direct argv without stdin", directScriptCommand);
|
||||
assertCondition(directScriptCommand.remoteCommand === "'sed' '-n' '1,2p' 'file.txt'", "script -- command form must preserve dash-prefixed command args", directScriptCommand);
|
||||
assertCondition(directScriptCommand.requiresStdin === false, "script -- command form must not wait for stdin", directScriptCommand);
|
||||
|
||||
const k3sGuard = parseSshInvocation("D601:k3s", ["guard"]).parsed;
|
||||
assertCondition(k3sGuard.invocationKind === "helper", "k3s guard must be classified as helper", k3sGuard);
|
||||
assertCondition(k3sGuard.remoteCommand?.includes("KUBECONFIG") && k3sGuard.remoteCommand.includes("/etc/rancher/k3s/k3s.yaml"), "k3s guard must force native k3s kubeconfig", k3sGuard);
|
||||
@@ -116,6 +121,16 @@ export function runSshArgvGuidanceContract(): JsonRecord {
|
||||
assertCondition(routeControlScript.parsed.requiresStdin === true, "k3s control-plane script operation must stream local stdin", routeControlScript);
|
||||
assertCondition(routeControlScript.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'bash' '-s' '--' 'arg'", "D601:k3s script must inject native kubeconfig without manual export", routeControlScript);
|
||||
|
||||
const topLevelScriptSeparator = extractRemoteCliOptions(["ssh", "D601:/tmp", "script", "--", "sed", "-n", "1,2p", "file.txt"]);
|
||||
assertCondition(JSON.stringify(topLevelScriptSeparator.args) === JSON.stringify(["ssh", "D601:/tmp", "script", "--", "sed", "-n", "1,2p", "file.txt"]), "top-level remote option parser must preserve command-local -- after the command starts", topLevelScriptSeparator);
|
||||
const topLevelScriptInvocation = parseSshInvocation(topLevelScriptSeparator.args[1] as string, topLevelScriptSeparator.args.slice(2));
|
||||
assertCondition(topLevelScriptInvocation.parsed.remoteCommand === "'sed' '-n' '1,2p' 'file.txt'", "script -- must allow argv such as sed -n to execute without being parsed as script options", topLevelScriptInvocation);
|
||||
assertCondition(topLevelScriptInvocation.parsed.requiresStdin === false, "script -- direct command must not require stdin after top-level parsing", topLevelScriptInvocation);
|
||||
|
||||
const remoteOptionSeparator = extractRemoteCliOptions(["--main-server-ip", "74.48.78.17", "--", "ssh", "D601:/tmp", "script", "--", "sed", "-n", "1,2p", "file.txt"]);
|
||||
assertCondition(remoteOptionSeparator.host === "74.48.78.17", "global remote options before -- must still be parsed", remoteOptionSeparator);
|
||||
assertCondition(JSON.stringify(remoteOptionSeparator.args) === JSON.stringify(["ssh", "D601:/tmp", "script", "--", "sed", "-n", "1,2p", "file.txt"]), "global -- must not strip nested command separators", remoteOptionSeparator);
|
||||
|
||||
const routeApplyPatch = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api", ["apply-patch"]);
|
||||
assertCondition(routeApplyPatch.parsed.requiresStdin === true, "k3s apply-patch operation must stream local patch stdin", routeApplyPatch);
|
||||
assertCondition(routeApplyPatch.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-i' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'sh' '-s' '--'", "D601:k3s:<namespace>:<workload> apply-patch must enter pod with stdin", routeApplyPatch);
|
||||
@@ -357,12 +372,14 @@ export function runSshArgvGuidanceContract(): JsonRecord {
|
||||
checks: [
|
||||
"argv form is classified and quoted as the success path for non-interactive commands",
|
||||
"stdin script form removes shell-command strings for host and k3s workload scripts",
|
||||
"script -- command form executes dash-prefixed argv without waiting for stdin",
|
||||
"pod apply-patch operation injects helper and forwards patch stdin",
|
||||
"pod exec --stdin streams arbitrary local stdin through workload routes without shell wrapping",
|
||||
"apply-patch uses one sh helper for host and pod paths and rejects low-context hunks unless --allow-loose is explicit",
|
||||
"legacy operation-in-route forms are rejected in any k3s route segment with canonical route-plus-operation guidance",
|
||||
"post-provider k3s shorthand is rejected so location and operation stay separated",
|
||||
"k3s route stays location-only while operations fix native kubeconfig and assemble kubectl exec as argv",
|
||||
"top-level remote option parsing preserves command-local -- separators for script -- sed -n style commands",
|
||||
"ssh-like timeout/kex failures emit one structured argv retry hint",
|
||||
"ssh runtime emits one structured timing hint on stderr and marks operations over 10 seconds as warnings",
|
||||
"help text documents stdin script passthrough and UNIDESK_SSH_HINT",
|
||||
|
||||
Reference in New Issue
Block a user