fix: support windows ssh apply-patch
This commit is contained in:
@@ -32,7 +32,20 @@ export interface ApplyPatchV2Options {
|
||||
}
|
||||
|
||||
export interface ApplyPatchV2Executor {
|
||||
run(command: string[], input?: string): Promise<ApplyPatchV2RemoteResult>;
|
||||
run?: (command: string[], input?: string) => Promise<ApplyPatchV2RemoteResult>;
|
||||
fs?: ApplyPatchV2FileSystem;
|
||||
}
|
||||
|
||||
export interface ApplyPatchV2FileSystem {
|
||||
stat(path: string): Promise<ApplyPatchV2FileStat>;
|
||||
readBlock(path: string, blockIndex: number, blockBytes: number): Promise<Buffer>;
|
||||
writeFile(path: string, content: Buffer): Promise<void>;
|
||||
deleteFile(path: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ApplyPatchV2FileStat {
|
||||
bytes: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface ApplyPatchV2RemoteResult {
|
||||
@@ -258,6 +271,10 @@ async function executePlannedOperation(executor: ApplyPatchV2Executor, operation
|
||||
await writeRemoteText(executor, operation.path, operation.content);
|
||||
return;
|
||||
}
|
||||
if (executor.fs) {
|
||||
await executor.fs.deleteFile(operation.path);
|
||||
return;
|
||||
}
|
||||
await checkedRemoteV2(executor, "delete", [operation.path]);
|
||||
}
|
||||
|
||||
@@ -266,6 +283,45 @@ const writeB64ArgvLimit = 48_000;
|
||||
const writeB64ChunkChars = 12_000;
|
||||
|
||||
async function readRemoteText(executor: ApplyPatchV2Executor, target: string): Promise<string> {
|
||||
if (executor.fs) {
|
||||
const stat = await executor.fs.stat(target);
|
||||
if (!Number.isSafeInteger(stat.bytes) || stat.bytes < 0 || !/^[0-9a-f]{64}$/u.test(stat.sha256)) {
|
||||
throw new ApplyPatchV2Error("remote apply-patch v2 fs stat returned invalid metadata", { path: target, stat });
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let actualBytes = 0;
|
||||
for (let blockIndex = 0; actualBytes < stat.bytes; blockIndex += 1) {
|
||||
const chunk = await executor.fs.readBlock(target, blockIndex, readBlockBytes);
|
||||
if (chunk.length === 0) {
|
||||
throw new ApplyPatchV2Error("remote apply-patch v2 fs read returned an empty block before EOF", {
|
||||
path: target,
|
||||
blockIndex,
|
||||
expectedBytes: stat.bytes,
|
||||
actualBytes,
|
||||
});
|
||||
}
|
||||
chunks.push(chunk);
|
||||
actualBytes += chunk.length;
|
||||
}
|
||||
const contentBuffer = Buffer.concat(chunks);
|
||||
if (contentBuffer.length !== stat.bytes) {
|
||||
throw new ApplyPatchV2Error("remote apply-patch v2 fs read byte count mismatch", {
|
||||
path: target,
|
||||
expectedBytes: stat.bytes,
|
||||
actualBytes: contentBuffer.length,
|
||||
});
|
||||
}
|
||||
const actualSha256 = sha256Hex(contentBuffer);
|
||||
if (actualSha256 !== stat.sha256) {
|
||||
throw new ApplyPatchV2Error("remote apply-patch v2 fs read sha256 mismatch", {
|
||||
path: target,
|
||||
expectedSha256: stat.sha256,
|
||||
actualSha256,
|
||||
});
|
||||
}
|
||||
return contentBuffer.toString("utf8");
|
||||
}
|
||||
|
||||
const stat = await checkedRemoteV2(executor, "stat", [target]);
|
||||
const [bytesText, expectedSha256] = stat.stdout.trim().split(/\s+/u);
|
||||
const expectedBytes = Number(bytesText);
|
||||
@@ -312,6 +368,10 @@ async function readRemoteText(executor: ApplyPatchV2Executor, target: string): P
|
||||
|
||||
async function writeRemoteText(executor: ApplyPatchV2Executor, target: string, content: string): Promise<void> {
|
||||
const contentBuffer = Buffer.from(content, "utf8");
|
||||
if (executor.fs) {
|
||||
await executor.fs.writeFile(target, contentBuffer);
|
||||
return;
|
||||
}
|
||||
const encoded = contentBuffer.toString("base64");
|
||||
const expectedBytes = String(contentBuffer.length);
|
||||
const expectedSha256 = sha256Hex(contentBuffer);
|
||||
@@ -347,6 +407,9 @@ type RemoteV2Operation =
|
||||
| "move";
|
||||
|
||||
async function checkedRemoteV2(executor: ApplyPatchV2Executor, operation: RemoteV2Operation, args: string[], input?: string): Promise<{ stdout: string }> {
|
||||
if (!executor.run) {
|
||||
throw new ApplyPatchV2Error("remote apply-patch v2 executor does not provide a command runner", { operation, args });
|
||||
}
|
||||
const result = await executor.run(remoteV2Script(operation, args), input);
|
||||
if (result.exitCode === 0) return result;
|
||||
throw new ApplyPatchV2Error("remote apply-patch v2 operation failed", {
|
||||
|
||||
+2
-1
@@ -176,6 +176,7 @@ export function sshHelp(): unknown {
|
||||
"bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api/app apply-patch <<'PATCH'",
|
||||
"bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api apply-patch-v1 <<'PATCH'",
|
||||
"tar -C /path/to/files -cf - . | bun scripts/cli.ts ssh D601:k3s:unidesk:code-queue/root/unidesk exec --stdin -- tar -xf - -C /root/unidesk",
|
||||
"bun scripts/cli.ts ssh D601:win/c/test apply-patch <<'PATCH'",
|
||||
"bun scripts/cli.ts ssh D601:win upload ./tool.mjs F:\\Work\\hwlab\\.tmp\\tool.mjs",
|
||||
"bun scripts/cli.ts ssh D601:win download F:\\Work\\hwlab\\.tmp\\tool.mjs ./tool.mjs",
|
||||
"bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api node -e 'console.log(process.version)'",
|
||||
@@ -188,7 +189,7 @@ export function sshHelp(): unknown {
|
||||
"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`.",
|
||||
"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, so long Unicode/Chinese lines and pure insertion hunks avoid the legacy remote shell hunk parser.",
|
||||
"`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.",
|
||||
"`apply-patch-v1` is the only legacy fallback entry: it 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.",
|
||||
|
||||
+126
-3
@@ -1,6 +1,7 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { type UniDeskConfig, repoRoot } from "./config";
|
||||
import { isApplyPatchV2HelpArgs, runApplyPatchV2, type ApplyPatchV2Executor } from "./apply-patch-v2";
|
||||
import { isApplyPatchV2HelpArgs, runApplyPatchV2, type ApplyPatchV2Executor, type ApplyPatchV2FileSystem } from "./apply-patch-v2";
|
||||
import { isSshFileTransferOperation, runSshFileTransferOperation, type SshRemoteCommandExecutor } from "./ssh-file-transfer";
|
||||
|
||||
export interface ParsedSshArgs {
|
||||
@@ -988,6 +989,18 @@ function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs
|
||||
if (operation === "upload" || operation === "download") {
|
||||
return { remoteCommand: null, requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
if (operation === "apply-patch") {
|
||||
if (isApplyPatchV2HelpArgs(args.slice(1))) {
|
||||
return { remoteCommand: null, requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
return { remoteCommand: null, requiresStdin: true, invocationKind: "helper" };
|
||||
}
|
||||
if (operation === "apply-patch-v1") {
|
||||
throw new Error(`ssh ${route.raw} apply-patch-v1 is not supported for Windows routes; use apply-patch v2`);
|
||||
}
|
||||
if (operation === "patch" || operation === "patch-v1" || operation === "v2") {
|
||||
throw new Error("remote patch entrypoints are `apply-patch` for the default v2 engine and `apply-patch-v1` for the legacy helper");
|
||||
}
|
||||
if (operation === "skills" || operation === "skill-discover" || operation === "discover-skills") {
|
||||
return {
|
||||
remoteCommand: buildWindowsPowerShellInvocation(buildWindowsSkillsDiscoveryScript(args.slice(1))),
|
||||
@@ -996,7 +1009,7 @@ function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs
|
||||
};
|
||||
}
|
||||
if (operation !== "cmd" && operation !== "cmd.exe") {
|
||||
throw new Error(`unsupported ssh win operation: ${operation}; use ssh ${route.providerId}:win cmd <command-line> or ssh ${route.providerId}:win skills`);
|
||||
throw new Error(`unsupported ssh win operation: ${operation}; use ssh ${route.providerId}:win cmd <command-line>, ssh ${route.providerId}:win apply-patch, or ssh ${route.providerId}:win skills`);
|
||||
}
|
||||
const commandArgs = args[1] === "--" ? args.slice(2) : args.slice(1);
|
||||
if (commandArgs.length === 0) throw new Error(`ssh ${route.raw} cmd requires a command line, for example: ssh ${route.providerId}:win cmd ver`);
|
||||
@@ -2091,6 +2104,114 @@ export function remoteCommandForRoute(route: ParsedSshRoute, command: string[],
|
||||
return shellArgv(command);
|
||||
}
|
||||
|
||||
type WindowsApplyPatchFsOperation =
|
||||
| "stat"
|
||||
| "read-b64-block"
|
||||
| "write-b64-stdin"
|
||||
| "write-b64-begin"
|
||||
| "write-b64-append-stdin"
|
||||
| "write-b64-commit"
|
||||
| "delete";
|
||||
|
||||
const windowsApplyPatchWriteB64ChunkChars = 12_000;
|
||||
|
||||
function createWindowsApplyPatchFileSystem(config: UniDeskConfig, invocation: ParsedSshInvocation): ApplyPatchV2FileSystem {
|
||||
async function checked(operation: WindowsApplyPatchFsOperation, args: string[], input?: string): Promise<SshCaptureResult> {
|
||||
const command = buildWindowsPowerShellInvocation(windowsApplyPatchFsScript(invocation.route.workspace, operation, args));
|
||||
const result = await runSshCaptureRemoteCommand(config, invocation, command, input);
|
||||
if (result.exitCode === 0) return result;
|
||||
throw new Error(`windows apply-patch fs operation failed: ${operation} ${JSON.stringify({
|
||||
route: invocation.route.raw,
|
||||
args: args.slice(0, 4),
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout.slice(-2000),
|
||||
stderr: result.stderr.slice(-4000),
|
||||
})}`);
|
||||
}
|
||||
|
||||
return {
|
||||
async stat(filePath) {
|
||||
const result = await checked("stat", [filePath]);
|
||||
const [bytesText, sha256] = result.stdout.trim().split(/\s+/u);
|
||||
const bytes = Number(bytesText);
|
||||
if (!Number.isSafeInteger(bytes) || bytes < 0 || !/^[0-9a-f]{64}$/u.test(sha256 ?? "")) {
|
||||
throw new Error(`windows apply-patch fs stat returned invalid metadata: ${JSON.stringify({ filePath, stdout: result.stdout.slice(0, 500) })}`);
|
||||
}
|
||||
return { bytes, sha256: sha256! };
|
||||
},
|
||||
async readBlock(filePath, blockIndex, blockBytes) {
|
||||
const result = await checked("read-b64-block", [filePath, String(blockIndex), String(blockBytes)]);
|
||||
const encoded = result.stdout.replace(/\s+/gu, "");
|
||||
return encoded.length === 0 ? Buffer.alloc(0) : Buffer.from(encoded, "base64");
|
||||
},
|
||||
async writeFile(filePath, content) {
|
||||
const encoded = content.toString("base64");
|
||||
const expectedBytes = String(content.length);
|
||||
const expectedSha256 = createHash("sha256").update(content).digest("hex");
|
||||
try {
|
||||
await checked("write-b64-stdin", [filePath, expectedBytes, expectedSha256], encoded);
|
||||
return;
|
||||
} catch {
|
||||
const token = `${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}-${expectedSha256.slice(0, 12)}`;
|
||||
await checked("write-b64-begin", [filePath, token]);
|
||||
for (const chunk of chunkString(encoded, windowsApplyPatchWriteB64ChunkChars)) {
|
||||
await checked("write-b64-append-stdin", [filePath, token], chunk);
|
||||
}
|
||||
await checked("write-b64-commit", [filePath, token, expectedBytes, expectedSha256]);
|
||||
}
|
||||
},
|
||||
async deleteFile(filePath) {
|
||||
await checked("delete", [filePath]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function windowsApplyPatchFsScript(basePath: string | null, operation: WindowsApplyPatchFsOperation, args: string[]): string {
|
||||
const target = args[0] ?? "";
|
||||
const arg1 = args[1] ?? "";
|
||||
const arg2 = args[2] ?? "";
|
||||
const arg3 = args[3] ?? "";
|
||||
return [
|
||||
"$ErrorActionPreference = 'Stop';",
|
||||
"$ProgressPreference = 'SilentlyContinue';",
|
||||
"[Console]::InputEncoding = [System.Text.UTF8Encoding]::new();",
|
||||
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new();",
|
||||
"$OutputEncoding = [System.Text.UTF8Encoding]::new();",
|
||||
`$basePath = ${powerShellSingleQuote(basePath ?? "")};`,
|
||||
`$operation = ${powerShellSingleQuote(operation)};`,
|
||||
`$targetArg = ${powerShellSingleQuote(target)};`,
|
||||
`$arg1 = ${powerShellSingleQuote(arg1)};`,
|
||||
`$arg2 = ${powerShellSingleQuote(arg2)};`,
|
||||
`$arg3 = ${powerShellSingleQuote(arg3)};`,
|
||||
"function Fail([string]$Message, [int]$Code) { [Console]::Error.WriteLine($Message); exit $Code }",
|
||||
"function Resolve-UnideskPath([string]$Raw) { if ([string]::IsNullOrWhiteSpace($Raw)) { Fail 'empty apply-patch path' 2 }; if ([System.IO.Path]::IsPathRooted($Raw)) { return [System.IO.Path]::GetFullPath($Raw) }; if (-not [string]::IsNullOrWhiteSpace($basePath)) { return [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($basePath, $Raw)) }; return [System.IO.Path]::GetFullPath($Raw) }",
|
||||
"function Ensure-Parent([string]$Target) { $parent = [System.IO.Path]::GetDirectoryName($Target); if (-not [string]::IsNullOrWhiteSpace($parent)) { [System.IO.Directory]::CreateDirectory($parent) | Out-Null } }",
|
||||
"function Get-Sha256([string]$Path) { return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() }",
|
||||
"function Set-TmpPaths([string]$Target, [string]$Token) { if ($Token -notmatch '^[A-Za-z0-9_.-]+$') { Fail 'invalid apply-patch temp token' 2 }; $dir = [System.IO.Path]::GetDirectoryName($Target); if ([string]::IsNullOrWhiteSpace($dir)) { $dir = (Get-Location).ProviderPath }; $base = [System.IO.Path]::GetFileName($Target); $script:tmp = [System.IO.Path]::Combine($dir, '.' + $base + '.unidesk-v2-' + $Token + '.tmp'); $script:tmpB64 = $script:tmp + '.b64' }",
|
||||
"function Verify-Temp([string]$Target, [string]$Tmp, [Int64]$ExpectedBytes, [string]$ExpectedSha256) { $actualBytes = ([System.IO.FileInfo]$Tmp).Length; if ($actualBytes -ne $ExpectedBytes) { Remove-Item -LiteralPath $Tmp -Force -ErrorAction SilentlyContinue; Fail ('apply-patch byte count mismatch for ' + $Target + ': expected=' + $ExpectedBytes + ' actual=' + $actualBytes) 23 }; $actualSha256 = Get-Sha256 $Tmp; if ($actualSha256 -ne $ExpectedSha256) { Remove-Item -LiteralPath $Tmp -Force -ErrorAction SilentlyContinue; Fail ('apply-patch sha256 mismatch for ' + $Target + ': expected=' + $ExpectedSha256 + ' actual=' + $actualSha256) 24 } }",
|
||||
"function Decode-ToTarget([string]$Target, [string]$Encoded, [Int64]$ExpectedBytes, [string]$ExpectedSha256) { Ensure-Parent $Target; Set-TmpPaths $Target ([guid]::NewGuid().ToString('N')); try { $bytes = [Convert]::FromBase64String(($Encoded -replace '\\s','')) } catch { Fail ('apply-patch base64 decode failed for ' + $Target + ': ' + $_.Exception.Message) 22 }; [System.IO.File]::WriteAllBytes($script:tmp, $bytes); Verify-Temp $Target $script:tmp $ExpectedBytes $ExpectedSha256; Move-Item -LiteralPath $script:tmp -Destination $Target -Force; $actualSha256 = Get-Sha256 $Target; if ($actualSha256 -ne $ExpectedSha256) { Fail ('apply-patch final sha256 mismatch for ' + $Target) 25 } }",
|
||||
"$target = Resolve-UnideskPath $targetArg;",
|
||||
"switch ($operation) {",
|
||||
" 'stat' { if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { Fail ('file not found: ' + $target) 1 }; $bytes = ([System.IO.FileInfo]$target).Length; $digest = Get-Sha256 $target; [Console]::Out.WriteLine(([string]$bytes) + ' ' + $digest); break }",
|
||||
" 'read-b64-block' { $blockIndex = [Int64]$arg1; $blockSize = [Int32]$arg2; if ($blockIndex -lt 0 -or $blockSize -le 0) { Fail 'invalid read block args' 2 }; $fs = [System.IO.File]::Open($target, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite); try { [void]$fs.Seek($blockIndex * $blockSize, [System.IO.SeekOrigin]::Begin); $buffer = New-Object byte[] $blockSize; $read = $fs.Read($buffer, 0, $blockSize); if ($read -gt 0) { [Console]::Out.Write([Convert]::ToBase64String($buffer, 0, $read)) } } finally { $fs.Dispose() }; break }",
|
||||
" 'write-b64-stdin' { Decode-ToTarget $target ([Console]::In.ReadToEnd()) ([Int64]$arg1) $arg2; break }",
|
||||
" 'write-b64-begin' { Ensure-Parent $target; Set-TmpPaths $target $arg1; [System.IO.File]::WriteAllText($script:tmpB64, '', [System.Text.Encoding]::ASCII); break }",
|
||||
" 'write-b64-append-stdin' { Set-TmpPaths $target $arg1; $chunk = ([Console]::In.ReadToEnd()) -replace '\\s',''; [System.IO.File]::AppendAllText($script:tmpB64, $chunk, [System.Text.Encoding]::ASCII); break }",
|
||||
" 'write-b64-commit' { Set-TmpPaths $target $arg1; $encoded = [System.IO.File]::ReadAllText($script:tmpB64, [System.Text.Encoding]::ASCII); Remove-Item -LiteralPath $script:tmpB64 -Force -ErrorAction SilentlyContinue; Decode-ToTarget $target $encoded ([Int64]$arg2) $arg3; break }",
|
||||
" 'delete' { Remove-Item -LiteralPath $target -Force -ErrorAction SilentlyContinue; break }",
|
||||
" default { Fail ('unsupported apply-patch fs op: ' + $operation) 2 }",
|
||||
"}",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function chunkString(value: string, chunkSize: number): string[] {
|
||||
const chunks: string[] = [];
|
||||
for (let index = 0; index < value.length; index += chunkSize) {
|
||||
chunks.push(value.slice(index, index + chunkSize));
|
||||
}
|
||||
return chunks.length > 0 ? chunks : [""];
|
||||
}
|
||||
|
||||
async function runSshCaptureCommand(config: UniDeskConfig, invocation: ParsedSshInvocation, command: string[], input?: string): Promise<SshCaptureResult> {
|
||||
const remoteCommand = remoteCommandForRoute(invocation.route, command, { stdin: input !== undefined });
|
||||
return await runSshCaptureRemoteCommand(config, invocation, remoteCommand, input);
|
||||
@@ -2211,7 +2332,9 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
|
||||
});
|
||||
}
|
||||
if (operationName === "apply-patch") {
|
||||
const executor: ApplyPatchV2Executor = { run: (command, input) => runSshCaptureCommand(config, invocation, command, input) };
|
||||
const executor: ApplyPatchV2Executor = invocation.route.plane === "win"
|
||||
? { fs: createWindowsApplyPatchFileSystem(config, invocation) }
|
||||
: { run: (command, input) => runSshCaptureCommand(config, invocation, command, input) };
|
||||
return await runApplyPatchV2({
|
||||
executor,
|
||||
stdin: process.stdin,
|
||||
|
||||
Reference in New Issue
Block a user