From c3c50c3b36db35567307f8fc2ff9117e634f1027 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 25 May 2026 14:06:40 +0000 Subject: [PATCH] fix: speed up tran apply-patch --- docs/reference/cli.md | 2 +- scripts/src/remote.ts | 4 +- scripts/src/ssh.ts | 121 +++++++++++++++++---- scripts/ssh-argv-guidance-contract-test.ts | 10 +- 4 files changed, 112 insertions(+), 25 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index b77d4e92..1644f3eb 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -133,7 +133,7 @@ core 只允许声明了 `host.ssh` capability 的 provider 使用 `ssh` 透传 ssh-like 远端命令如果出现 `kex_exchange_identification`、`Connection closed by remote host`、provider session timeout 或 exit code 255,CLI 会在原始 stderr 后追加一行 `UNIDESK_SSH_HINT { ... }`。该 JSON 不回显原始远端命令,只包含 `code=ssh-like-command-friction`、`trigger`、`try` 和 `triage`;`try` 固定指向 stdin script 形态,避免把一次 ssh-like 解析/握手摩擦误读成 D601 SSH 整体不可用。 -`ssh ` 会在远端会话启动时注入 `/tmp/unidesk-ssh-tools/apply_patch`、`/tmp/unidesk-ssh-tools/glob` 和 `/tmp/unidesk-ssh-tools/skill-discover`,并把该目录加入远端 `PATH`。`apply_patch` 是统一的 sh helper,接受标准 `*** Begin Patch` / `*** End Patch` patch 格式,便于通过 SSH 透传编辑远端仓库文件;`glob` 在远端用 Python 执行路径匹配,避免依赖 shell glob 展开;`skill-discover` 用于列出远端 Linux/WSL 与 Windows skill。目标节点需要具备 `sh`、`base64`,并在使用 `glob`/`skill-discover` 时具备 `python3`。注入工具只写 `/tmp/unidesk-ssh-tools`,不修改目标仓库,交互式 shell 和远端命令都可以直接调用这些工具。 +`ssh ` 只在当前 operation 需要 helper 时才注入 `/tmp/unidesk-ssh-tools`,普通 `argv`、`script`、`kubectl`、`logs` 等路径不得传输无关工具源码。`apply-patch` 只注入 `apply_patch`;`glob` 只注入 `glob`;`skills`/`skill discover` 只注入 `skill-discover`。`apply_patch` 接受标准 `*** Begin Patch` / `*** End Patch` patch 格式,便于通过 SSH 透传编辑远端仓库文件;远端存在 `perl` 时必须走快速精确匹配路径,避免大文件 hunk 被 sh 模式匹配拖成几十秒,缺少 `perl` 时才退回 sh-only 实现。`glob` 和 `skill-discover` 需要远端 `python3`。注入工具只写 `/tmp/unidesk-ssh-tools`,不修改目标仓库。 `apply_patch` 默认拒绝低上下文 update hunk:空搜索/纯插入无锚点、只在插入点前有上下文而没有插入点后上下文、或同一 hunk search 在目标文件中匹配多个位置时,都会结构化失败并提示补充上下文。成功应用时每个 hunk 会在 stderr 输出 `apply_patch: hunk N matched path:line`,用于复核实际落点;只有人工确认确实需要文件开头插入、重复上下文或其他模糊改写时,才允许给 `apply-patch --allow-loose`。 diff --git a/scripts/src/remote.ts b/scripts/src/remote.ts index d2b4df04..8d397e2b 100644 --- a/scripts/src/remote.ts +++ b/scripts/src/remote.ts @@ -895,7 +895,7 @@ async function runRemoteSshWebSocket( const openTimeoutMs = Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000)); const payload = { providerId: invocation.providerId, - command: wrapSshRemoteCommand(parsed.remoteCommand), + command: wrapSshRemoteCommand(parsed.remoteCommand, parsed.requiredHelpers), cwd: invocation.route.plane === "host" ? invocation.route.workspace ?? undefined : undefined, tty: parsed.remoteCommand === null, stdinEotOnEnd: parsed.remoteCommand !== null, @@ -1033,7 +1033,7 @@ export function remoteSshFrontendPlanForTest(target: string, args: string[]): Re providerId: invocation.providerId, route: invocation.route, remoteCommand: invocation.parsed.remoteCommand, - wrappedRemoteCommand: wrapSshRemoteCommand(invocation.parsed.remoteCommand), + wrappedRemoteCommand: wrapSshRemoteCommand(invocation.parsed.remoteCommand, invocation.parsed.requiredHelpers), requiresStdin: invocation.parsed.requiresStdin, invocationKind: invocation.parsed.invocationKind, payloadCwd: invocation.route.plane === "host" ? invocation.route.workspace : null, diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 77b75264..dcb5651e 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -7,9 +7,11 @@ export interface ParsedSshArgs { invocationKind: SshInvocationKind; stdinPrefix?: string; stdinSuffix?: string; + requiredHelpers?: SshHelperName[]; } export type SshInvocationKind = "interactive" | "argv" | "helper" | "ssh-like"; +export type SshHelperName = "apply_patch" | "glob" | "skill-discover"; export interface ParsedSshRoute { providerId: string; @@ -104,6 +106,65 @@ line_number_for_prefix() { printf '%s\n' $((newline_count + 1)) } +replace_once_with_perl() { + command -v perl >/dev/null 2>&1 || return 127 + perl -0777 -e ' +use strict; +use warnings; + +sub fail { + print STDERR "apply_patch: ", @_, "\n"; + exit 1; +} + +sub read_all { + my ($path, $label) = @_; + open my $fh, "<", $path or fail("failed to read $label"); + binmode $fh; + local $/; + my $data = <$fh>; + close $fh; + return defined $data ? $data : ""; +} + +my ($target, $search_file, $replace_file, $hunk_id, $allow_loose, $out) = @ARGV; +my $old = read_all($target, $target); +my $search = read_all($search_file, "hunk search"); +my $replacement = read_all($replace_file, "hunk replacement"); +my $new; + +if ($search eq "") { + fail("hunk $hunk_id in $target has no context; add unchanged/deleted anchor lines or pass --allow-loose after manual review") if $allow_loose ne "1"; + print STDERR "apply_patch: hunk $hunk_id matched $target:1 (loose)\n"; + $new = $replacement . $old; +} else { + my $pos = -1; + my $offset = 0; + my $count = 0; + while (1) { + my $found = index($old, $search, $offset); + last if $found < 0; + $pos = $found if $count == 0; + $count += 1; + last if $count > 1 && $allow_loose ne "1"; + $offset = $found + length($search); + } + fail("hunk $hunk_id context not found in $target") if $count == 0; + fail("hunk $hunk_id context matched multiple locations in $target; add more unchanged context or pass --allow-loose after manual review") if $count > 1 && $allow_loose ne "1"; + my $prefix = substr($old, 0, $pos); + my $suffix = substr($old, $pos + length($search)); + my $line = ($prefix =~ tr/\n//) + 1; + print STDERR "apply_patch: hunk $hunk_id matched $target:$line\n"; + $new = $prefix . $replacement . $suffix; +} + +open my $ofh, ">", $out or fail("failed to render patched file"); +binmode $ofh; +print $ofh $new or fail("failed to render patched file"); +close $ofh or fail("failed to render patched file"); +' "$@" +} + replace_once() { target=$1 search_file=$2 @@ -111,6 +172,17 @@ replace_once() { hunk_id=$4 [ -e "$target" ] || die "file not found: $target" + fast_out=$(mk_temp) + if replace_once_with_perl "$target" "$search_file" "$replace_file" "$hunk_id" "$allow_loose" "$fast_out"; then + write_file "$target" "$fast_out" + rm -f "$fast_out" + return 0 + else + status=$? + fi + rm -f "$fast_out" + [ "$status" = 127 ] || exit "$status" + marker="__UNIDESK_APPLY_PATCH_EOF_$$__" old=$(cat "$target"; printf '%s' "$marker") || die "failed to read $target" old=${"$"}{old%"$marker"} @@ -707,11 +779,11 @@ export function parseSshArgs(args: string[]): ParsedSshArgs { const subcommand = args[0] ?? ""; if (isSshSkillDiscoveryArgs(args)) { const toolArgs = subcommand === "skill" ? ["skill-discover", ...args.slice(2)] : ["skill-discover", ...args.slice(1)]; - return { remoteCommand: shellArgv(toolArgs), requiresStdin: false, invocationKind: "helper" }; + return { remoteCommand: shellArgv(toolArgs), requiresStdin: false, invocationKind: "helper", requiredHelpers: ["skill-discover"] }; } if (subcommand === "apply-patch" || subcommand === "patch") { const toolArgs = ["apply_patch", ...args.slice(1)]; - return { remoteCommand: shellArgv(toolArgs), requiresStdin: true, invocationKind: "helper" }; + return { remoteCommand: shellArgv(toolArgs), requiresStdin: true, invocationKind: "helper", requiredHelpers: ["apply_patch"] }; } if (subcommand === "py") { return { remoteCommand: buildPythonStdinCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" }; @@ -728,7 +800,7 @@ export function parseSshArgs(args: string[]): ParsedSshArgs { return { remoteCommand: buildFindCommand(args.slice(1)), requiresStdin: false, invocationKind: "helper" }; } if (subcommand === "glob") { - return { remoteCommand: shellArgv(["glob", ...args.slice(1)]), requiresStdin: false, invocationKind: "helper" }; + return { remoteCommand: shellArgv(["glob", ...args.slice(1)]), requiresStdin: false, invocationKind: "helper", requiredHelpers: ["glob"] }; } if (subcommand === "k3s") { throw new Error("ssh k3s shorthand is unsupported; put k3s in the route, for example: ssh D601:k3s kubectl get nodes"); @@ -1412,27 +1484,34 @@ function buildPythonStdinCommand(args: string[]): string { ].join("; "); } -function remoteToolBootstrapCommand(): string { - const encodedApplyPatch = Buffer.from(remoteApplyPatchSource, "utf8").toString("base64"); - const encodedGlob = Buffer.from(remoteGlobSource, "utf8").toString("base64"); - const encodedSkillDiscover = Buffer.from(remoteSkillDiscoverSource, "utf8").toString("base64"); - return [ +const remoteToolSources: Record = { + apply_patch: remoteApplyPatchSource, + glob: remoteGlobSource, + "skill-discover": remoteSkillDiscoverSource, +}; + +function remoteToolBootstrapCommand(helpers: readonly SshHelperName[] = []): string { + const uniqueHelpers = [...new Set(helpers)]; + if (uniqueHelpers.length === 0) return ""; + const commands = [ "UNIDESK_SSH_TOOL_DIR=/tmp/unidesk-ssh-tools", 'mkdir -p "$UNIDESK_SSH_TOOL_DIR"', - `printf %s ${shellQuote(encodedApplyPatch)} | base64 -d > "$UNIDESK_SSH_TOOL_DIR/apply_patch"`, - `printf %s ${shellQuote(encodedGlob)} | base64 -d > "$UNIDESK_SSH_TOOL_DIR/glob"`, - `printf %s ${shellQuote(encodedSkillDiscover)} | base64 -d > "$UNIDESK_SSH_TOOL_DIR/skill-discover"`, - 'chmod 700 "$UNIDESK_SSH_TOOL_DIR/apply_patch"', - 'chmod 700 "$UNIDESK_SSH_TOOL_DIR/glob"', - 'chmod 700 "$UNIDESK_SSH_TOOL_DIR/skill-discover"', - 'export PATH="$UNIDESK_SSH_TOOL_DIR:$PATH"', - ].join("; "); + ]; + for (const helper of uniqueHelpers) { + const source = remoteToolSources[helper]; + const encoded = Buffer.from(source, "utf8").toString("base64"); + commands.push(`printf %s ${shellQuote(encoded)} | base64 -d > "$UNIDESK_SSH_TOOL_DIR/${helper}"`); + commands.push(`chmod 700 "$UNIDESK_SSH_TOOL_DIR/${helper}"`); + } + commands.push('export PATH="$UNIDESK_SSH_TOOL_DIR:$PATH"'); + return commands.join("; "); } -export function wrapSshRemoteCommand(command: string | null): string { - const bootstrap = remoteToolBootstrapCommand(); - if (command === null) return `${bootstrap}; exec "\${SHELL:-/bin/bash}" -l`; - return `${bootstrap}; stty -echo 2>/dev/null || true; ${command}`; +export function wrapSshRemoteCommand(command: string | null, helpers: readonly SshHelperName[] = []): string { + const bootstrap = remoteToolBootstrapCommand(helpers); + const prefix = bootstrap.length > 0 ? `${bootstrap}; ` : ""; + if (command === null) return `${prefix}exec "\${SHELL:-/bin/bash}" -l`; + return `${prefix}stty -echo 2>/dev/null || true; ${command}`; } function safeProviderId(providerId: string): string { @@ -1615,7 +1694,7 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st const openTimeoutMs = Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000)); const payload = { providerId: invocation.providerId, - command: wrapSshRemoteCommand(parsed.remoteCommand), + command: wrapSshRemoteCommand(parsed.remoteCommand, parsed.requiredHelpers), cwd: invocation.route.plane === "host" ? invocation.route.workspace ?? undefined : undefined, tty: parsed.remoteCommand === null, stdinEotOnEnd: parsed.remoteCommand !== null, diff --git a/scripts/ssh-argv-guidance-contract-test.ts b/scripts/ssh-argv-guidance-contract-test.ts index 5b298a52..4b537bd4 100644 --- a/scripts/ssh-argv-guidance-contract-test.ts +++ b/scripts/ssh-argv-guidance-contract-test.ts @@ -126,6 +126,8 @@ export function runSshArgvGuidanceContract(): JsonRecord { const hostApplyPatchLoose = parseSshArgs(["apply-patch", "--allow-loose"]); assertCondition(hostApplyPatchLoose.remoteCommand === "'apply_patch' '--allow-loose'", "host apply-patch must pass --allow-loose as an explicit helper argument", hostApplyPatchLoose); + assertCondition(hostApplyPatchLoose.requiredHelpers?.length === 1 && hostApplyPatchLoose.requiredHelpers.includes("apply_patch"), "host apply-patch must request only the apply_patch helper bootstrap", hostApplyPatchLoose); + assertCondition(remoteApplyPatchSource.includes("replace_once_with_perl") && remoteApplyPatchSource.includes("perl -0777"), "apply_patch helper must keep a fast path for large files", {}); const safePatch = applyPatchFixture([], [ "*** Begin Patch", @@ -287,7 +289,11 @@ export function runSshArgvGuidanceContract(): JsonRecord { assertCondition(frontendRemoteK3sPlan.transport === "frontend-websocket", "remote frontend ssh must use the streaming websocket bridge", frontendRemoteK3sPlan); assertCondition(frontendRemoteK3sPlan.providerId === "D601", "remote frontend ssh must dispatch route target to the provider id", frontendRemoteK3sPlan); assertCondition(frontendRemoteK3sPlan.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'get' 'nodes' '-o' 'name'", "remote frontend ssh must preserve k3s route command construction", frontendRemoteK3sPlan); - assertCondition(String(frontendRemoteK3sPlan.wrappedRemoteCommand ?? "").includes("UNIDESK_SSH_TOOL_DIR=/tmp/unidesk-ssh-tools"), "remote frontend ssh must use the same remote tool bootstrap as local ssh", frontendRemoteK3sPlan); + assertCondition(!String(frontendRemoteK3sPlan.wrappedRemoteCommand ?? "").includes("UNIDESK_SSH_TOOL_DIR=/tmp/unidesk-ssh-tools"), "remote frontend ssh must not bootstrap helper tools for plain kubectl argv", frontendRemoteK3sPlan); + + const frontendRemoteHostPatchPlan = remoteSshFrontendPlanForTest("D601", ["apply-patch"]); + assertCondition(String(frontendRemoteHostPatchPlan.wrappedRemoteCommand ?? "").includes("UNIDESK_SSH_TOOL_DIR=/tmp/unidesk-ssh-tools"), "host apply-patch must bootstrap the remote apply_patch helper", frontendRemoteHostPatchPlan); + assertCondition(String(frontendRemoteHostPatchPlan.wrappedRemoteCommand ?? "").includes("/apply_patch") && !String(frontendRemoteHostPatchPlan.wrappedRemoteCommand ?? "").includes("/glob") && !String(frontendRemoteHostPatchPlan.wrappedRemoteCommand ?? "").includes("/skill-discover"), "host apply-patch must not bootstrap unrelated helper tools", frontendRemoteHostPatchPlan); const frontendRemotePodArgvPlan = remoteSshFrontendPlanForTest("G14:k3s:unidesk:code-queue", ["argv", "sh", "-c", "command -v tran"]); assertCondition(frontendRemotePodArgvPlan.providerId === "G14", "remote frontend pod route must dispatch through G14 provider", frontendRemotePodArgvPlan); @@ -336,6 +342,8 @@ export function runSshArgvGuidanceContract(): JsonRecord { "help text documents stdin script passthrough and UNIDESK_SSH_HINT", "provider triage recommendedCrossChecks keeps ssh D601 argv true", "remote frontend ssh uses the same structured route parser for host, k3s and pod argv routes", + "ssh helper bootstrap is lazy so plain argv/script commands do not transfer helper sources", + "host apply-patch bootstraps only the apply_patch helper and uses a Perl fast path for large files", "remote frontend ssh uses authenticated /ws/ssh streaming instead of host.ssh dispatch task polling", "Code Queue runner image installs the tran wrapper and runner tran auto-selects remote frontend transport", "Code Queue runner remote frontend HTTP uses curl by default for non-ssh API calls to avoid Bun response-body native crashes",