From c0eddacd8d450a5717968027262c9c7dd51adf06 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 27 May 2026 11:28:54 +0000 Subject: [PATCH] fix: support windows ssh apply-patch --- docs/reference/cli.md | 4 +- scripts/src/apply-patch-v2.ts | 65 ++++++++++++++++- scripts/src/help.ts | 3 +- scripts/src/ssh.ts | 129 +++++++++++++++++++++++++++++++++- 4 files changed, 194 insertions(+), 7 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 2c843f75..ca4a6b15 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -20,7 +20,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `server rebuild ` 创建异步 job,先构建目标服务镜像,随后在 `.state/locks/server-compose.lock` 串行保护下用 `--no-deps --force-recreate` 替换目标 service 并等待容器 `healthy/running`;该命令用于替代手工删除容器的兜底流程,其中 `dev-frontend-proxy` 只更新主 server dev 入口薄代理,`todo-note`、`code-queue-mgr`、`project-manager`、`baidu-netdisk` 和 `oa-event-flow` 只重建主 server 承载的对应后端,不会重建或删除 database 命名卷。D601 Code Queue 执行面不由 `server rebuild` 管理,Rust backend-core 迭代不得用 `server rebuild backend-core` 在 master server 编译,规则见 `docs/reference/dev-environment.md`。 - `provider attach [--master-server URL] [--up] [--force]` 在新计算节点生成两项配置的 provider-gateway 挂载包:`.state/provider-.env` 默认只包含 `UNIDESK_MASTER_SERVER` 与 `PROVIDER_ID`,`provider-.yml` 固定 Docker socket、`pid: "host"`、`restart: always`、只读 `/workspace` 和 SSH 维护私钥挂载;`--up` 会立即执行生成的 `docker compose up -d --build`。`provider triage [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]` 是只读多信号健康裁决入口,会把单路径 `provider is not online`、SSH 超时、registry 失败和 service proxy 失败归类成 `runner-local-observation-gap`、`service-degraded`、`provider-degraded` 或 `global-blocker`。默认输出只返回裁决、scope、失败/降级/未知信号和有界 evidence 摘要,完整 evidence 必须显式加 `--full` 或 `--raw`;推荐交叉验证命令仍包含 `debug health`、`debug dispatch host.ssh --wait-ms 15000`、`ssh argv true`、`artifact-registry health --provider-id `、`microservice health k3sctl-adapter`、`microservice health code-queue` 和 `codex tasks --view supervisor --limit 20`。 - `ssh [operation args...]` / `tran [operation args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;`route` 基础形态是 provider id,例如 `D601` 或 `G14`,也可以扩展为纯定位路径 `provider:plane[:namespace:resource[:container]]`,例如 `D601:win`、`D601:win/c/test`、`G14:k3s`、`D601:k3s` 或 `G14:k3s::`。WSL provider 的 Windows cmd 入口固定写 `tran D601:win cmd `,需要 Windows cwd 时用 `tran D601:win/c/test cmd cd`,由 CLI 自动设置 `chcp 65001`、`PYTHONUTF8=1` 和 `PYTHONIOENCODING=utf-8`;命名只允许 `win`,不得使用 `win32`。非交互远端命令优先使用 `ssh argv ...`;需要 shell 脚本、管道、变量或循环时优先使用 quoted heredoc 单步传输,例如 `tran G14 script <<'SCRIPT'`、`tran G14:k3s script <<'SCRIPT'` 或 `tran G14:k3s:: script <<'SCRIPT'`,把脚本走 stdin。`script -- '<单个字符串>'` 是无需 stdin 的远端 shell one-liner,例如 `tran G14:/root/hwlab script -- 'cd /root/hwlab && git status --short --branch'`;`script -- <多个 argv>` 才是 direct argv,适合 `tran D601:/path script -- sed -n '1,20p' file` 这类带短横线的单进程命令。顶层 remote option parser 必须保留命令已经开始后的 `--`,不得把它吞成全局选项结束符。需要远端改文本文件时默认优先使用 ` apply-patch < patch.diff`;需要可靠传输非文本或整文件时使用 ` upload ` 和 ` download `,CLI 会按字节数与 SHA-256 自动校验并在 provider-gateway stdin/argv 限制下切换客户端分块策略;需要旧 helper 时显式使用 `:k3s:: apply-patch-v1` 或 ` apply-patch-v1`。ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示 stdin script/argv 重试和 provider triage 交叉验证。 -- `ssh apply-patch < patch.diff` 是默认推荐的远端 patch 入口:本地 TypeScript line-based engine 解析和计算新文件内容,远端 route 只负责读写文件;支持 host workspace、k3s pod workspace 和 frontend transport,并优先处理长中文/Unicode、低上下文插入、重复块 `@@` 定位等旧 helper 容易失败的场景。`ssh apply-patch-v1 [tool args...] < patch.diff` 保留为 v1 fallback,直接调用远端注入的 `apply_patch` sh/perl helper;只有默认 v2 引擎出现问题、需要复用旧 helper 行为或人工确认 `--allow-loose` 时才优先使用 v1。 +- `ssh apply-patch < patch.diff` 是默认推荐的远端 patch 入口:本地 TypeScript line-based engine 解析和计算新文件内容,远端 route 只负责读写文件;支持 host workspace、k3s pod workspace、Windows workspace route(例如 `D601:win/c/test`)和 frontend transport,并优先处理长中文/Unicode、低上下文插入、重复块 `@@` 定位等旧 helper 容易失败的场景。Windows route 复用同一套 v2 核心算法,只把底层读写替换成 PowerShell 文件系统接口;`ssh apply-patch-v1 [tool args...] < patch.diff` 保留为 v1 fallback,直接调用远端注入的 `apply_patch` sh/perl helper;只有默认 v2 引擎出现问题、需要复用旧 helper 行为或人工确认 `--allow-loose` 时才优先使用 v1。 - `ssh py [script-args...] < script.py` 把本地 stdin 落到远端临时 `.py` 文件后再以 `python3 -u` 执行并自动清理,避免再手写 `'python3 -'`、heredoc 或多层引号;`script-args` 会按 argv 安全透传给远端脚本。 - `ssh skills [--scope all|wsl|windows] [--limit N]` 发现目标节点上的 WSL/Linux skill 根目录;当 provider 是 WSL 时同一次调用还会扫描 Windows 用户目录下的 `.agents/skills` 与 `.codex/skills`。 - `ssh :k3s[:namespace:workload[:container]] ...` 是原生 k3s 结构化 route 入口,route 只定位控制面或 workload,`kubectl`、`logs`、`exec`、`script`、`apply-patch`、旧 `apply-patch-v1` fallback 和普通容器命令作为 operation 放在 route 之后;CLI 固定注入 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并把 kubectl、workload exec、logs 和 pod workspace 读写参数组装成 argv,避免在 Host SSH、bash、kubectl exec 和容器 shell 之间反复手写多层引号;D601 与 G14 都有 provider-specific guard,分别校验 `d601` 和 G14 k3s 节点身份。 @@ -151,7 +151,7 @@ ssh-like 远端命令如果出现 `kex_exchange_identification`、`Connection cl 远端文本 patch 默认使用 `apply-patch` 的 v2 引擎:它不把 hunk 解析交给远端 shell/perl helper,而是在本地按行序列匹配,支持长中文/Unicode 行、纯新增 hunk、低上下文插入和 `@@` 上下文定位,再把完整新内容写回远端。`apply_patch` 旧 helper 默认拒绝低上下文 update hunk:空搜索/纯插入无锚点、只在插入点前有上下文而没有插入点后上下文、或同一 hunk search 在目标文件中匹配多个位置时,都会结构化失败并提示补充上下文。成功应用时每个 hunk 会在 stderr 输出 `apply_patch: hunk N matched path:line`,用于复核实际落点;只有人工确认确实需要旧 helper 行为或 `--allow-loose` 时,才显式调用 `apply-patch-v1 --allow-loose`。 -如果只是远端打文本补丁,不需要再手写 `ssh D601 'apply_patch' < patch.diff` 这种命令拼接;正式默认入口是 `bun scripts/cli.ts ssh D601:/absolute/workspace apply-patch < patch.diff` 或 `bun scripts/cli.ts ssh D601:k3s::/ apply-patch < patch.diff`。旧 helper 只有 `apply-patch-v1` 一个入口,附加参数会原样透传给远端 `apply_patch`,例如 `bun scripts/cli.ts ssh D601 apply-patch-v1 --help` 或 `bun scripts/cli.ts ssh D601 apply-patch-v1 --allow-loose < reviewed.patch`。标准单命令用法如下,不需要先创建本地 patch 临时文件: +如果只是远端打文本补丁,不需要再手写 `ssh D601 'apply_patch' < patch.diff` 这种命令拼接;正式默认入口是 `bun scripts/cli.ts ssh D601:/absolute/workspace apply-patch < patch.diff`、`bun scripts/cli.ts ssh D601:k3s::/ apply-patch < patch.diff` 或 `bun scripts/cli.ts ssh D601:win/c/test apply-patch < patch.diff`。旧 helper 只有 `apply-patch-v1` 一个入口,附加参数会原样透传给远端 `apply_patch`,例如 `bun scripts/cli.ts ssh D601 apply-patch-v1 --help` 或 `bun scripts/cli.ts ssh D601 apply-patch-v1 --allow-loose < reviewed.patch`。标准单命令用法如下,不需要先创建本地 patch 临时文件: ```bash bun scripts/cli.ts ssh D601:/home/ubuntu/pipeline apply-patch <<'PATCH' diff --git a/scripts/src/apply-patch-v2.ts b/scripts/src/apply-patch-v2.ts index 8d989a25..37dc4dd6 100644 --- a/scripts/src/apply-patch-v2.ts +++ b/scripts/src/apply-patch-v2.ts @@ -32,7 +32,20 @@ export interface ApplyPatchV2Options { } export interface ApplyPatchV2Executor { - run(command: string[], input?: string): Promise; + run?: (command: string[], input?: string) => Promise; + fs?: ApplyPatchV2FileSystem; +} + +export interface ApplyPatchV2FileSystem { + stat(path: string): Promise; + readBlock(path: string, blockIndex: number, blockBytes: number): Promise; + writeFile(path: string, content: Buffer): Promise; + deleteFile(path: string): Promise; +} + +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 { + 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 { 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", { diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 4f2d6750..116cb515 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -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 -- ''`; 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`.", "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, 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.", diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 793aae1c..f50ae4e5 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -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 or ssh ${route.providerId}:win skills`); + throw new Error(`unsupported ssh win operation: ${operation}; use ssh ${route.providerId}:win cmd , 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 { + 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 { 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,