From b2435d406b1a04b620c7e9e7b1fd6051eb59e304 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 30 May 2026 02:15:31 +0000 Subject: [PATCH] fix: harden ssh script shell compatibility --- docs/reference/cli.md | 4 ++-- scripts/src/help.ts | 3 ++- scripts/src/ssh.ts | 23 ++++++++++++------ scripts/ssh-argv-guidance-contract-test.ts | 27 ++++++++++++++++------ 4 files changed, 40 insertions(+), 17 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 7d841ea8..eac54d8b 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -141,7 +141,7 @@ exec /root/unidesk/scripts/tran "$@" 主 server 上的人工/Codex 分布式敏捷操作必须直接写 `tran ...`,不要在 Codex 工具调用里退回完整 `bun scripts/cli.ts ssh ...` 前缀。例如 `tran D601:/home/ubuntu/workspace/hwlab-dev git status --short --branch`、`tran D601:k3s kubectl get pods -n hwlab-dev` 或 `tran D601:k3s:hwlab-dev:hwlab-cloud-web/tmp pwd`。CLI 命令参考和需要跨机器复制的脚本为了说明稳定入口,可以保留完整 `bun scripts/cli.ts ssh ...` 形式;`tran` 是主 server 本机操作纪律,不作为远端 provider 或 CI/CD 的前置依赖。 -`tran` 同样遵守 route/operation 解析器;route 后面的第一个 token 不是原生 ssh 命令字符串。不要写 `tran G14:/root/hwlab sh -lc '...'`,因为 `sh` 会被解析为 stdin script helper 的别名,`-lc` 会变成不受支持的 script 选项。带变量展开、管道、重定向或多条命令的远端逻辑,默认使用 `tran G14:/root/hwlab script <<'SCRIPT'`;默认 `script` 走目标节点 `/bin/sh`,并继承 provider-gateway/G14 已长期化的 proxy 环境。需要临时单步执行一行远端 shell 逻辑、且不想先创建脚本文件或 heredoc 时,优先使用 `tran G14:/root/hwlab script -- 'sed -n "1,20p" a && sed -n "1,20p" b'`,CLI 会把单个字符串放进目标节点的 `sh -c`,第二个 `sed`、管道和重定向都会留在远端;等价 `shell ''` 仍保留为显式 shell operation。`script --` 后跟多个 token 时保持 direct argv,例如 `tran G14:/root/hwlab script -- sed -n '1,20p' AGENTS.md`。只有脚本确实使用 `pipefail`、数组、`[[ ... ]]` 等 bash 专有语义时才加 `--shell bash`,不能把 `--shell bash` 当作 proxy 修复手段。单进程命令才直接写成 argv,例如 `tran G14:/root/hwlab git status --short --branch`。遇到分布式开发摩擦时,优先补强 `tran` 的 route/operation、stdin helper 或目标节点环境,并把稳定解法写回长期参考文档,不要退回多层 shell 字符串拼接。 +`tran` 同样遵守 route/operation 解析器;route 后面的第一个 token 不是原生 ssh 命令字符串。不要写 `tran G14:/root/hwlab sh -lc '...'`,因为 `sh` 会被解析为 stdin script helper 的别名,`-lc` 会变成不受支持的 script 选项。带变量展开、管道、重定向或多条命令的远端逻辑,默认使用 `tran G14:/root/hwlab script <<'SCRIPT'`;默认 `script` 走目标节点 `/bin/sh`,并继承 provider-gateway/G14 已长期化的 proxy 环境。需要临时单步执行一行远端 shell 逻辑、且不想先创建脚本文件或 heredoc 时,优先使用 `tran G14:/root/hwlab script -- 'sed -n "1,20p" a && sed -n "1,20p" b'`,CLI 会把单个字符串放进目标节点的 `sh -c`,第二个 `sed`、管道和重定向都会留在远端;等价 `shell ''` 仍保留为显式 shell operation。`script` 和 `shell` helper 会在用户 shell 文本前注入一个极小的 POSIX 兼容 `printf` wrapper,使 `printf "--- section ---\n"` 这类高频排障分隔标题在 dash/sh 与 bash 下行为一致;direct argv 形态不注入该 wrapper。`script --` 后跟多个 token 时保持 direct argv,例如 `tran G14:/root/hwlab script -- sed -n '1,20p' AGENTS.md`。只有脚本确实使用 `pipefail`、数组、`[[ ... ]]` 等 bash 专有语义时才加 `--shell bash`,不能把 `--shell bash` 当作 proxy 修复手段。单进程命令才直接写成 argv,例如 `tran G14:/root/hwlab git status --short --branch`。遇到分布式开发摩擦时,优先补强 `tran` 的 route/operation、stdin helper 或目标节点环境,并把稳定解法写回长期参考文档,不要退回多层 shell 字符串拼接。 本地 shell 运算符不是 `tran` 可以拦截的内容。`tran G14:/root/hwlab sed -n '1,20p' AGENTS.md && sed -n '1,20p' docs/reference/g14.md` 会先由 master server 的本地 shell 拆成两个命令,只有第一个 `sed` 进入 G14,第二个 `sed` 会在 master server 当前目录执行。需要把两个命令都放到目标节点时,必须写成 `tran G14:/root/hwlab script -- 'sed -n "1,20p" AGENTS.md && sed -n "1,20p" docs/reference/g14.md'`,或者用 `tran G14:/root/hwlab script <<'SCRIPT'` 把多行脚本送到远端。 @@ -201,7 +201,7 @@ printf 'import sys\nprint(sys.argv)\n' | bun scripts/cli.ts ssh D601 py foo '--b `ssh py` 的附加参数是脚本参数,不是 Python 解释器参数;如需 `-m`、`-X` 或多条 shell 命令,仍使用原始远端命令入口。为了保证 CLI 输出及时可见,helper 固定采用“临时文件 + `python3 -u`”模式;provider 命令模式不分配 TTY,因此脚本内容不应被远端回显。 -如果远端逻辑需要 shell 特性,不要再把整段脚本作为原生 ssh-like 命令字符串传入。正式入口是 `bun scripts/cli.ts ssh D601 script`,脚本正文从 stdin 进入;CLI 会把本地 stdin 直接送到远端 `sh -s --`,`--shell bash` 可切换为 bash,`--` 后的内容会作为脚本参数传入。临时单步执行优先用 quoted heredoc;只有命令很短、明确希望一行内完成时才用 `script -- ''`,它会把单个字符串按远端 shell one-liner 执行且不等待 stdin;复用脚本时才用 `< script.sh` 文件重定向。`script -- <多个 argv>` 仍是 direct argv,不经过远端 shell,适合 `script -- sed -n '1,20p' file`。典型用法: +如果远端逻辑需要 shell 特性,不要再把整段脚本作为原生 ssh-like 命令字符串传入。正式入口是 `bun scripts/cli.ts ssh D601 script`,脚本正文从 stdin 进入;CLI 会把本地 stdin 直接送到远端 `sh -s --`,`--shell bash` 可切换为 bash,`--` 后的内容会作为脚本参数传入。`script`/`shell` helper 会在用户脚本文本前注入兼容前缀,让 `printf "--- section ---\n"` 这类分隔标题不再因目标 `/bin/sh` 方言失败;已有 `printf '%s\n' value`、`printf -- ...` 和 bash 的 `printf -v` 仍按原语义工作。临时单步执行优先用 quoted heredoc;只有命令很短、明确希望一行内完成时才用 `script -- ''`,它会把单个字符串按远端 shell one-liner 执行且不等待 stdin;复用脚本时才用 `< script.sh` 文件重定向。`script -- <多个 argv>` 仍是 direct argv,不经过远端 shell,适合 `script -- sed -n '1,20p' file`。典型用法: ```bash cat <<'SCRIPT' | bun scripts/cli.ts ssh D601 script --shell bash -- alpha diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 08599090..89ebc5b4 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -27,7 +27,7 @@ export function rootHelp(): unknown { { command: "ssh upload | ssh download ", description: "Transfer whole files through SSH passthrough with remote temp files, byte-count checks, SHA-256 verification, and client-side chunk fallback." }, { command: "ssh 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 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 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 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 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 find [--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 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 -- ''`; 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 ''` operation remains available for the same sh -c path.", "When a one-line shell command is easier to type through the script path, `script -- ''` 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 -- ...`; 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.", diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 0387a176..35cc3c62 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -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 } { diff --git a/scripts/ssh-argv-guidance-contract-test.ts b/scripts/ssh-argv-guidance-contract-test.ts index 7a9416a8..f9a0e349 100644 --- a/scripts/ssh-argv-guidance-contract-test.ts +++ b/scripts/ssh-argv-guidance-contract-test.ts @@ -16,7 +16,9 @@ import { parseSshArgs, parseSshInvocation, remoteApplyPatchSource, + shellArgv, sshFailureHint, + sshShellCompatibilityPrelude, sshRuntimeTimeoutHint, sshRuntimeTimeoutMs, sshRuntimeTimingHint, @@ -423,14 +425,19 @@ export async function runSshArgvGuidanceContract(): Promise { const directScriptOneLiner = parseSshArgs(["script", "--", "cd /root/hwlab && git status --short --branch"]); assertCondition(directScriptOneLiner.invocationKind === "helper", "script -- single-string command should run through a remote shell", directScriptOneLiner); - assertCondition(directScriptOneLiner.remoteCommand === "'sh' '-c' 'cd /root/hwlab && git status --short --branch'", "script -- single-string command should match the intuitive remote shell one-liner form", directScriptOneLiner); + assertCondition(directScriptOneLiner.remoteCommand === shellArgv(["sh", "-c", `${sshShellCompatibilityPrelude}\ncd /root/hwlab && git status --short --branch`]), "script -- single-string command should match the intuitive remote shell one-liner form with the compatibility prelude", directScriptOneLiner); assertCondition(directScriptOneLiner.requiresStdin === false, "script -- single-string command should not wait for stdin", directScriptOneLiner); const shellOneLiner = parseSshArgs(["shell", "sed -n '1,2p' a && sed -n '1,2p' b"]); assertCondition(shellOneLiner.invocationKind === "helper", "shell one-liner must be a helper operation", shellOneLiner); - assertCondition(shellOneLiner.remoteCommand === "'sh' '-c' 'sed -n '\\''1,2p'\\'' a && sed -n '\\''1,2p'\\'' b'", "shell one-liner must keep command operators inside the remote shell", shellOneLiner); + assertCondition(shellOneLiner.remoteCommand === shellArgv(["sh", "-c", `${sshShellCompatibilityPrelude}\nsed -n '1,2p' a && sed -n '1,2p' b`]), "shell one-liner must keep command operators inside the remote shell", shellOneLiner); assertCondition(shellOneLiner.requiresStdin === false, "shell one-liner must not require stdin", shellOneLiner); + for (const shell of ["sh", "bash"]) { + const fixture = spawnSync(shell, ["-c", `${sshShellCompatibilityPrelude}\nprintf "--- AGENTS ---\\n"\nprintf -- "%s\\n" ok`], { encoding: "utf8" }); + assertCondition(fixture.status === 0 && fixture.stdout === "--- AGENTS ---\nok\n", "script/shell compatibility prelude must make printf headings portable across sh and bash", { shell, status: fixture.status, stdout: fixture.stdout, stderr: fixture.stderr }); + } + 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); @@ -445,7 +452,7 @@ export async function runSshArgvGuidanceContract(): Promise { assertCondition(routeKubectl.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'get' 'pods' '-n' 'hwlab-dev'", "D601:k3s kubectl must map to kubectl argv", routeKubectl); const routeK3sShell = parseSshInvocation("D601:k3s", ["shell", "kubectl get nodes && kubectl get pods -A"]); - assertCondition(routeK3sShell.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'sh' '-c' 'kubectl get nodes && kubectl get pods -A'", "D601:k3s shell must run one-line shell logic on the k3s host with native kubeconfig", routeK3sShell); + assertCondition(routeK3sShell.parsed.remoteCommand === shellArgv(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", "sh", "-c", `${sshShellCompatibilityPrelude}\nkubectl get nodes && kubectl get pods -A`]), "D601:k3s shell must run one-line shell logic on the k3s host with native kubeconfig", routeK3sShell); const g14Guard = parseSshInvocation("G14:k3s", []); assertCondition(g14Guard.providerId === "G14" && g14Guard.route.plane === "k3s", "G14:k3s must parse as a native k3s route", g14Guard); @@ -466,23 +473,25 @@ export async function runSshArgvGuidanceContract(): Promise { assertCondition(routeTargetArgv.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'sh' '-c' 'printf ok'", "D601:k3s:: argv must exec the argv payload instead of treating argv as a pod command", routeTargetArgv); const routeTargetShell = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api/app", ["shell", "pwd && ls"]); - assertCondition(routeTargetShell.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'sh' '-c' 'cd \"$1\" || exit; shift; exec \"$@\"' 'unidesk-cwd' '/app' 'sh' '-c' 'pwd && ls'", "D601:k3s::/ shell must run shell logic after cd inside the pod", routeTargetShell); + assertCondition(routeTargetShell.parsed.remoteCommand === shellArgv(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", "kubectl", "exec", "-n", "hwlab-dev", "deployment/hwlab-cloud-api", "--", "sh", "-c", 'cd "$1" || exit; shift; exec "$@"', "unidesk-cwd", "/app", "sh", "-c", `${sshShellCompatibilityPrelude}\npwd && ls`]), "D601:k3s::/ shell must run shell logic after cd inside the pod", routeTargetShell); const routeScript = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api", ["script", "--shell", "bash", "--", "arg"]); assertCondition(routeScript.parsed.requiresStdin === true, "k3s script operation must stream local stdin", routeScript); assertCondition(routeScript.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-i' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'bash' '-s' '--' 'arg'", "D601:k3s:: script must map stdin to shell -s", routeScript); + assertCondition(routeScript.parsed.stdinPrefix === `${sshShellCompatibilityPrelude}\n`, "k3s script stdin must inject the shell compatibility prelude before user script text", routeScript); const routeControlScript = parseSshInvocation("D601:k3s", ["script", "--shell", "bash", "--", "arg"]); 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); + assertCondition(routeControlScript.parsed.stdinPrefix === `${sshShellCompatibilityPrelude}\n`, "k3s control-plane script stdin must inject the shell compatibility prelude before user script text", routeControlScript); const routeControlScriptOneLiner = parseSshInvocation("D601:k3s", ["script", "--", "echo k3s-script-ok"]); assertCondition(routeControlScriptOneLiner.parsed.requiresStdin === false, "k3s control-plane script -- one-liner must not wait for stdin", routeControlScriptOneLiner); - assertCondition(routeControlScriptOneLiner.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'sh' '-c' 'echo k3s-script-ok'", "k3s control-plane script -- one-liner must run through the native kubeconfig shell path", routeControlScriptOneLiner); + assertCondition(routeControlScriptOneLiner.parsed.remoteCommand === shellArgv(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", "sh", "-c", `${sshShellCompatibilityPrelude}\necho k3s-script-ok`]), "k3s control-plane script -- one-liner must run through the native kubeconfig shell path", routeControlScriptOneLiner); const routePodScriptOneLiner = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api", ["script", "--", "echo pod-script-ok"]); assertCondition(routePodScriptOneLiner.parsed.requiresStdin === false, "k3s workload script -- one-liner must not wait for stdin", routePodScriptOneLiner); - assertCondition(routePodScriptOneLiner.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'sh' '-c' 'echo pod-script-ok'", "k3s workload script -- one-liner must run as sh -c inside the workload", routePodScriptOneLiner); + assertCondition(routePodScriptOneLiner.parsed.remoteCommand === shellArgv(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", "kubectl", "exec", "-n", "hwlab-dev", "deployment/hwlab-cloud-api", "--", "sh", "-c", `${sshShellCompatibilityPrelude}\necho pod-script-ok`]), "k3s workload script -- one-liner must run as sh -c inside the workload", routePodScriptOneLiner); 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); @@ -1016,11 +1025,14 @@ export async function runSshArgvGuidanceContract(): Promise { assertCondition(formattedRuntimeTimeout.includes("short query plus poll semantics"), "runtime timeout hint must point to short polling", formattedRuntimeTimeout); assertCondition(!formattedRuntimeTimeout.includes("kubectl"), "runtime timeout hint must not echo remote command text", formattedRuntimeTimeout); - const helpText = JSON.stringify(sshHelp()); + const help = sshHelp() as { notes?: unknown }; + const helpText = JSON.stringify(help); + const helpNotes = Array.isArray(help.notes) ? help.notes.map(String).join("\n") : ""; assertCondition(helpText.includes("ssh script [--shell sh|bash] [script-args...] <<'SCRIPT'"), "ssh help must recommend stdin script passthrough for shell scripts", helpText); assertCondition(helpText.includes("ssh shell [--shell sh|bash]") && helpText.includes("outer shell operators written outside tran"), "ssh help must document one-line shell passthrough and the local operator boundary", helpText); assertCondition(helpText.includes("inherits provider proxy variables"), "ssh help must state default script inherits provider proxy env", helpText); assertCondition(helpText.includes("not as a proxy workaround"), "ssh help must reserve --shell bash for bash syntax instead of proxy workarounds", helpText); + assertCondition(helpNotes.includes("portable printf headings") && helpNotes.includes('printf "--- section ---\\n"'), "ssh help must document script/shell printf heading compatibility", helpNotes); assertCondition(helpText.includes("ssh D601:/home/ubuntu/workspace/hwlab-dev git status --short --branch"), "ssh help must document host workspace routes", helpText); assertCondition(helpText.includes("ssh D601:win cmd ver") && helpText.includes("ssh D601:win/c/test cmd cd") && helpText.includes("ssh D601:win skills"), "ssh help must document Windows cmd and skills win routes", helpText); assertCondition(helpText.includes("Use `win`, not `win32`") && helpText.includes("chcp 65001") && helpText.includes("PYTHONIOENCODING=utf-8"), "ssh help must document win route UTF-8 defaults and naming", helpText); @@ -1103,6 +1115,7 @@ export async function runSshArgvGuidanceContract(): Promise { "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 -- single-string runs as a remote shell one-liner while multi-token form keeps dash-prefixed argv", + "script/shell helpers inject a portable printf prelude for common section headings", "pod apply-patch operation uses the v2 local engine and apply-patch-v1 injects the legacy helper", "pod exec --stdin streams arbitrary local stdin through workload routes without shell wrapping", "upload/download file transfer operations use a dedicated module with byte-count and sha256 verification",