fix: preserve tran script command separator

This commit is contained in:
Codex
2026-05-25 15:32:56 +00:00
parent 073213c302
commit 964cc7e984
5 changed files with 41 additions and 7 deletions
+1
View File
@@ -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.",
+6 -2
View File
@@ -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
View File
@@ -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 } {
+18 -1
View File
@@ -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",