diff --git a/scripts/src/apply-patch-v2.ts b/scripts/src/apply-patch-v2.ts index f3bb7f08..700ee9f1 100644 --- a/scripts/src/apply-patch-v2.ts +++ b/scripts/src/apply-patch-v2.ts @@ -425,7 +425,7 @@ async function readRemoteText(executor: ApplyPatchV2Executor, target: string): P const [bytesText, expectedSha256] = stat.stdout.trim().split(/\s+/u); const expectedBytes = Number(bytesText); if (!Number.isSafeInteger(expectedBytes) || expectedBytes < 0 || !/^[0-9a-f]{64}$/u.test(expectedSha256 ?? "")) { - throw new ApplyPatchV2Error("remote apply-patch v2 stat returned invalid metadata", { path: target, stdout: stat.stdout.slice(0, 500) }); + throw new ApplyPatchV2Error("remote apply-patch v2 stat returned invalid metadata", { path: target, stdout: stat.stdout.slice(0, 500), stderr: stat.stderr.slice(-500) }); } const chunks: Buffer[] = []; @@ -606,6 +606,8 @@ function remoteV2Script(operation: RemoteV2Operation, args: string[]): string[] "case \"$op\" in", " stat)", " target=$1", + " if [ ! -e \"$target\" ]; then printf 'file not found: %s\\n' \"$target\" >&2; exit 1; fi", + " if [ -d \"$target\" ]; then printf 'not a file: %s\\n' \"$target\" >&2; exit 1; fi", " bytes=$(wc -c < \"$target\" | tr -d '[:space:]')", " digest=$(sha256_file \"$target\")", " printf '%s %s\\n' \"$bytes\" \"$digest\"", diff --git a/scripts/ssh-argv-guidance-contract-test.ts b/scripts/ssh-argv-guidance-contract-test.ts index 83d7c1e3..97d707f7 100644 --- a/scripts/ssh-argv-guidance-contract-test.ts +++ b/scripts/ssh-argv-guidance-contract-test.ts @@ -19,6 +19,7 @@ import { shellArgv, sshFailureHint, sshShellCompatibilityPrelude, + sshUserToolPathPrelude, sshRuntimeTimeoutHint, sshRuntimeTimeoutMs, sshRuntimeTimingHint, @@ -49,6 +50,10 @@ function sha256BufferHex(value: Buffer): string { return createHash("sha256").update(value).digest("hex"); } +function sshShellScriptPrelude(): string { + return `${sshUserToolPathPrelude}\n${sshShellCompatibilityPrelude}`; +} + async function captureStdout(fn: () => Promise): Promise<{ exitCode: number; stdout: string }> { const originalWrite = process.stdout.write; let stdout = ""; @@ -464,12 +469,12 @@ 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 === 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.remoteCommand === shellArgv(["sh", "-c", `${sshShellScriptPrelude()}\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 === 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.remoteCommand === shellArgv(["sh", "-c", `${sshShellScriptPrelude()}\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"]) { @@ -491,7 +496,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 === 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); + assertCondition(routeK3sShell.parsed.remoteCommand === shellArgv(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", "sh", "-c", `${sshShellScriptPrelude()}\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); @@ -512,25 +517,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 === 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); + 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", `${sshShellScriptPrelude()}\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); + assertCondition(routeScript.parsed.stdinPrefix === `${sshShellScriptPrelude()}\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); + assertCondition(routeControlScript.parsed.stdinPrefix === `${sshShellScriptPrelude()}\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 === 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); + assertCondition(routeControlScriptOneLiner.parsed.remoteCommand === shellArgv(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", "sh", "-c", `${sshShellScriptPrelude()}\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 === 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); + assertCondition(routePodScriptOneLiner.parsed.remoteCommand === shellArgv(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", "kubectl", "exec", "-n", "hwlab-dev", "deployment/hwlab-cloud-api", "--", "sh", "-c", `${sshShellScriptPrelude()}\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); @@ -724,6 +729,25 @@ export async function runSshArgvGuidanceContract(): Promise { assertCondition(truncatedLargeReadV2.files["large-read.txt"] === largeOriginal, "v2 must keep the original file when bridge stdout truncates a read block", truncatedLargeReadV2); assertCondition(!truncatedLargeReadV2.commands.some((command) => command.startsWith("write-b64")), "v2 must not write after read integrity fails", truncatedLargeReadV2.commands); + const missingUpdateShellV2 = await applyPatchV2ActualShellFixtureAttempt([ + "*** Begin Patch", + "*** Update File: missing-dist.js", + "@@", + "-old", + "+new", + "*** End Patch", + "", + ].join("\n"), {}); + const missingUpdateShellError = missingUpdateShellV2.error instanceof Error ? missingUpdateShellV2.error : null; + assertCondition(missingUpdateShellError !== null, "v2 real shell stat should reject missing update targets", missingUpdateShellV2); + assertCondition( + missingUpdateShellError.message.includes("remote apply-patch v2 operation failed") + && JSON.stringify((missingUpdateShellError as { details?: unknown }).details ?? {}).includes("file not found: missing-dist.js"), + "v2 real shell stat must expose file-not-found instead of invalid metadata", + missingUpdateShellError, + ); + assertCondition(!missingUpdateShellV2.commands.some((command) => command.startsWith("read-b64") || command.startsWith("write-b64")), "missing update target must fail before remote read/write", missingUpdateShellV2.commands); + const truncatedLargeWriteV2 = await applyPatchV2ActualShellFixtureAttempt([ "*** Begin Patch", "*** Update File: large.txt",