diff --git a/.agents/skills/unidesk-trans/SKILL.md b/.agents/skills/unidesk-trans/SKILL.md index 382db07e..330962c1 100644 --- a/.agents/skills/unidesk-trans/SKILL.md +++ b/.agents/skills/unidesk-trans/SKILL.md @@ -7,6 +7,8 @@ description: UniDesk SSH 透传与 apply-patch 语法 — `trans ROUTE OPERATION 分布式 SSH 透传入口。`trans ` 中 route 只定位目标,后续 token 都属于 operation parser。 +遵循 `Skill(cli-spec)`:无输出、超时伪装成无匹配和 argv 边界丢失均视为 CLI 契约故障。 + ## 高频 route ```bash @@ -32,7 +34,11 @@ Host workspace、k3s、Windows、GitHub issue/PR route 见 [references/routes.md - 普通 trans/ssh 短连接硬预算 60s;长 CI/CD、trace、logs、build、硬件流程必须 submit-and-poll。 - `/mnt/` WSL host workspace 由 root 透传执行时,CLI 自动把 workspace owner uid 桥接到进程级 `SUDO_UID`,让 Git 信任 Windows 挂载目录而不写全局 `safe.directory`;非 Git 命令和普通 Linux workspace 不改变所有权信任。 - Windows route 的 `win` 是 route plane,operation 直接写 `ps`、`cmd`、`git` 或只读 fs 操作 `pwd|ls|cat|head|tail|stat|wc|rg`;不要写成 `trans D601:win/... win ps`,也不要把 POSIX shell 当 Windows shell。 +- Windows `rg` 调用 Windows PATH 中的原生 `rg.exe`,支持 `--files`、`-g|--glob`、`--max-count`、`--max-files` 和 `--timeout-ms`;stderr 固定输出 `UNIDESK_WINDOWS_RG_SUMMARY`,其中包含 `matched`、`fileCount`、`elapsedMs` 和 `timeout`,默认预算来自 `config/unidesk-cli.yaml#trans.windowsFs.rg`。缺少 `rg.exe` 时明确返回 127 和安装/PowerShell 替代提示。 +- Windows `wc` 支持 `-l|--lines`、`-w|--words`、`-m|--chars` 和 `-c|--bytes`;`skills` 支持 `--name <精确名称>` 与 `--filter <文本>`,单个 skill 查询不应先拉取全量列表。 +- Direct argv 会保留调用端已经形成的每个 argv token,包括空格和中文;需要 shell 语法时仍显式使用 `sh`、`bash`、Windows `cmd` 或 `ps`。 - Windows workspace 中依赖 Windows PATH、解释器或工具链的命令优先走 `:win// cmd|ps`;不得因同一 WSL provider 的 host/WSL plane 缺少 `python`、编译器或其他命令,就判断 Windows plane 也未安装。Windows Python 例如 `trans G14-WSL:win/d/Work/CONSTAR_workspace cmd python --version`。 +- `/mnt/` workspace 的命令返回 `command not found` 时,CLI 会输出对应 `:win//... cmd` 的 `UNIDESK_SSH_HINT`;该提示只建议核对 Windows plane,不改变原命令退出码。 - Windows `git` helper 拒绝包含 `%` format 等需 shell review 的复杂参数时,按错误提示改用 `trans :win/... ps ''`,不要反复改写 direct `git` argv 绕过校验。 - 从本地 Bash 调用一行 Windows `ps` 时,PowerShell 表达式外层使用单引号,保护 `$`、`$_` 和 `$_.Property` 不被本地 shell 提前展开;复杂或多行 PowerShell 改用 `ps <<'PS'` heredoc。 - 扩展 Windows helper 时保持 operation-scoped PowerShell payload;不要把多操作大脚本塞进 single `EncodedCommand`。 diff --git a/config/unidesk-cli.yaml b/config/unidesk-cli.yaml index ab63ea57..d8049f65 100644 --- a/config/unidesk-cli.yaml +++ b/config/unidesk-cli.yaml @@ -20,6 +20,13 @@ github: initialDelayMs: 1000 maxDelayMs: 16000 factor: 2 +trans: + windowsFs: + rg: + timeoutMs: 10000 + maxFiles: 5000 + maxMatches: 1000 + maxBytesPerFile: 1048576 gc: targetUsePercent: null fileLogs: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index d77e3787..24e0c606 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -413,7 +413,7 @@ trans D601 glob --root /home/ubuntu/pikapython --pattern '**/*-test.cpp' --limit `ssh` 的 route 语法是 `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`。第一个 argv token 只负责定位分布式目标,不表达操作;第一个 token 后面的所有 token 才进入 operation 解析器。Host workspace route 使用 `:/absolute/workspace`,例如 `D601:/home/ubuntu/workspace/hwlab-dev`,CLI 会把该路径作为远端 cwd 传给 Host SSH 维护桥,后续 `pwd`、`git`、`sh`、`bash`、`apply-patch` 和旧 `apply-patch-v1` fallback 等操作仍按同一套 operation parser 执行。`:host:/absolute/workspace` 是等价长写法;workspace 必须是绝对路径,远端是否存在由维护桥实际 `cd` 失败或成功证明。显式 host workspace 进入失败必须返回 `UNIDESK_SSH_CWD_FAILED` 和非零退出,不得静默落回默认目录;完整 provider-gateway 语义见 `docs/reference/provider-gateway.md`。 -当前稳定 plane 包括 `win` 和 `k3s`。`win` plane 的 operation 是 Windows 操作,不是 POSIX shell 别名:`:win ps` 在 WSL provider 上启动 Windows PowerShell,stdin heredoc 会被写入临时 `.ps1` 后执行;`:win cmd` 启动 Windows host 的 `cmd.exe`,stdin heredoc 会被写入临时 `.cmd` 后执行;`:win skills` 发现 Windows skill 目录。需要 Windows 当前目录时使用 slash 路由 `:win//`,例如 `D601:win/c/test ps` 会先在 PowerShell 内 `Set-Location -LiteralPath 'C:\test'`,`D601:win/c/test cmd cd` 会先在 cmd 内执行 `cd /d "C:\test"`。`D601:win/c/test git ...` 是 cmd convenience wrapper,覆盖 `status`、`diff`、非交互 `commit -m ...` 等常规 argv 形态;会打开编辑器或需要 shell 审阅的 git 命令仍用 `ps` 或 `cmd` 包装。`win32` 不是合法 plane,调用者必须改用 `win`。 +当前稳定 plane 包括 `win` 和 `k3s`。`win` plane 的 operation 是 Windows 操作,不是 POSIX shell 别名:`:win ps` 在 WSL provider 上启动 Windows PowerShell,stdin heredoc 会被写入临时 `.ps1` 后执行;`:win cmd` 启动 Windows host 的 `cmd.exe`,stdin heredoc 会被写入临时 `.cmd` 后执行;`:win skills` 发现 Windows skill 目录。需要 Windows 当前目录时使用 slash 路由 `:win//`,例如 `D601:win/c/test ps` 会先在 PowerShell 内 `Set-Location -LiteralPath 'C:\test'`,`D601:win/c/test cmd cd` 会先在 cmd 内执行 `cd /d "C:\test"`。`cmd`、`git` 和 host direct argv 会逐 token 保留调用端已经形成的参数边界,包括空格与中文;会打开编辑器或需要 shell 审阅的命令仍用 `ps` 或 `cmd` heredoc 包装。`win32` 不是合法 plane,调用者必须改用 `win`。 Windows workspace 中依赖 Windows PATH、解释器、SDK 或编译工具链的操作必须优先在 `win` plane 执行,而不是先进入同一 provider 的 WSL `/mnt/` host workspace。WSL/host plane 与 Windows plane 是两套命令环境:前者出现 `python: command not found` 只证明 WSL PATH 中没有该命令,禁止据此判断 Windows 未安装 Python。Windows Python 的最小探测入口是 `trans G14-WSL:win/d/Work/CONSTAR_workspace cmd python --version`;需要 PowerShell 变量、管道或多步逻辑时改用同一路由的 `ps`。 @@ -426,7 +426,9 @@ Get-ChildItem -LiteralPath 'F:\Work' -Directory -Filter '*HWLAB*' | Select-Objec PS ``` -`:win skills [--scope agents|codex|all] [--limit N]` 是 Windows 用户 skill 发现入口,默认只读取当前 Windows 用户的 `%USERPROFILE%\.agents\skills`,输出 JSON 中包含 `roots`、`counts` 和每个 skill 的 `name`、`path`、`skillFile`、`description`。需要同时检查 `%USERPROFILE%\.codex\skills` 时显式加 `--scope all`;不要为了列 skill 手写 `cmd dir` 或宽泛扫描整个用户目录。 +`:win skills [--scope agents|codex|all] [--limit N] [--name <精确名称>|--filter <文本>]` 是 Windows 用户 skill 发现入口,默认只读取当前 Windows 用户的 `%USERPROFILE%\.agents\skills`,输出 JSON 中包含 `roots`、`counts`、`query` 和每个 skill 的 `name`、`path`、`skillFile`、`description`。查单个 skill 时优先 `--name`,模糊查找时使用 `--filter`,避免全量结果触发 stdout dump;需要同时检查 `%USERPROFILE%\.codex\skills` 时显式加 `--scope all`。 + +`:win// rg` 是有界 Windows 文件搜索入口,调用 Windows PATH 中的原生 `rg.exe`,支持 `--files`、`-g|--glob`、`--max-count`、`--max-files` 和 `--timeout-ms`。每次搜索都在 stderr 输出 `UNIDESK_WINDOWS_RG_SUMMARY`,稳定披露 `matched`、`fileCount`、`elapsedMs`、`timeout` 和截断状态;默认预算由 `config/unidesk-cli.yaml#trans.windowsFs.rg` 唯一控制。超时返回 124,无匹配返回 1,缺少 `rg.exe` 返回 127 和可执行替代提示,均不会以空输出伪装成功。Windows `wc` 同时支持 `-l|--lines`、`-w|--words`、`-m|--chars` 与 `-c|--bytes`。 `D601:k3s` 或 `G14:k3s` 定位到对应 provider 的原生 k3s 控制面;`:k3s::[:container]` 定位到 namespace 下的一个默认 deployment workload;若目标是具体 Pod,标准 workload 段写成 `pod:`,例如 `D601:k3s:hwlab-v03:pod:hwlab-cloud-api-abc:hwlab-cloud-api`。`pod/` 不是合法 route 写法,因为 `:` 是分布式路由分隔符,`/` 只表示目标容器里的文件系统 cwd;如果调用者写成 `pod//`,CLI 必须在连接运行面前报错并提示改用 `pod::` 或 `--container `。若目标是 Deployment,也可以显式写 `deployment:` 或简写 ``;容器选择写 `:` 或 operation 参数 `--container `。pod 内 cwd 推荐用 operation 参数 `--cwd /path`,例如 `D601:k3s:hwlab-dev:hwlab-cloud-api:hwlab-cloud-api exec --cwd /app -- pwd`。`kubectl`、`logs`、`sh`、`bash`、`apply-patch`、旧 `apply-patch-v1` fallback、`exec` 和普通容器命令都是 route 后面的 operation,这样路由子模块和操作子模块可以独立扩展。 @@ -467,7 +469,7 @@ PATCH `logs` operation 默认是有界读取;`--follow`/`-f` 会被拒绝,防止 CLI 长时间占用维护桥。目标 route 后面直接跟普通命令时,CLI 会把 argv 放到 `kubectl exec --` 后;显式 `exec` operation 可用于让命令边界更清晰。`exec --stdin -- ...` 是 workload route 的通用 stdin 流入口,适合把 tar、patch 以外的任意字节流直接送进容器命令;operation 选项必须放在 `--` 前,容器命令从 `--` 后开始。需要 shell 语法时优先改用 `sh` 或 `bash` operation,把脚本走 stdin,而不是把 `kubectl exec ... -- sh -c ...` 放进远端命令字符串。pod 内文本热修默认使用 workspace route 加 `apply-patch`,不要求目标容器自带 `python3`、`node` 或仓库里的工具脚本;旧 `apply-patch-v1` operation 仍使用同一个 sh helper,只作为显式 legacy fallback,不用于二进制改写。 -`trans argv [args...]` 是通用 argv 安全拼接入口;`exec` 是同义入口。它是非交互远端单进程命令的默认成功路径,不需要 shell 管道时直接传命令和参数,例如 `trans D601 argv true`。需要管道、重定向、变量展开或多条命令时,优先改用 `trans sh <<'SH'`。`apply-patch`、`find`、`glob` 和旧 `apply-patch-v1` fallback 有专用入口;`git`、`rg`、`grep`、`sed`、`nl`、`stat`、`du`、`ls`、`cat`、`head`、`tail`、`wc` 和 `pwd` 可以直接作为 `trans` 子命令使用,CLI 会对每个 argv token 做 shell quoting。旧的自由 ssh-like 远端命令入口只保留为近似原生 ssh 的人工兼容路径。 +`trans argv [args...]` 是通用 argv 安全拼接入口;`exec` 是同义入口。它是非交互远端单进程命令的默认成功路径,不需要 shell 管道时直接传命令和参数,例如 `trans D601 argv true`。直接写未列入专用 helper 的单进程命令时也会逐 token shell quote,不再把已形成的多词参数重新按空格拆分。需要管道、重定向、变量展开或多条命令时,优先改用 `trans sh <<'SH'`。`apply-patch`、`find`、`glob` 和旧 `apply-patch-v1` fallback 有专用入口;`git`、`rg`、`grep`、`sed`、`nl`、`stat`、`du`、`ls`、`cat`、`head`、`tail`、`wc` 和 `pwd` 可以直接作为 `trans` 子命令使用,CLI 会对每个 argv token 做 shell quoting。`/mnt/` workspace 上的命令若以 127 和 `command not found` 失败,stderr 会给出同 provider 对应 `:win//... cmd` 的 `UNIDESK_SSH_HINT`,用于核对 Windows PATH;原退出码保持不变。 通过 `trans ` 执行多行脚本时,优先使用结构化 helper,例如 `trans G14 py < script.py`、`trans G14 sh <<'SH'` 或 `trans G14:k3s sh <<'SH'`。不要在远端命令字符串里再嵌套 heredoc、复杂引号或 `ssh 'python3 - < { + test("preserves backticks, dollar expressions, and heredoc markers byte-for-byte", () => { + const body = [ + "目标合并分支: 不适用", + "", + "`G14` 与 $HOME 保持原样。", + "", + "<<'INNER'", + "markdown `code` ${literal}", + "INNER", + "", + ].join("\n"); + const script = [ + 'import { readMarkdownBodyFileOrStdin } from "./scripts/src/gh/body-input.ts";', + 'process.stdout.write(JSON.stringify(readMarkdownBodyFileOrStdin("-")));', + ].join(" "); + const result = spawnSync(process.execPath, ["-e", script], { + cwd: process.cwd(), + input: body, + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual({ + body, + bodySource: { kind: "stdin", path: "-" }, + }); + }); +}); diff --git a/scripts/src/remote-ssh.ts b/scripts/src/remote-ssh.ts index b2c40500..8cad47fa 100644 --- a/scripts/src/remote-ssh.ts +++ b/scripts/src/remote-ssh.ts @@ -23,6 +23,7 @@ import { sshRuntimeTimeoutHint, sshRuntimeTimeoutMs, sshRuntimeTimingHint, + sshWindowsPlaneHint, wrapSshRemoteCommand, type SshCaptureResult, } from "./ssh"; @@ -390,6 +391,7 @@ async function runRemoteSshWebSocket( transport: "frontend-websocket", }); let timedOut = false; + let stderrTail = ""; const openTimer = setTimeout(() => { if (sessionReady || settled) return; process.stderr.write("unidesk remote frontend ssh bridge timed out waiting for provider session\n"); @@ -428,8 +430,12 @@ async function runRemoteSshWebSocket( if (settled) return; settled = true; restore(); - const hint = timedOut ? null : sshFailureHint(invocation.providerId, parsed, code, ""); + const hint = timedOut ? null : sshFailureHint(invocation.providerId, parsed, code, stderrTail); if (hint !== null) process.stderr.write(formatSshFailureHint(hint)); + if (!timedOut) { + const windowsPlaneHint = sshWindowsPlaneHint(invocation, code, stderrTail); + if (windowsPlaneHint) process.stderr.write(windowsPlaneHint); + } const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({ invocation, transport: "frontend-websocket", @@ -472,6 +478,7 @@ async function runRemoteSshWebSocket( if (message.type === "ssh.data") { const chunk = Buffer.from(String(message.data ?? ""), message.encoding === "base64" ? "base64" : "utf8"); if (message.stream === "stderr") { + stderrTail = (stderrTail + chunk.toString("utf8")).slice(-16_384); if (stderrForwarder === null) { process.stderr.write(chunk); } else { @@ -487,7 +494,9 @@ async function runRemoteSshWebSocket( return; } if (message.type === "ssh.error") { - process.stderr.write(`${String(message.message || "ssh bridge error")}\n`); + const errorText = `${String(message.message || "ssh bridge error")}\n`; + stderrTail = (stderrTail + errorText).slice(-16_384); + process.stderr.write(errorText); exitCode = 255; ws.close(); return; diff --git a/scripts/src/remote.ts b/scripts/src/remote.ts index b052d366..d0b9e26d 100644 --- a/scripts/src/remote.ts +++ b/scripts/src/remote.ts @@ -31,6 +31,7 @@ import { sshRuntimeTimeoutHint, sshRuntimeTimeoutMs, sshRuntimeTimingHint, + sshWindowsPlaneHint, wrapSshRemoteCommand, type SshCaptureResult, } from "./ssh"; @@ -1049,6 +1050,7 @@ async function runRemoteSshWebSocket( transport: "frontend-websocket", }); let timedOut = false; + let stderrTail = ""; const openTimer = setTimeout(() => { if (sessionReady || settled) return; process.stderr.write("unidesk remote frontend ssh bridge timed out waiting for provider session\n"); @@ -1087,8 +1089,12 @@ async function runRemoteSshWebSocket( if (settled) return; settled = true; restore(); - const hint = timedOut ? null : sshFailureHint(invocation.providerId, parsed, code, ""); + const hint = timedOut ? null : sshFailureHint(invocation.providerId, parsed, code, stderrTail); if (hint !== null) process.stderr.write(formatSshFailureHint(hint)); + if (!timedOut) { + const windowsPlaneHint = sshWindowsPlaneHint(invocation, code, stderrTail); + if (windowsPlaneHint) process.stderr.write(windowsPlaneHint); + } const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({ invocation, transport: "frontend-websocket", @@ -1131,6 +1137,7 @@ async function runRemoteSshWebSocket( if (message.type === "ssh.data") { const chunk = Buffer.from(String(message.data ?? ""), message.encoding === "base64" ? "base64" : "utf8"); if (message.stream === "stderr") { + stderrTail = (stderrTail + chunk.toString("utf8")).slice(-16_384); if (stderrForwarder === null) { process.stderr.write(chunk); } else { @@ -1146,7 +1153,9 @@ async function runRemoteSshWebSocket( return; } if (message.type === "ssh.error") { - process.stderr.write(`${String(message.message || "ssh bridge error")}\n`); + const errorText = `${String(message.message || "ssh bridge error")}\n`; + stderrTail = (stderrTail + errorText).slice(-16_384); + process.stderr.write(errorText); exitCode = 255; ws.close(); return; diff --git a/scripts/src/ssh-remote-tools.ts b/scripts/src/ssh-remote-tools.ts new file mode 100644 index 00000000..ad02acc1 --- /dev/null +++ b/scripts/src/ssh-remote-tools.ts @@ -0,0 +1,9 @@ +import { readFileSync } from "node:fs"; + +function loadRemoteTool(name: string): string { + return readFileSync(new URL(`./ssh-remote-tools/${name}`, import.meta.url), "utf8"); +} + +export const remoteApplyPatchSource = loadRemoteTool("apply-patch-v1.sh"); +export const remoteGlobSource = loadRemoteTool("glob.py"); +export const remoteSkillDiscoverSource = loadRemoteTool("skill-discover.py"); diff --git a/scripts/src/ssh-remote-tools/apply-patch-v1.sh b/scripts/src/ssh-remote-tools/apply-patch-v1.sh new file mode 100644 index 00000000..3834547c --- /dev/null +++ b/scripts/src/ssh-remote-tools/apply-patch-v1.sh @@ -0,0 +1,385 @@ +#!/bin/sh +set -eu + +allow_loose=0 + +die() { + printf 'apply_patch: %s\n' "$*" >&2 + exit 1 +} + +mk_temp() { + mktemp "${TMPDIR:-/tmp}/unidesk-apply-patch.XXXXXX" || exit 1 +} + +ensure_parent() { + case "$1" in + */*) + dir=${1%/*} + [ -n "$dir" ] || dir=/ + mkdir -p "$dir" + ;; + esac +} + +read_text_preserve_newlines() { + marker="__UNIDESK_APPLY_PATCH_EOF_$$__" + text=$(cat "$1"; printf '%s' "$marker") || die "failed to read $1" + printf '%s' "${text%"$marker"}" +} + +write_file() { + target=$1 + source=$2 + ensure_parent "$target" + cp "$source" "$target" || die "failed to write $target" +} + +line_number_for_prefix() { + newline_count=$(printf '%s' "$1" | tr -cd '\n' | wc -c | tr -d ' ') + 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 + replace_file=$3 + 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"} + search=$(cat "$search_file"; printf '%s' "$marker") || die "failed to read hunk search" + search=${search%"$marker"} + replacement=$(cat "$replace_file"; printf '%s' "$marker") || die "failed to read hunk replacement" + replacement=${replacement%"$marker"} + + if [ -z "$search" ]; then + [ "$allow_loose" = 1 ] || die "hunk $hunk_id in $target has no context; add unchanged/deleted anchor lines or pass --allow-loose after manual review" + printf 'apply_patch: hunk %s matched %s:1 (loose)\n' "$hunk_id" "$target" >&2 + new=$replacement$old + else + case "$old" in + *"$search"*) + prefix=${old%%"$search"*} + suffix=${old#*"$search"} + case "$suffix" in + *"$search"*) + [ "$allow_loose" = 1 ] || die "hunk $hunk_id context matched multiple locations in $target; add more unchanged context or pass --allow-loose after manual review" + ;; + esac + printf 'apply_patch: hunk %s matched %s:%s\n' "$hunk_id" "$target" "$(line_number_for_prefix "$prefix")" >&2 + new=$prefix$replacement$suffix + ;; + *) + die "hunk $hunk_id context not found in $target" + ;; + esac + fi + + out=$(mk_temp) + printf '%s' "$new" > "$out" || die "failed to render patched file" + write_file "$target" "$out" + rm -f "$out" +} + +apply_update() { + target=$1 + body=$2 + in_hunk=0 + search_file= + replace_file= + changed=0 + hunk_number=0 + has_add=0 + has_delete=0 + seen_add=0 + anchor_before_add=0 + anchor_after_add=0 + explicit_eof=0 + + finish_hunk() { + [ "$in_hunk" = 1 ] || return 0 + if [ "$allow_loose" != 1 ]; then + [ -s "$search_file" ] || die "hunk $hunk_number in $target has no context; add unchanged/deleted anchor lines or pass --allow-loose after manual review" + if [ "$has_add" = 1 ] && [ "$has_delete" = 0 ]; then + if [ "$anchor_before_add" != 1 ] || { [ "$anchor_after_add" != 1 ] && [ "$explicit_eof" != 1 ]; }; then + die "hunk $hunk_number in $target is insert-only without both leading and trailing context; include nearby unchanged lines after the insertion or pass --allow-loose after manual review" + fi + fi + fi + replace_once "$target" "$search_file" "$replace_file" "$hunk_number" + rm -f "$search_file" "$replace_file" + search_file= + replace_file= + in_hunk=0 + changed=1 + } + + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + "*** End of File"*) + continue + ;; + "@@"*) + finish_hunk + search_file=$(mk_temp) + replace_file=$(mk_temp) + hunk_number=$((hunk_number + 1)) + has_add=0 + has_delete=0 + seen_add=0 + anchor_before_add=0 + anchor_after_add=0 + explicit_eof=0 + in_hunk=1 + continue + ;; + esac + [ "$in_hunk" = 1 ] || die "expected hunk header in $target" + case "$line" in + " "*) + text=${line#?} + printf '%s\n' "$text" >> "$search_file" + printf '%s\n' "$text" >> "$replace_file" + if [ "$seen_add" = 1 ]; then anchor_after_add=1; else anchor_before_add=1; fi + ;; + "-"*) + text=${line#?} + printf '%s\n' "$text" >> "$search_file" + has_delete=1 + if [ "$seen_add" = 1 ]; then anchor_after_add=1; else anchor_before_add=1; fi + ;; + "+"*) + text=${line#?} + printf '%s\n' "$text" >> "$replace_file" + has_add=1 + seen_add=1 + ;; + "\\ No newline at end of file") + ;; + "*** End of File"*) + explicit_eof=1 + ;; + *) + die "bad hunk line in $target: $line" + ;; + esac + done < "$body" + + finish_hunk + [ "$changed" = 1 ] || [ -e "$target" ] || die "file not found: $target" +} + +is_top_header() { + case "$1" in + "*** Add File: "*|"*** Update File: "*|"*** Delete File: "*|"*** End Patch") + return 0 + ;; + *) + return 1 + ;; + esac +} + +pushed=0 +pushed_line= +next_patch_line() { + if [ "$pushed" = 1 ]; then + line=$pushed_line + pushed=0 + pushed_line= + return 0 + fi + if IFS= read -r line; then + return 0 + fi + [ -n "${line:-}" ] +} + +push_patch_line() { + pushed_line=$1 + pushed=1 +} + +parse_add_file() { + target=$1 + [ ! -e "$target" ] || die "file already exists: $target" + tmp=$(mk_temp) + while next_patch_line; do + if is_top_header "$line"; then + push_patch_line "$line" + break + fi + case "$line" in + "+"*) + printf '%s\n' "${line#?}" >> "$tmp" + ;; + *) + rm -f "$tmp" + die "add file lines must start with + for $target" + ;; + esac + done + write_file "$target" "$tmp" + rm -f "$tmp" +} + +parse_update_file() { + target=$1 + move_to= + body=$(mk_temp) + if next_patch_line; then + case "$line" in + "*** Move to: "*) + move_to=${line#"*** Move to: "} + ;; + *) + push_patch_line "$line" + ;; + esac + fi + while next_patch_line; do + if is_top_header "$line"; then + push_patch_line "$line" + break + fi + case "$line" in + "*** Move to: "*) + rm -f "$body" + die "move marker must appear before update hunks" + ;; + *) + printf '%s\n' "$line" >> "$body" + ;; + esac + done + if [ -s "$body" ]; then + apply_update "$target" "$body" + elif [ -z "$move_to" ] && [ ! -e "$target" ]; then + rm -f "$body" + die "file not found: $target" + fi + rm -f "$body" + if [ -n "$move_to" ]; then + [ -e "$target" ] || die "file not found: $target" + [ ! -e "$move_to" ] || die "target file already exists: $move_to" + ensure_parent "$move_to" + mv "$target" "$move_to" || die "failed to move $target to $move_to" + fi +} + +main() { + while [ "$#" -gt 0 ]; do + case "$1" in + -h|--help) + printf 'apply_patch: sh-only helper; reads *** Begin Patch format from stdin; --allow-loose bypasses low-context hunk guards\n' + return 0 + ;; + --allow-loose) + allow_loose=1 + shift + ;; + *) + die "unsupported option: $1" + ;; + esac + done + next_patch_line || die "patch must start with *** Begin Patch" + [ "$line" = "*** Begin Patch" ] || die "patch must start with *** Begin Patch" + while next_patch_line; do + case "$line" in + "*** End Patch") + printf 'Done!\n' + return 0 + ;; + "*** Add File: "*) + parse_add_file "${line#"*** Add File: "}" + ;; + "*** Delete File: "*) + target=${line#"*** Delete File: "} + rm -f "$target" || die "failed to delete $target" + ;; + "*** Update File: "*) + parse_update_file "${line#"*** Update File: "}" + ;; + *) + die "unexpected patch line: $line" + ;; + esac + done + die "missing *** End Patch" +} + +main "$@" diff --git a/scripts/src/ssh-remote-tools/glob.py b/scripts/src/ssh-remote-tools/glob.py new file mode 100644 index 00000000..efc782ad --- /dev/null +++ b/scripts/src/ssh-remote-tools/glob.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +import argparse +import glob +import os +import sys + + +def main(): + parser = argparse.ArgumentParser(description="remote glob helper for UniDesk ssh passthrough") + parser.add_argument("patterns", nargs="*", help="glob patterns relative to --root unless absolute") + parser.add_argument("--root", default=".", help="base directory for relative patterns") + parser.add_argument("--pattern", action="append", default=[], help="additional glob pattern") + parser.add_argument("--contains", action="append", default=[], help="match path names containing text") + parser.add_argument("--icontains", action="append", default=[], help="case-insensitive contains match") + parser.add_argument("--type", choices=["any", "f", "d"], default="any", help="filter by any/file/dir") + parser.add_argument("--limit", type=int, default=0, help="maximum number of rows to print") + parser.add_argument("--sort", action="store_true", help="sort output") + parser.add_argument("--absolute", action="store_true", help="print absolute paths") + args = parser.parse_args() + + if args.limit < 0: + print("glob: --limit must be >= 0", file=sys.stderr) + return 2 + + root = os.path.abspath(args.root) + patterns = list(args.patterns) + list(args.pattern) + for text in args.contains: + patterns.append(f"**/*{text}*") + for text in args.icontains: + # Python glob is case-sensitive on Linux, so filter from a broad recursive scan. + patterns.append("**/*") + if not patterns: + patterns = ["*"] + + seen = set() + rows = [] + lowered_contains = [text.lower() for text in args.icontains] + for pattern in patterns: + effective = pattern if os.path.isabs(pattern) else os.path.join(root, pattern) + for path in glob.iglob(effective, recursive=True): + full = os.path.abspath(path) + if full in seen: + continue + if lowered_contains and not any(text in os.path.basename(full).lower() or text in full.lower() for text in lowered_contains): + continue + if args.type == "f" and not os.path.isfile(full): + continue + if args.type == "d" and not os.path.isdir(full): + continue + seen.add(full) + rows.append(full if args.absolute else os.path.relpath(full, root)) + + if args.sort: + rows.sort() + if args.limit > 0: + rows = rows[:args.limit] + for row in rows: + print(row) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/src/ssh-remote-tools/skill-discover.py b/scripts/src/ssh-remote-tools/skill-discover.py new file mode 100644 index 00000000..9ad5d658 --- /dev/null +++ b/scripts/src/ssh-remote-tools/skill-discover.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +import argparse +import getpass +import json +import os +import platform +import socket +import sys +from datetime import datetime, timezone +from pathlib import Path + + +SKIP_PARTS = {"node_modules", ".git", ".state", "logs", "references", "__pycache__"} + + +def is_wsl(): + try: + release = Path("/proc/sys/kernel/osrelease").read_text(errors="ignore").lower() + except Exception: + release = "" + return "microsoft" in release or "wsl" in release or "WSL_INTEROP" in os.environ + + +def to_windows_path(path): + text = str(path) + if text.startswith("/mnt/") and len(text) >= 7 and text[5].isalpha() and text[6] == "/": + drive = text[5].upper() + rest = text[7:].replace("/", "\\") + return drive + ":\\" + rest + return None + + +def read_bounded(path, limit=16384): + try: + data = path.read_bytes()[:limit] + return data.decode("utf-8", errors="replace") + except Exception: + return "" + + +def frontmatter_value(line): + if ":" not in line: + return None + key, value = line.split(":", 1) + return key.strip().lower(), value.strip().strip("\"'") + + +def parse_skill_metadata(skill_md): + text = read_bounded(skill_md) + name = skill_md.parent.name + description = "" + lines = text.splitlines() + if lines and lines[0].strip() == "---": + for line in lines[1:]: + if line.strip() == "---": + break + item = frontmatter_value(line) + if item is None: + continue + key, value = item + if key == "name" and value: + name = value + if key == "description" and value: + description = value + if not description: + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith("---") and not stripped.startswith("#"): + description = stripped + break + return name, description + + +def iter_skill_files(root, max_depth): + try: + iterator = root.rglob("SKILL.md") + for skill_md in iterator: + try: + rel = skill_md.relative_to(root) + except ValueError: + continue + directory_parts = rel.parts[:-1] + if len(directory_parts) == 0 or len(directory_parts) > max_depth: + continue + if any(part in SKIP_PARTS for part in directory_parts): + continue + yield skill_md + except Exception as exc: + raise RuntimeError(str(exc)) from exc + + +def unique_paths(paths): + seen = set() + output = [] + for raw in paths: + path = Path(raw).expanduser() + key = str(path) + if key in seen: + continue + seen.add(key) + output.append(path) + return output + + +def default_wsl_roots(): + home = Path.home() + roots = [home / ".agents" / "skills", home / ".codex" / "skills"] + for raw in ("/root/.agents/skills", "/root/.codex/skills"): + path = Path(raw) + if str(path) not in {str(item) for item in roots}: + roots.append(path) + return roots + + +def default_windows_roots(): + if not is_wsl(): + return [] + users = Path("/mnt/c/Users") + roots = [] + try: + children = list(users.iterdir()) if users.exists() else [] + except Exception: + children = [] + for child in children: + try: + if not child.is_dir(): + continue + except Exception: + continue + lower = child.name.lower() + if lower in {"all users", "default", "default user", "public"}: + continue + roots.append(child / ".agents" / "skills") + roots.append(child / ".codex" / "skills") + return roots + + +def scan_root(scope, root, max_depth): + try: + root_exists = root.exists() + root_error = None + except Exception as exc: + root_exists = False + root_error = str(exc) + record = { + "scope": scope, + "path": str(root), + "windowsPath": to_windows_path(root), + "exists": root_exists, + "skillCount": 0, + "error": root_error, + } + skills = [] + if not record["exists"]: + return record, skills + try: + for skill_md in iter_skill_files(root, max_depth): + name, description = parse_skill_metadata(skill_md) + skill = { + "scope": scope, + "name": name, + "description": description, + "path": str(skill_md.parent), + "skillMd": str(skill_md), + "windowsPath": to_windows_path(skill_md.parent), + "root": str(root), + } + skills.append(skill) + except Exception as exc: + record["error"] = str(exc) + record["skillCount"] = len(skills) + return record, skills + + +def main(): + parser = argparse.ArgumentParser(description="discover WSL/Linux and Windows skill directories from a UniDesk ssh passthrough session") + parser.add_argument("--scope", choices=["all", "wsl", "windows"], default="all", help="which skill roots to scan") + parser.add_argument("--max-depth", type=int, default=4, help="maximum directory depth below each skill root") + parser.add_argument("--limit", type=int, default=0, help="maximum skill rows to return; 0 means unlimited") + parser.add_argument("--root", action="append", default=[], help="extra WSL/Linux skill root") + parser.add_argument("--windows-root", action="append", default=[], help="extra Windows skill root, expressed as /mnt//...") + args = parser.parse_args() + + if args.max_depth <= 0: + print(json.dumps({"ok": False, "error": "--max-depth must be positive"}, ensure_ascii=False)) + return 2 + if args.limit < 0: + print(json.dumps({"ok": False, "error": "--limit must be >= 0"}, ensure_ascii=False)) + return 2 + + roots = [] + if args.scope in ("all", "wsl"): + roots.extend(("wsl", path) for path in default_wsl_roots()) + roots.extend(("wsl", Path(raw).expanduser()) for raw in args.root) + if args.scope in ("all", "windows"): + roots.extend(("windows", path) for path in default_windows_roots()) + roots.extend(("windows", Path(raw).expanduser()) for raw in args.windows_root) + + seen = set() + unique = [] + for scope, path in roots: + key = (scope, str(path)) + if key in seen: + continue + seen.add(key) + unique.append((scope, path)) + + root_records = [] + skills = [] + for scope, root in unique: + record, found = scan_root(scope, root, args.max_depth) + root_records.append(record) + skills.extend(found) + + scope_order = {"wsl": 0, "windows": 1} + skills.sort(key=lambda item: (scope_order.get(str(item["scope"]), 9), str(item["name"]).lower(), str(item["path"]))) + total_before_limit = len(skills) + if args.limit > 0: + skills = skills[:args.limit] + + counts = {"total": len(skills), "totalBeforeLimit": total_before_limit, "wsl": 0, "windows": 0} + for skill in skills: + scope = str(skill["scope"]) + if scope in counts: + counts[scope] += 1 + + payload = { + "ok": True, + "command": "unidesk ssh skills", + "generatedAt": datetime.now(timezone.utc).isoformat(), + "node": { + "hostname": socket.gethostname(), + "user": getpass.getuser(), + "home": str(Path.home()), + "platform": platform.platform(), + "isWsl": is_wsl(), + "python": sys.version.split()[0], + }, + "counts": counts, + "roots": root_records, + "skills": skills, + } + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/src/ssh-runtime.ts b/scripts/src/ssh-runtime.ts new file mode 100644 index 00000000..c4b364fa --- /dev/null +++ b/scripts/src/ssh-runtime.ts @@ -0,0 +1,1543 @@ +import { spawn, spawnSync } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { type UniDeskConfig, repoRoot } from "./config"; +import { + ApplyPatchV2Error, + applyPatchV2RemoteCommand, + decodeApplyPatchV2BulkRead, + formatApplyPatchV2BulkReplacementPayload, + runApplyPatchV2, + type ApplyPatchV2BulkReplacementWritePlan, + type ApplyPatchV2Executor, + type ApplyPatchV2FileSystem, + type ApplyPatchV2RemoteOperation, +} from "./apply-patch-v2"; +import { + isSshFileTransferOperation, + runSshFileTransferOperation, + type SshRemoteCommandExecutor, + type SshRemoteCommandStreamHandlers, +} from "./ssh-file-transfer"; +import { readTransSshBackendConfig, type TransSshBackendConfig } from "./trans-config"; +import { readCliOutputPolicy } from "./output"; +import { readHostK8sPublicHost } from "./host-k8s-config"; +import { + buildWindowsPowerShellInvocation, + effectiveApplyPatchV2Invocation, + normalizeSshOperationArgs, + parseSshInvocation, + powerShellSingleQuote, + safeProviderId, + shellQuote, + sshRoutePayloadCwd, + sshRouteSeparatorCompatibilityHint, + wrapSshRemoteCommand, + type ParsedSshArgs, + type ParsedSshInvocation, + type ParsedSshRoute, + type SshCaptureBackend, + type SshCaptureBackendPlan, + type SshCaptureResult, + type SshFailureHint, + type SshInvocationKind, + type SshRuntimeTimeoutHint, + type SshRuntimeTimingHint, + type SshStdoutTruncationHint, + type SshStreamTruncationSummary, + type SshTcpPoolFailureKind, + type SshTcpPoolHint, + type SshTruncationCompletionSummary, +} from "./ssh"; + +const defaultSshSlowWarningMs = 10_000; +const defaultSshRuntimeTimeoutMs = 60_000; +const maxSshRuntimeTimeoutMs = 60_000; +const defaultSshBackendCoreDetectTimeoutMs = 15_000; +const maxSshBackendCoreDetectTimeoutMs = 60_000; +const minSshStdoutStreamMaxBytes = 4 * 1024; +const maxSshStdoutStreamMaxBytes = 16 * 1024 * 1024; +function classifySshLikeFailure(exitCode: number, stderrText: string): SshFailureHint["trigger"] | null { + const normalized = stderrText.toLowerCase(); + if ( + normalized.includes("kex_exchange_identification") + || normalized.includes("ssh_exchange_identification") + || normalized.includes("connection closed by remote host") + || normalized.includes("connection reset by peer") + || normalized.includes("connection timed out") + || normalized.includes("operation timed out") + || normalized.includes("timed out waiting for provider session") + || normalized.includes("the operation was aborted") + ) { + return "timeout-or-kex"; + } + return exitCode === 255 ? "exit-255" : null; +} + +export function sshFailureHint(providerId: string, parsed: ParsedSshArgs, exitCode: number, stderrText: string): SshFailureHint | null { + if (parsed.invocationKind !== "ssh-like") return null; + const trigger = classifySshLikeFailure(exitCode, stderrText); + if (trigger === null) return null; + const shownProviderId = safeProviderId(providerId); + return { + code: "ssh-like-command-friction", + providerId: shownProviderId, + trigger, + exitCode, + message: "ssh-like remote command failed before proving Host SSH is globally unavailable; prefer structured argv or explicit sh/bash stdin passthrough for non-interactive commands.", + try: `trans ${shownProviderId} sh <<'SH'`, + triage: `bun scripts/cli.ts provider triage ${shownProviderId} --observed-scope ssh --observed-error ''`, + note: "This hint intentionally does not echo the original remote command.", + }; +} + +export function formatSshFailureHint(hint: SshFailureHint): string { + return `UNIDESK_SSH_HINT ${JSON.stringify(hint)}\n`; +} + +export function sshWindowsPlaneHint(invocation: ParsedSshInvocation, exitCode: number, stderrText: string): string { + if (invocation.route.plane !== "host" || exitCode !== 127) return ""; + const workspace = invocation.route.workspace ?? ""; + const match = workspace.match(/^\/mnt\/([a-z])(?:\/(.*))?$/iu); + if (match === null || !/command not found|not found/iu.test(stderrText)) return ""; + const drive = (match[1] ?? "c").toLowerCase(); + const suffix = (match[2] ?? "").replace(/^\/+|\/+$/gu, ""); + const windowsRoute = `${invocation.providerId}:win/${drive}${suffix.length > 0 ? `/${suffix}` : ""}`; + return `UNIDESK_SSH_HINT ${JSON.stringify({ + code: "wsl-command-not-found-check-windows-plane", + providerId: invocation.providerId, + route: invocation.route.raw, + exitCode, + message: "The command was not found on the WSL/host plane; this does not prove it is absent from the same provider's Windows PATH.", + try: `trans ${windowsRoute} cmd ...`, + windowsRoute, + })}\n`; +} + +export function sshSlowWarningThresholdMs(env: NodeJS.ProcessEnv = process.env): number { + const raw = env.UNIDESK_SSH_SLOW_WARNING_MS; + const parsed = raw === undefined ? NaN : Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return defaultSshSlowWarningMs; + return Math.max(1000, Math.trunc(parsed)); +} + +export function sshRuntimeTimingHint(options: { + invocation: ParsedSshInvocation; + transport: SshRuntimeTimingHint["transport"]; + exitCode: number; + startedAtMs: number; + finishedAtMs?: number; + thresholdMs?: number; +}): SshRuntimeTimingHint { + const finishedAtMs = options.finishedAtMs ?? Date.now(); + const thresholdMs = options.thresholdMs ?? sshSlowWarningThresholdMs(); + const elapsedMs = Math.max(0, Math.round(finishedAtMs - options.startedAtMs)); + const elapsedSeconds = Number((elapsedMs / 1000).toFixed(3)); + const slow = elapsedMs > thresholdMs; + const thresholdSeconds = Number((thresholdMs / 1000).toFixed(3)); + return { + code: "ssh-runtime-timing", + level: slow ? "warning" : "info", + providerId: safeProviderId(options.invocation.providerId), + route: options.invocation.route.raw, + transport: options.transport, + invocationKind: options.invocation.parsed.invocationKind, + exitCode: options.exitCode, + elapsedMs, + elapsedSeconds, + thresholdMs, + slow, + message: slow + ? `ssh operation took ${elapsedSeconds}s, above the ${thresholdSeconds}s warning threshold; consider checking provider/session latency, remote command cost, helper bootstrap, or tran/apply-patch optimization before repeating high-frequency operations.` + : `ssh operation completed in ${elapsedSeconds}s.`, + note: "Timing hint is written to stderr and intentionally does not echo the original remote command.", + }; +} + +export function formatSshRuntimeTimingHint(hint: SshRuntimeTimingHint): string { + if (!hint.slow) return ""; + return `UNIDESK_SSH_TIMING ${JSON.stringify(hint)}\n`; +} + +export function sshRuntimeTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { + const raw = env.UNIDESK_SSH_RUNTIME_TIMEOUT_MS ?? env.UNIDESK_TRAN_RUNTIME_TIMEOUT_MS; + const parsed = raw === undefined ? NaN : Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return defaultSshRuntimeTimeoutMs; + return Math.min(maxSshRuntimeTimeoutMs, Math.max(1000, Math.trunc(parsed))); +} + +export function sshRuntimeTimeoutHint(options: { + invocation: ParsedSshInvocation; + transport: SshRuntimeTimeoutHint["transport"]; + timeoutMs: number; +}): SshRuntimeTimeoutHint { + const timeoutSeconds = Number((options.timeoutMs / 1000).toFixed(3)); + return { + code: "ssh-runtime-timeout", + level: "warning", + providerId: safeProviderId(options.invocation.providerId), + route: options.invocation.route.raw, + transport: options.transport, + invocationKind: options.invocation.parsed.invocationKind, + timeoutMs: options.timeoutMs, + timeoutSeconds, + message: `ssh/tran operation exceeded the ${timeoutSeconds}s top-level runtime limit and was disconnected.`, + action: "Use short query plus poll semantics; do not keep tran open waiting for long CI/CD, trace, logs, or build progress.", + note: "Timeout hint is written to stderr and intentionally does not echo the original remote command.", + }; +} + +export function formatSshRuntimeTimeoutHint(hint: SshRuntimeTimeoutHint): string { + return `UNIDESK_SSH_RUNTIME_TIMEOUT ${JSON.stringify(hint)}\n`; +} + +export function sshStdoutStreamMaxBytes(env: NodeJS.ProcessEnv = process.env): number { + const raw = env.UNIDESK_SSH_STDOUT_STREAM_MAX_BYTES ?? env.UNIDESK_TRAN_STDOUT_STREAM_MAX_BYTES; + const parsed = raw === undefined ? NaN : Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return readCliOutputPolicy().maxStdoutBytes; + return Math.min(maxSshStdoutStreamMaxBytes, Math.max(minSshStdoutStreamMaxBytes, Math.trunc(parsed))); +} + +export function sshStderrStreamMaxBytes(env: NodeJS.ProcessEnv = process.env): number { + const raw = env.UNIDESK_SSH_STDERR_STREAM_MAX_BYTES ?? env.UNIDESK_TRAN_STDERR_STREAM_MAX_BYTES; + const parsed = raw === undefined ? NaN : Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return readCliOutputPolicy().maxStdoutBytes; + return Math.min(maxSshStdoutStreamMaxBytes, Math.max(minSshStdoutStreamMaxBytes, Math.trunc(parsed))); +} + +export function sshStdoutTruncationHint(options: { + invocation: ParsedSshInvocation; + transport: SshStdoutTruncationHint["transport"]; + stream?: SshStdoutTruncationHint["stream"]; + thresholdBytes: number; + observedBytesAtTruncation: number; + forwardedBytes?: number; + dumpPath: string | null; + dumpError?: string | null; +}): SshStdoutTruncationHint { + const stream = options.stream ?? "stdout"; + const policy = readCliOutputPolicy(); + return { + code: stream === "stdout" ? "ssh-stdout-truncated" : "ssh-stderr-truncated", + level: "warning", + stream, + providerId: safeProviderId(options.invocation.providerId), + route: options.invocation.route.raw, + transport: options.transport, + invocationKind: options.invocation.parsed.invocationKind, + thresholdBytes: options.thresholdBytes, + observedBytesAtTruncation: options.observedBytesAtTruncation, + forwardedBytes: options.forwardedBytes ?? Math.min(options.thresholdBytes, options.observedBytesAtTruncation), + dumpPath: options.dumpPath, + dumpError: options.dumpError ?? null, + disclosurePolicy: { + name: "unified-cli-dump-preview", + configPath: policy.configPath, + maxPreviewBytes: policy.maxStdoutBytes, + dumpDir: policy.dumpDir, + }, + recommendedRerun: [ + "Use rg -m/--max-count, sed -n, head, tail, --limit, --tail-bytes, or an id-specific query.", + "Use --full, --raw, --tail-bytes, --limit, or UNIDESK_*_STREAM_MAX_BYTES only when you intentionally need a wider one-off disclosure.", + "Read the dumpPath for the complete captured stream.", + ], + message: `ssh ${stream} exceeded the YAML-configured preview budget; ${stream} is bounded and the complete stream is written to a local dump when possible.`, + action: "Inspect dumpPath for the complete stream, or rerun a narrower remote command instead of emitting full logs, huge JSON, or broad search output.", + note: "This hint is written to stderr and intentionally does not echo the original remote command.", + }; +} + +export function formatSshStdoutTruncationHint(hint: SshStdoutTruncationHint): string { + const marker = hint.stream === "stderr" ? "UNIDESK_SSH_STDERR_TRUNCATED" : "UNIDESK_SSH_STDOUT_TRUNCATED"; + return `${marker} ${JSON.stringify(hint)}\n`; +} + +export function sshTruncationCompletionSummary(options: { + invocation: ParsedSshInvocation; + transport: SshTruncationCompletionSummary["transport"]; + exitCode: number; + timedOut: boolean; + startedAtMs: number; + stdout: SshStreamTruncationSummary | null; + stderr: SshStreamTruncationSummary | null; +}): SshTruncationCompletionSummary | null { + if (options.stdout === null && options.stderr === null) return null; + const elapsedMs = Math.max(0, Date.now() - options.startedAtMs); + return { + code: "ssh-truncation-summary", + level: "info", + providerId: safeProviderId(options.invocation.providerId), + route: options.invocation.route.raw, + transport: options.transport, + invocationKind: options.invocation.parsed.invocationKind, + exitCode: options.exitCode, + timedOut: options.timedOut, + elapsedMs, + elapsedSeconds: Math.round(elapsedMs / 100) / 10, + commandOmitted: true, + stdout: options.stdout, + stderr: options.stderr, + message: "SSH output was truncated, but the command has finished; use this completion summary for closeout status and inspect dumpPath only when full output is needed.", + action: "Use exitCode/timedOut plus dumpPath from this summary instead of manually inferring success from a long truncated stream.", + }; +} + +export function formatSshTruncationCompletionSummary(summary: SshTruncationCompletionSummary | null): string { + return summary === null ? "" : `UNIDESK_SSH_TRUNCATION_SUMMARY ${JSON.stringify(summary)}\n`; +} + +export function classifySshTcpPoolFailure(text: string): SshTcpPoolFailureKind | null { + const normalized = text.toLowerCase(); + if (normalized.includes("ssh tcp data channel closed")) return "provider-data-channel-closed"; + if ( + normalized.includes("requested ssh tcp data channel is not ready") + || normalized.includes("ssh tcp data channel is not available") + || normalized.includes('"failurekind":"provider-data-channel-missing"') + ) { + return "provider-data-channel-missing"; + } + if ( + normalized.includes("provider ssh tcp data pool has no idle channel") + || normalized.includes('"failurekind":"provider-data-pool-exhausted"') + ) { + return "provider-data-pool-exhausted"; + } + return null; +} + +export function sshTcpPoolHint(options: { + invocation: ParsedSshInvocation; + transport: SshTcpPoolHint["transport"]; + exitCode: number; + stderrText: string; +}): SshTcpPoolHint | null { + if (options.exitCode === 0) return null; + const failureKind = classifySshTcpPoolFailure(options.stderrText); + if (failureKind === null) return null; + const providerId = safeProviderId(options.invocation.providerId); + return { + code: "ssh-tcp-pool-transient", + level: "warning", + providerId, + route: options.invocation.route.raw, + transport: options.transport, + invocationKind: options.invocation.parsed.invocationKind, + failureKind, + exitCode: options.exitCode, + message: "host.ssh.tcp-pool data channel failed during an SSH operation; classify this as transport/data-pool transient until pool labels and a retry prove otherwise.", + action: "Inspect providerGatewaySshData* labels, then rerun the same idempotent command through the controlled CLI. Do not treat this alone as HWLAB runtime configuration failure.", + retry: `trans ${providerId} argv true`, + diagnostics: { + poolStatus: `bun scripts/cli.ts debug ssh-pool ${providerId}`, + fullHealth: "bun scripts/cli.ts debug health", + }, + note: "Hint is written to stderr and intentionally does not echo the original remote command.", + }; +} + +export function formatSshTcpPoolHint(hint: SshTcpPoolHint | null): string { + return hint === null ? "" : `UNIDESK_SSH_TCP_POOL_HINT ${JSON.stringify(hint)}\n`; +} + +function sshStreamDumpPath(invocation: ParsedSshInvocation, stream: SshStdoutTruncationHint["stream"]): string { + const policy = readCliOutputPolicy(); + mkdirSync(policy.dumpDir, { recursive: true, mode: 0o700 }); + const timestamp = new Date().toISOString().replace(/[:.]/gu, "-"); + const suffix = randomBytes(4).toString("hex"); + const slug = `${invocation.providerId}-${invocation.route.raw}-${invocation.route.plane}` + .replace(/[^A-Za-z0-9._-]+/gu, "-") + .replace(/^-+|-+$/gu, "") + .slice(0, 80) || "ssh"; + return join(policy.dumpDir, `${timestamp}-${process.pid}-${suffix}-${slug}.${stream}.bin`); +} + +export interface SshStreamForwarder { + write: (chunk: Buffer) => string | null; + summary: () => SshStreamTruncationSummary | null; +} + +export function createSshStdoutForwarder(options: { + invocation: ParsedSshInvocation; + transport: SshStdoutTruncationHint["transport"]; + maxBytes?: number; + stdout?: NodeJS.WritableStream; +}): SshStreamForwarder { + return createSshStreamForwarder({ + invocation: options.invocation, + transport: options.transport, + stream: "stdout", + maxBytes: options.maxBytes ?? sshStdoutStreamMaxBytes(), + target: options.stdout ?? process.stdout, + }); +} + +export function createSshStderrForwarder(options: { + invocation: ParsedSshInvocation; + transport: SshStdoutTruncationHint["transport"]; + maxBytes?: number; + stderr?: NodeJS.WritableStream; +}): SshStreamForwarder { + return createSshStreamForwarder({ + invocation: options.invocation, + transport: options.transport, + stream: "stderr", + maxBytes: options.maxBytes ?? sshStderrStreamMaxBytes(), + target: options.stderr ?? process.stderr, + }); +} + +function createSshStreamForwarder(options: { + invocation: ParsedSshInvocation; + transport: SshStdoutTruncationHint["transport"]; + stream: SshStdoutTruncationHint["stream"]; + maxBytes: number; + target: NodeJS.WritableStream; +}): SshStreamForwarder { + let observedBytes = 0; + let forwardedBytes = 0; + let truncated = false; + let dumpPath: string | null = null; + let dumpError: string | null = null; + let lastHint: SshStdoutTruncationHint | null = null; + const bufferedChunks: Buffer[] = []; + + const appendDump = (chunk: Buffer): void => { + if (dumpError !== null) return; + try { + if (dumpPath === null) { + dumpPath = sshStreamDumpPath(options.invocation, options.stream); + writeFileSync(dumpPath, Buffer.alloc(0), { mode: 0o600 }); + for (const buffered of bufferedChunks) appendFileSync(dumpPath, buffered); + bufferedChunks.length = 0; + } + appendFileSync(dumpPath, chunk); + } catch (error) { + dumpError = error instanceof Error ? error.message : String(error); + dumpPath = null; + } + }; + + return { + write(chunk: Buffer): string | null { + observedBytes += chunk.length; + if (!truncated && observedBytes <= options.maxBytes) { + bufferedChunks.push(Buffer.from(chunk)); + options.target.write(chunk); + forwardedBytes += chunk.length; + return null; + } + + if (!truncated) { + truncated = true; + const remaining = Math.max(0, options.maxBytes - forwardedBytes); + if (remaining > 0) { + const forwarded = chunk.subarray(0, remaining); + options.target.write(forwarded); + forwardedBytes += remaining; + } + appendDump(chunk); + lastHint = sshStdoutTruncationHint({ + invocation: options.invocation, + transport: options.transport, + stream: options.stream, + thresholdBytes: options.maxBytes, + observedBytesAtTruncation: observedBytes, + forwardedBytes, + dumpPath, + dumpError, + }); + return formatSshStdoutTruncationHint(lastHint); + } + + appendDump(chunk); + return null; + }, + summary(): SshStreamTruncationSummary | null { + if (lastHint === null) return null; + return { + stream: lastHint.stream, + thresholdBytes: lastHint.thresholdBytes, + observedBytesAtTruncation: lastHint.observedBytesAtTruncation, + forwardedBytes: lastHint.forwardedBytes, + dumpPath, + dumpError, + }; + }, + }; +} + +function brokerSource(): string { + return String.raw` +const open = JSON.parse(process.argv[2] || process.argv[1] || "{}"); +const token = process.env.PROVIDER_TOKEN || process.env.UNIDESK_PROVIDER_TOKEN || ""; +const baseUrl = process.env.UNIDESK_SSH_BROKER_URL || "ws://backend-core:8080/ws/ssh"; +const url = baseUrl + "?token=" + encodeURIComponent(token); +const ws = new WebSocket(url); +let exitCode = 255; +let canSend = false; +let sessionReady = false; +let opened = false; +const pending = []; +const pendingInput = []; +const openTimer = setTimeout(() => { + if (opened) return; + process.stderr.write("unidesk ssh bridge timed out waiting for provider session\n"); + try { ws.close(); } catch {} + process.exit(255); +}, Number(open.openTimeoutMs || 15000)); +const runtimeTimeoutMs = Number(open.runtimeTimeoutMs || 60000); +const runtimeTimeoutMode = open.runtimeTimeoutMode === "inactivity" ? "inactivity" : "wall-clock"; +let runtimeTimer = null; +function armRuntimeTimer() { + if (runtimeTimer !== null) clearTimeout(runtimeTimer); + runtimeTimer = setTimeout(() => { + const noun = runtimeTimeoutMode === "inactivity" ? "inactivity timeout" : "runtime timeout"; + process.stderr.write("unidesk ssh bridge " + noun + "; use short query plus poll semantics for long non-streaming work\n"); + exitCode = 124; + try { ws.close(); } catch {} + setTimeout(() => process.exit(124), 250).unref?.(); + }, runtimeTimeoutMs); +} +function clearRuntimeTimer() { + if (runtimeTimer !== null) clearTimeout(runtimeTimer); + runtimeTimer = null; +} +armRuntimeTimer(); + +function send(value) { + const text = JSON.stringify(value); + if (!canSend || ws.readyState !== WebSocket.OPEN) { + pending.push(text); + return; + } + ws.send(text); +} + +function flush() { + while (pending.length > 0 && ws.readyState === WebSocket.OPEN) { + ws.send(pending.shift()); + } +} + +function sendInput(value) { + const text = JSON.stringify(value); + if (!sessionReady || ws.readyState !== WebSocket.OPEN) { + pendingInput.push(text); + return; + } + ws.send(text); +} + +function flushInput() { + if (!sessionReady || ws.readyState !== WebSocket.OPEN) return; + while (pendingInput.length > 0) { + ws.send(pendingInput.shift()); + } +} + +function decodeData(data) { + return typeof data === "string" ? data : Buffer.from(data).toString("utf8"); +} + +ws.addEventListener("open", () => { + canSend = true; + send({ + type: "ssh.open", + providerId: open.providerId, + command: open.command || undefined, + cwd: open.cwd || undefined, + tty: open.tty === true, + cols: open.cols || 100, + rows: open.rows || 30, + }); + flush(); +}); + +ws.addEventListener("message", (event) => { + const message = JSON.parse(decodeData(event.data)); + if (runtimeTimeoutMode === "inactivity") armRuntimeTimer(); + if (message.type === "ssh.data") { + opened = true; + const chunk = Buffer.from(message.data || "", "base64"); + if (message.stream === "stderr") process.stderr.write(chunk); + else process.stdout.write(chunk); + return; + } + if (message.type === "ssh.opened") { + opened = true; + sessionReady = true; + clearTimeout(openTimer); + if (open.stdinEotOnEnd === true) setTimeout(flushInput, 200); + else flushInput(); + return; + } + if (message.type === "ssh.dispatched") { + return; + } + if (message.type === "ssh.error") { + clearTimeout(openTimer); + clearRuntimeTimer(); + process.stderr.write(String(message.message || "ssh bridge error") + "\n"); + if (message.failureKind || message.providerId || message.dataChannelId || message.dataPool) { + process.stderr.write("UNIDESK_SSH_ERROR " + JSON.stringify({ + failureKind: message.failureKind || null, + providerId: message.providerId || null, + dataChannelId: message.dataChannelId || null, + dataPool: message.dataPool || null, + transport: message.transport || null, + controlFallback: message.controlFallback === true + }) + "\n"); + } + exitCode = 255; + ws.close(); + return; + } + if (message.type === "ssh.exit") { + clearTimeout(openTimer); + clearRuntimeTimer(); + exitCode = Number.isInteger(message.exitCode) ? message.exitCode : 255; + ws.close(); + } +}); + +ws.addEventListener("close", () => { + clearTimeout(openTimer); + clearRuntimeTimer(); + process.exit(exitCode); +}); + +ws.addEventListener("error", () => { + process.stderr.write("unidesk ssh bridge websocket error\n"); + process.exit(255); +}); + +process.stdin.on("data", (chunk) => { + sendInput({ type: "ssh.input", data: Buffer.from(chunk).toString("base64"), encoding: "base64" }); +}); +process.stdin.on("end", () => { + if (open.stdinEotOnEnd === true) { + sendInput({ type: "ssh.input", data: Buffer.from([4]).toString("base64"), encoding: "base64" }); + } + sendInput({ type: "ssh.eof" }); +}); +`; +} + +function terminalSize(): { cols: number; rows: number } { + return { + cols: Number(process.stdout.columns) > 0 ? Number(process.stdout.columns) : 100, + rows: Number(process.stdout.rows) > 0 ? Number(process.stdout.rows) : 30, + }; +} + +export function remoteCommandForRoute(route: ParsedSshRoute, command: string[], options: { stdin?: boolean } = {}): string { + if (route.plane === "k3s") return buildK3sTargetCommand(route, command, options); + if (route.plane === "win") throw new Error(`ssh apply-patch does not support win routes yet: ${route.raw}`); + return shellArgv(command); +} + +type WindowsApplyPatchFsOperation = + | "stat" + | "read-b64-block" + | "read-bulk-b64" + | "apply-replacements-bulk-stdin" + | "write-b64-stdin" + | "write-b64-begin" + | "write-b64-append-stdin" + | "write-b64-commit" + | "delete"; + +const windowsApplyPatchWriteB64ChunkChars = 12_000; +const posixApplyPatchWriteB64ChunkChars = 12_000; + +type PosixApplyPatchFsOperation = Exclude; + +export function createPosixApplyPatchFileSystem(invocation: ParsedSshInvocation, runRemoteCommand: (command: string[], input?: string) => Promise): ApplyPatchV2FileSystem { + async function checked(operation: PosixApplyPatchFsOperation, args: string[], input?: string): Promise { + const startedAtMs = Date.now(); + let result: SshCaptureResult; + try { + result = await runRemoteCommand(applyPatchV2RemoteCommand(operation, args), input); + } catch (error) { + throw new ApplyPatchV2Error(`posix apply-patch fs operation failed: ${operation}`, applyPatchFsFailureDetails({ + operation, + args, + input, + invocation, + remoteElapsedMs: Math.max(0, Date.now() - startedAtMs), + cause: error instanceof Error ? { name: error.name, message: error.message } : { message: String(error) }, + })); + } + if (result.exitCode === 0) return result; + throw new ApplyPatchV2Error(`posix apply-patch fs operation failed: ${operation}`, applyPatchFsFailureDetails({ + operation, + args, + input, + invocation, + result, + remoteElapsedMs: Math.max(0, Date.now() - startedAtMs), + })); + } + + 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(`posix 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 + .split(/\r?\n/u) + .filter((line) => !line.startsWith("UNIDESK_APPLY_PATCH_V2_BLOCK ")) + .join("") + .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, posixApplyPatchWriteB64ChunkChars)) { + await checked("write-b64-append", [filePath, token, chunk]); + } + await checked("write-b64-commit", [filePath, token, expectedBytes, expectedSha256]); + } + }, + async deleteFile(filePath) { + await checked("delete", [filePath]); + }, + async readFiles(filePaths) { + const result = await checked("read-bulk-b64", filePaths); + return decodeApplyPatchV2BulkRead(result.stdout, filePaths); + }, + async applyReplacementsBulk(filePaths, plans: Map) { + const { targets, payload } = formatApplyPatchV2BulkReplacementPayload(filePaths, plans); + if (targets.length === 0) return; + await checked("apply-replacements-bulk-stdin", [String(targets.length)], payload); + }, + }; +} + +export function createWindowsApplyPatchFileSystem(invocation: ParsedSshInvocation, runRemoteCommand: (remoteCommand: string, input?: string) => Promise): ApplyPatchV2FileSystem { + async function checked(operation: WindowsApplyPatchFsOperation, args: string[], input?: string): Promise { + const command = buildWindowsPowerShellInvocation(windowsApplyPatchFsScript(invocation.route.workspace, operation, args)); + const startedAtMs = Date.now(); + let result: SshCaptureResult; + try { + result = await runRemoteCommand(command, input); + } catch (error) { + throw new ApplyPatchV2Error(`windows apply-patch fs operation failed: ${operation}`, applyPatchFsFailureDetails({ + operation, + args, + input, + invocation, + remoteElapsedMs: Math.max(0, Date.now() - startedAtMs), + cause: error instanceof Error ? { name: error.name, message: error.message } : { message: String(error) }, + })); + } + if (result.exitCode === 0) return result; + throw new ApplyPatchV2Error(`windows apply-patch fs operation failed: ${operation}`, applyPatchFsFailureDetails({ + operation, + args, + input, + invocation, + result, + remoteElapsedMs: Math.max(0, Date.now() - startedAtMs), + })); + } + + 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]); + }, + async readFiles(filePaths) { + const result = await checked("read-bulk-b64", [String(filePaths.length)], `${filePaths.join("\n")}\n`); + return decodeApplyPatchV2BulkRead(result.stdout, filePaths); + }, + async applyReplacementsBulk(filePaths, plans: Map) { + const { targets, payload } = formatApplyPatchV2BulkReplacementPayload(filePaths, plans); + if (targets.length === 0) return; + await checked("apply-replacements-bulk-stdin", [String(targets.length)], payload); + }, + }; +} + +function applyPatchFsFailureDetails(options: { + operation: string; + args: string[]; + input?: string; + invocation: ParsedSshInvocation; + result?: SshCaptureResult; + remoteElapsedMs: number; + cause?: Record; +}): Record { + const { operation, args, input, invocation, result } = options; + const inputBytes = input === undefined ? 0 : Buffer.byteLength(input, "utf8"); + const expected = applyPatchFsExpectedWrite(operation, args); + const targetCount = applyPatchFsBulkTargetCount(operation, args); + return { + operation, + route: invocation.route.raw, + ...(operation === "read-bulk-b64" || operation === "apply-replacements-bulk-stdin" ? {} : { filePath: args[0] ?? "" }), + ...(targetCount === null ? {} : { targetCount }), + args: args.slice(0, 4), + inputBytes, + ...(inputBytes === 0 ? {} : { inputSha256: createHash("sha256").update(input, "utf8").digest("hex") }), + ...(expected.expectedBytes === undefined ? {} : { expectedBytes: expected.expectedBytes }), + ...(expected.expectedSha256 === undefined ? {} : { expectedSha256: expected.expectedSha256 }), + remoteElapsedMs: options.remoteElapsedMs, + landed: false, + ...(result === undefined ? {} : { + exitCode: result.exitCode, + stdoutTail: result.stdout.slice(-2000), + stderrTail: result.stderr.slice(-4000), + }), + ...(options.cause === undefined ? {} : { cause: options.cause }), + }; +} + +function applyPatchFsExpectedWrite(operation: string, args: string[]): { expectedBytes?: string; expectedSha256?: string } { + if (operation === "write-b64-stdin") return { expectedBytes: args[1], expectedSha256: args[2] }; + if (operation === "write-b64-commit") return { expectedBytes: args[2], expectedSha256: args[3] }; + return {}; +} + +function applyPatchFsBulkTargetCount(operation: string, args: string[]): number | null { + if (operation === "read-bulk-b64") { + const count = Number(args[0]); + return Number.isSafeInteger(count) && count >= 0 ? count : args.length; + } + if (operation !== "apply-replacements-bulk-stdin") return null; + const count = Number(args[0]); + return Number.isSafeInteger(count) && count >= 0 ? count : null; +} + +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 Get-Sha256Bytes([byte[]]$Bytes) { $sha = [System.Security.Cryptography.SHA256]::Create(); try { return ([System.BitConverter]::ToString($sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() } }", + "function Decode-Utf8([string]$Encoded) { return [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Encoded)) }", + "function Encode-Utf8([string]$Value) { return [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Value)) }", + "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 }", + " 'read-bulk-b64' { $expectedCount = [Int32]$targetArg; $items = @([Console]::In.ReadToEnd() -split \"`r?`n\" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }); if ($items.Count -ne $expectedCount) { Fail ('bulk read record count mismatch: expected=' + $expectedCount + ' actual=' + $items.Count) 23 }; [Console]::Out.WriteLine('UNIDESK_APPLY_PATCH_V2_BULK_READ ' + $items.Count); foreach ($item in $items) { $path = Resolve-UnideskPath $item; if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { Fail ('file not found: ' + $path) 1 }; $bytes = [System.IO.File]::ReadAllBytes($path); $pathB64 = Encode-Utf8 $item; $digest = Get-Sha256Bytes $bytes; [Console]::Out.Write($pathB64 + ' ' + $bytes.Length + ' ' + $digest + ' '); [Console]::Out.Write([System.Convert]::ToBase64String($bytes)); [Console]::Out.WriteLine() }; break }", + " 'apply-replacements-bulk-stdin' { $expectedCount = [Int32]$targetArg; $records = @([Console]::In.ReadToEnd() -split \"`r?`n\" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }); if ($records.Count -ne $expectedCount) { Fail ('bulk replacement record count mismatch: expected=' + $expectedCount + ' actual=' + $records.Count) 23 }; $utf8 = [System.Text.UTF8Encoding]::new($false); $plans = New-Object System.Collections.Generic.List[object]; $index = 0; foreach ($record in $records) { $index += 1; $fields = $record -split '\\s+'; if ($fields.Count -ne 6) { Fail 'bulk replacement malformed record' 23 }; $targetRelative = Decode-Utf8 $fields[0]; $resolved = Resolve-UnideskPath $targetRelative; $originalBytes = [Int64]$fields[1]; $originalSha256 = $fields[2]; $finalBytes = [Int64]$fields[3]; $finalSha256 = $fields[4]; $replacementsText = $fields[5]; if (-not (Test-Path -LiteralPath $resolved -PathType Leaf)) { Fail ('file not found: ' + $resolved) 1 }; $source = [System.IO.File]::ReadAllBytes($resolved); if ($source.Length -ne $originalBytes -or (Get-Sha256Bytes $source) -ne $originalSha256) { Fail ('bulk replacement original integrity mismatch for ' + $resolved) 23 }; $lines = New-Object System.Collections.Generic.List[string]; $sourceText = [System.Text.Encoding]::UTF8.GetString($source); foreach ($line in ($sourceText -split \"`n\", -1)) { $lines.Add($line) | Out-Null }; if ($lines.Count -gt 0 -and $lines[$lines.Count - 1] -eq '') { $lines.RemoveAt($lines.Count - 1) }; $replacements = New-Object System.Collections.Generic.List[object]; if (-not [string]::IsNullOrEmpty($replacementsText)) { foreach ($item in ($replacementsText -split ';')) { if ([string]::IsNullOrWhiteSpace($item)) { continue }; $parts = $item -split ',', 3; if ($parts.Count -ne 3) { Fail 'bulk replacement malformed replacement' 23 }; $newText = Decode-Utf8 $parts[2]; $newLines = New-Object System.Collections.Generic.List[string]; foreach ($line in ($newText -split \"`n\", -1)) { $newLines.Add($line) | Out-Null }; if ($newLines.Count -gt 0 -and $newLines[$newLines.Count - 1] -eq '') { $newLines.RemoveAt($newLines.Count - 1) }; $replacements.Add([pscustomobject]@{ start = [Int32]$parts[0]; oldLength = [Int32]$parts[1]; newLines = [string[]]$newLines }) | Out-Null } }; foreach ($replacement in @($replacements | Sort-Object -Property start -Descending)) { if ($replacement.start -lt 0 -or $replacement.oldLength -lt 0 -or ($replacement.start + $replacement.oldLength) -gt $lines.Count) { Fail ('bulk replacement out of bounds for ' + $resolved) 23 }; $removeCount = $replacement.oldLength; if ($removeCount -gt 0) { $lines.RemoveRange($replacement.start, $removeCount) }; if ($replacement.newLines.Count -gt 0) { $lines.InsertRange($replacement.start, [string[]]$replacement.newLines) } }; $outputText = if ($lines.Count -eq 0) { '' } else { ([string]::Join(\"`n\", [string[]]$lines) + \"`n\") }; $outputBytes = [System.Text.Encoding]::UTF8.GetBytes($outputText); if ($outputBytes.Length -ne $finalBytes -or (Get-Sha256Bytes $outputBytes) -ne $finalSha256) { Fail ('bulk replacement final integrity mismatch for ' + $resolved) 23 }; $tmp = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($resolved), ('.' + [System.IO.Path]::GetFileName($resolved) + '.unidesk-v2-bulk-' + [guid]::NewGuid().ToString('N') + '.tmp')); [System.IO.File]::WriteAllText($tmp, $outputText, $utf8); $plans.Add([pscustomobject]@{ target = $resolved; tmp = $tmp; sha256 = $finalSha256 }) | Out-Null }; foreach ($plan in $plans) { Ensure-Parent $plan.target; Move-Item -LiteralPath $plan.tmp -Destination $plan.target -Force; if ((Get-Sha256 $plan.target) -ne $plan.sha256) { Fail ('bulk replacement final sha256 mismatch for ' + $plan.target) 25 } }; 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); +} + +async function runSshCaptureRemoteCommand(config: UniDeskConfig, invocation: ParsedSshInvocation, remoteCommand: string, input?: string): Promise { + const startedAtMs = Date.now(); + const size = terminalSize(); + const runtimeTimeoutMs = sshRuntimeTimeoutMs(); + const payload = { + providerId: invocation.providerId, + command: wrapSshRemoteCommand(remoteCommand), + cwd: sshRoutePayloadCwd(invocation.route), + tty: false, + stdinEotOnEnd: false, + openTimeoutMs: Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000)), + runtimeTimeoutMs, + cols: size.cols, + rows: size.rows, + }; + const payloadJson = JSON.stringify(payload); + const encodedBrokerSource = Buffer.from(brokerSource(), "utf8").toString("base64"); + const script = [ + "set -eu", + `payload=${shellQuote(payloadJson)}`, + "if command -v backend-core >/dev/null 2>&1; then", + ' exec backend-core --ssh-broker "$payload"', + "fi", + `export UNIDESK_SSH_BROKER_URL=${shellQuote("ws://127.0.0.1:8080/ws/ssh")}`, + 'broker_js="$(mktemp "${TMPDIR:-/tmp}/unidesk-ssh-broker.XXXXXX")"', + 'trap \'rm -f "$broker_js"\' EXIT', + `printf %s ${shellQuote(encodedBrokerSource)} | base64 -d >"$broker_js"`, + 'bun "$broker_js" "$payload"', + ].join("\n"); + const child = spawn("docker", ["exec", "-i", "unidesk-backend-core", "sh", "-c", script], { + cwd: repoRoot, + stdio: ["pipe", "pipe", "pipe"], + }); + if (input !== undefined) { + writeChunkedStdin(child.stdin, input); + } else { + child.stdin.end(); + } + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + return await new Promise((resolve) => { + let settled = false; + let killTimer: NodeJS.Timeout | null = null; + const finish = (exitCode: number): void => { + if (settled) return; + settled = true; + clearTimeout(runtimeTimer); + if (killTimer !== null) clearTimeout(killTimer); + const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({ + invocation, + transport: "backend-core-broker", + exitCode, + startedAtMs, + })); + if (timingHint) stderr += timingHint; + stderr += formatSshTcpPoolHint(sshTcpPoolHint({ + invocation, + transport: "backend-core-broker", + exitCode, + stderrText: stderr, + })); + stderr += sshWindowsPlaneHint(invocation, exitCode, stderr); + resolve({ exitCode, stdout, stderr }); + }; + const runtimeTimer = setTimeout(() => { + const hint = formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({ + invocation, + transport: "backend-core-broker", + timeoutMs: runtimeTimeoutMs, + })); + stderr += hint; + try { + child.kill("SIGTERM"); + } catch { + // Ignore. + } + killTimer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + // Ignore. + } + }, 2000); + finish(124); + }, runtimeTimeoutMs); + child.on("error", (error) => { + stderr += `unidesk ssh failed to start broker: ${error.message}\n`; + finish(255); + }); + child.on("close", (code) => finish(code ?? 255)); + }); +} + +async function runSshStreamRemoteCommand( + config: UniDeskConfig, + invocation: ParsedSshInvocation, + remoteCommand: string, + handlers: SshRemoteCommandStreamHandlers, + input?: string, + options: { inactivityTimeoutMs?: number } = {}, +): Promise { + const streamInvocation = { + ...invocation, + parsed: { ...invocation.parsed, remoteCommand, requiresStdin: input !== undefined, invocationKind: "helper" as const }, + }; + const startedAtMs = Date.now(); + const size = terminalSize(); + const inactivityTimeoutMs = options.inactivityTimeoutMs ?? sshRuntimeTimeoutMs(); + const runtimeTimeoutMs = options.inactivityTimeoutMs === undefined ? inactivityTimeoutMs : Math.max(inactivityTimeoutMs * 4, 60 * 60_000); + const payload = { + providerId: invocation.providerId, + command: wrapSshRemoteCommand(remoteCommand), + cwd: sshRoutePayloadCwd(invocation.route), + tty: false, + stdinEotOnEnd: false, + openTimeoutMs: Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000)), + runtimeTimeoutMs, + runtimeTimeoutMode: options.inactivityTimeoutMs === undefined ? "wall-clock" : "inactivity", + cols: size.cols, + rows: size.rows, + }; + const payloadJson = JSON.stringify(payload); + const encodedBrokerSource = Buffer.from(brokerSource(), "utf8").toString("base64"); + const script = [ + "set -eu", + `payload=${shellQuote(payloadJson)}`, + "if command -v backend-core >/dev/null 2>&1; then", + ' exec backend-core --ssh-broker "$payload"', + "fi", + `export UNIDESK_SSH_BROKER_URL=${shellQuote("ws://127.0.0.1:8080/ws/ssh")}`, + 'broker_js="$(mktemp "${TMPDIR:-/tmp}/unidesk-ssh-broker.XXXXXX")"', + 'trap \'rm -f "$broker_js"\' EXIT', + `printf %s ${shellQuote(encodedBrokerSource)} | base64 -d >"$broker_js"`, + 'bun "$broker_js" "$payload"', + ].join("\n"); + const child = spawn("docker", ["exec", "-i", "unidesk-backend-core", "sh", "-c", script], { + cwd: repoRoot, + stdio: ["pipe", "pipe", "pipe"], + }); + if (input !== undefined) { + writeChunkedStdin(child.stdin, input); + } else { + child.stdin.end(); + } + let stdout = ""; + let stderr = ""; + let streamError: unknown = null; + let streamWrites = Promise.resolve(); + const queueStreamWrite = (chunk: Buffer, stream: "stdout" | "stderr"): void => { + streamWrites = streamWrites.then(async () => { + if (stream === "stdout") await handlers.onStdout(chunk); + else if (handlers.onStderr !== undefined) await handlers.onStderr(chunk); + }).catch((error) => { + streamError = error; + stderr += `unidesk ssh stream sink failed: ${error instanceof Error ? error.message : String(error)}\n`; + try { + child.kill("SIGTERM"); + } catch { + // Ignore kill failures after a local stream sink error. + } + }); + }; + child.stdout.on("data", (chunk: Buffer) => { + queueStreamWrite(chunk, "stdout"); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + queueStreamWrite(chunk, "stderr"); + }); + return await new Promise((resolve) => { + let settled = false; + let killTimer: NodeJS.Timeout | null = null; + let inactivityTimer: NodeJS.Timeout | null = null; + const refreshActivityTimer = (): void => { + if (settled || options.inactivityTimeoutMs === undefined) return; + if (inactivityTimer !== null) clearTimeout(inactivityTimer); + inactivityTimer = setTimeout(() => { + const hint = formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({ + invocation: streamInvocation, + transport: "backend-core-broker", + timeoutMs: inactivityTimeoutMs, + })); + stderr += hint; + try { + child.kill("SIGTERM"); + } catch { + // Ignore. + } + killTimer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + // Ignore. + } + }, 2000); + finish(124); + }, inactivityTimeoutMs); + }; + const finish = (exitCode: number): void => { + if (settled) return; + settled = true; + clearTimeout(runtimeTimer); + if (inactivityTimer !== null) clearTimeout(inactivityTimer); + if (killTimer !== null) clearTimeout(killTimer); + void streamWrites.then(() => { + const finalCode = streamError === null ? exitCode : 255; + const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({ + invocation: streamInvocation, + transport: "backend-core-broker", + exitCode: finalCode, + startedAtMs, + })); + if (timingHint) stderr += timingHint; + stderr += formatSshTcpPoolHint(sshTcpPoolHint({ + invocation: streamInvocation, + transport: "backend-core-broker", + exitCode: finalCode, + stderrText: stderr, + })); + resolve({ exitCode: finalCode, stdout, stderr }); + }); + }; + const runtimeTimer = setTimeout(() => { + const hint = formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({ + invocation: streamInvocation, + transport: "backend-core-broker", + timeoutMs: runtimeTimeoutMs, + })); + stderr += hint; + try { + child.kill("SIGTERM"); + } catch { + // Ignore. + } + killTimer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + // Ignore. + } + }, 2000); + finish(124); + }, runtimeTimeoutMs); + refreshActivityTimer(); + child.on("error", (error) => { + stderr += `unidesk ssh failed to start broker: ${error.message}\n`; + finish(255); + }); + child.on("close", (code) => finish(code ?? 255)); + child.stdout.on("data", refreshActivityTimer); + child.stderr.on("data", refreshActivityTimer); + }); +} + +async function runRemoteSsh(config: UniDeskConfig, host: string, providerId: string, args: string[]): Promise { + const { runRemoteSshCli } = await import("./remote-ssh"); + return await runRemoteSshCli({ + host, + user: "root", + port: 22, + projectRoot: repoRoot, + identityFile: null, + transport: "frontend", + args: ["ssh", providerId, ...args], + }, config); +} + +export async function runSshCommandCapture(config: UniDeskConfig, target: string, args: string[], input?: string): Promise { + const invocation = parseSshInvocation(target, args); + const parsed = invocation.parsed; + if (parsed.remoteCommand === null) throw new Error(`ssh ${target} capture requires a non-interactive operation`); + const stdin = parsed.stdinPrefix !== undefined || parsed.stdinSuffix !== undefined + ? `${parsed.stdinPrefix ?? ""}${input ?? ""}${parsed.stdinSuffix ?? ""}` + : input; + const plan = sshCaptureBackendPlan(config, process.env); + if (plan.backend === "remote-frontend-websocket" && plan.remoteHost !== null) { + return await runRemoteSshCapture(config, plan.remoteHost, target, args, stdin); + } + const local = await runSshCaptureRemoteCommand(config, invocation, parsed.remoteCommand, stdin); + const fallbackHost = sshCaptureRemoteHost(config, process.env); + if (local.exitCode !== 0 && fallbackHost !== null && isLocalSshCaptureBackendUnavailable(local)) { + return await runRemoteSshCapture(config, fallbackHost, target, args, stdin); + } + return local; +} + +async function runRemoteSshCapture(config: UniDeskConfig, host: string, target: string, args: string[], input?: string): Promise { + const { runRemoteSshCommandCapture } = await import("./remote-ssh"); + return await runRemoteSshCommandCapture(config, host, target, args, input); +} + +export function sshCaptureBackendPlan(config: UniDeskConfig, env: NodeJS.ProcessEnv = process.env): SshCaptureBackendPlan { + const configuredBackend = readTransSshBackendConfig(env); + const remoteHost = sshCaptureRemoteHost(config, env); + if (configuredBackend !== null) return configuredSshCaptureBackendPlan(configuredBackend, remoteHost); + const localBackendCore = detectLocalBackendCoreStatus(env); + const runnerEnv = isRunnerEnvironment(env); + if (runnerEnv && remoteHost !== null) return { backend: "remote-frontend-websocket", remoteHost, reason: "runner-environment", localBackendCore }; + if (!localBackendCore.backendCoreContainer && remoteHost !== null) return { backend: "remote-frontend-websocket", remoteHost, reason: "local-backend-core-unavailable", localBackendCore }; + return { backend: "local-backend-core-broker", remoteHost: null, reason: "main-server-local-backend-core", localBackendCore }; +} + +function configuredSshCaptureBackendPlan(configuredBackend: TransSshBackendConfig, remoteHost: string | null): SshCaptureBackendPlan { + if (configuredBackend.backend === "local-backend-core-broker") { + return { + backend: "local-backend-core-broker", + remoteHost: null, + reason: `configured:${configuredBackend.sourceRef}`, + localBackendCore: { + dockerExecutable: true, + backendCoreContainer: true, + error: null, + source: configuredBackend.sourceRef, + }, + }; + } + if (remoteHost === null) { + throw new Error(`${configuredBackend.sourceRef} selects ${configuredBackend.raw}, but no remote host is configured for frontend websocket SSH`); + } + return { + backend: "remote-frontend-websocket", + remoteHost, + reason: `configured:${configuredBackend.sourceRef}`, + localBackendCore: { + dockerExecutable: false, + backendCoreContainer: false, + error: null, + source: configuredBackend.sourceRef, + }, + }; +} + +function sshCaptureRemoteHost(config: UniDeskConfig, env: NodeJS.ProcessEnv): string | null { + return normalizeRemoteHost(env.UNIDESK_MAIN_SERVER_IP) + ?? normalizeRemoteHost(env.UNIDESK_MAIN_SERVER_HOST) + ?? normalizeRemoteHost(env.CODE_QUEUE_DEV_CONTAINER_MASTER_HOST) + ?? normalizeRemoteHost(readHostK8sPublicHost() ?? undefined) + ?? normalizeRemoteHost(config.network.publicHost); +} + +function normalizeRemoteHost(raw: string | undefined): string | null { + const value = raw?.trim() ?? ""; + if (value.length === 0 || value === "localhost" || value === "127.0.0.1" || value === "::1") return null; + return value.replace(/\/+$/u, ""); +} + +function isRunnerEnvironment(env: NodeJS.ProcessEnv): boolean { + return Boolean( + env.AGENTRUN_BOOT_MODE + || env.AGENTRUN_RUN_ID + || env.AGENTRUN_K8S_JOB_NAME + || env.CODE_QUEUE_SERVICE_ROLE + || env.CODE_QUEUE_INSTANCE_ID + || env.KUBERNETES_SERVICE_HOST, + ); +} + +function detectLocalBackendCoreStatus(env: NodeJS.ProcessEnv = process.env): SshCaptureBackendPlan["localBackendCore"] { + const timeout = sshBackendCoreDetectTimeoutMs(env); + const inspect = spawnSync("docker", ["inspect", "unidesk-backend-core", "--format", "{{.State.Running}}"], { encoding: "utf8", timeout }); + if (inspect.error !== undefined) return { dockerExecutable: false, backendCoreContainer: false, error: inspect.error.message, source: "docker-inspect" }; + const inspectOutput = `${inspect.stdout ?? ""}\n${inspect.stderr ?? ""}`.trim(); + if (inspect.status === 0) { + const running = String(inspect.stdout ?? "").trim() === "true"; + return { + dockerExecutable: true, + backendCoreContainer: running, + error: running ? null : inspectOutput || "docker inspect unidesk-backend-core reported not running", + source: "docker-inspect", + }; + } + const result = spawnSync("docker", ["ps", "--format", "{{.Names}}"], { encoding: "utf8", timeout }); + if (result.error !== undefined) return { dockerExecutable: false, backendCoreContainer: false, error: result.error.message, source: "docker-ps" }; + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); + return { + dockerExecutable: result.status === 0, + backendCoreContainer: result.status === 0 && String(result.stdout ?? "").split(/\r?\n/u).includes("unidesk-backend-core"), + error: result.status === 0 ? null : inspectOutput || output || `docker ps exited ${result.status ?? "unknown"}`, + source: "docker-ps", + }; +} + +function sshBackendCoreDetectTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { + const parsed = Number(env.UNIDESK_SSH_BACKEND_CORE_DETECT_TIMEOUT_MS); + if (!Number.isFinite(parsed) || parsed <= 0) return defaultSshBackendCoreDetectTimeoutMs; + return Math.min(maxSshBackendCoreDetectTimeoutMs, Math.max(2000, Math.trunc(parsed))); +} + +function isLocalSshCaptureBackendUnavailable(result: SshCaptureResult): boolean { + return /No such container: unidesk-backend-core|failed to start broker|Executable not found.*"docker"|docker: not found|Cannot connect to the Docker daemon/iu.test(result.stderr); +} + +function writeChunkedStdin(stdin: NodeJS.WritableStream, input: string): void { + const buffer = Buffer.from(input, "utf8"); + const chunkSize = 32 * 1024; + for (let offset = 0; offset < buffer.length; offset += chunkSize) { + stdin.write(buffer.subarray(offset, Math.min(buffer.length, offset + chunkSize))); + } + stdin.end(); +} + +export async function runSsh(config: UniDeskConfig, providerId: string, args: string[]): Promise { + const normalizedArgs = normalizeSshOperationArgs(args); + process.stderr.write(sshRouteSeparatorCompatibilityHint(args, normalizedArgs)); + const plan = sshCaptureBackendPlan(config, process.env); + if (plan.backend === "remote-frontend-websocket" && plan.remoteHost !== null) { + return await runRemoteSsh(config, plan.remoteHost, providerId, normalizedArgs); + } + const invocation = parseSshInvocation(providerId, normalizedArgs); + const parsed = invocation.parsed; + const operationName = normalizedArgs[0] ?? ""; + if (isSshFileTransferOperation(normalizedArgs)) { + const executor: SshRemoteCommandExecutor = { + runRemoteCommand: (remoteCommand, input) => runSshCaptureRemoteCommand(config, invocation, remoteCommand, input), + streamRemoteCommand: (remoteCommand, handlers, input, options) => runSshStreamRemoteCommand(config, invocation, remoteCommand, handlers, input, options), + }; + return await runSshFileTransferOperation(invocation, normalizedArgs, executor, { + buildRouteCommand: remoteCommandForRoute, + buildWindowsPowerShellCommand: buildWindowsPowerShellInvocation, + }); + } + if (operationName === "apply-patch") { + const applyPatch = effectiveApplyPatchV2Invocation(invocation, normalizedArgs.slice(1)); + const executor: ApplyPatchV2Executor = applyPatch.invocation.route.plane === "win" + ? { fs: createWindowsApplyPatchFileSystem(applyPatch.invocation, (remoteCommand, input) => runSshCaptureRemoteCommand(config, applyPatch.invocation, remoteCommand, input)) } + : applyPatch.invocation.route.plane === "host" + ? { fs: createPosixApplyPatchFileSystem(applyPatch.invocation, (command, input) => runSshCaptureCommand(config, applyPatch.invocation, command, input)) } + : { run: (command, input) => runSshCaptureCommand(config, applyPatch.invocation, command, input) }; + return await runApplyPatchV2({ + executor, + stdin: process.stdin, + stdout: process.stdout, + stderr: process.stderr, + argv: applyPatch.argv, + timing: { + providerId: applyPatch.invocation.providerId, + route: applyPatch.invocation.route.raw, + transport: "backend-core-broker", + }, + }); + } + const startedAtMs = Date.now(); + const size = terminalSize(); + const openTimeoutMs = Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000)); + const runtimeTimeoutMs = sshRuntimeTimeoutMs(); + const payload = { + providerId: invocation.providerId, + command: wrapSshRemoteCommand(parsed.remoteCommand, parsed.requiredHelpers), + cwd: sshRoutePayloadCwd(invocation.route), + tty: parsed.remoteCommand === null, + stdinEotOnEnd: parsed.remoteCommand !== null && parsed.requiresStdin !== true, + openTimeoutMs, + runtimeTimeoutMs, + cols: size.cols, + rows: size.rows, + }; + const payloadJson = JSON.stringify(payload); + const encodedBrokerSource = Buffer.from(brokerSource(), "utf8").toString("base64"); + const script = [ + "set -eu", + `payload=${shellQuote(payloadJson)}`, + "if command -v backend-core >/dev/null 2>&1; then", + ' exec backend-core --ssh-broker "$payload"', + "fi", + `export UNIDESK_SSH_BROKER_URL=${shellQuote("ws://127.0.0.1:8080/ws/ssh")}`, + 'broker_js="$(mktemp "${TMPDIR:-/tmp}/unidesk-ssh-broker.XXXXXX")"', + 'trap \'rm -f "$broker_js"\' EXIT', + `printf %s ${shellQuote(encodedBrokerSource)} | base64 -d >"$broker_js"`, + 'bun "$broker_js" "$payload"', + ].join("\n"); + const child = spawn("docker", [ + "exec", + "-i", + "unidesk-backend-core", + "sh", + "-c", + script, + ], { + cwd: repoRoot, + stdio: ["pipe", "pipe", "pipe"], + }); + + const rawMode = parsed.remoteCommand === null && process.stdin.isTTY; + if (rawMode) process.stdin.setRawMode(true); + process.stdin.resume(); + if (parsed.stdinPrefix !== undefined || parsed.stdinSuffix !== undefined) { + if (parsed.stdinPrefix) child.stdin.write(parsed.stdinPrefix); + process.stdin.pipe(child.stdin, { end: false }); + process.stdin.once("end", () => { + if (parsed.stdinSuffix) child.stdin.write(parsed.stdinSuffix); + child.stdin.end(); + }); + } else { + process.stdin.pipe(child.stdin); + } + let stderrTail = ""; + const appendStderrTail = (chunk: Buffer | string): void => { + const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk; + stderrTail = (stderrTail + text).slice(-16_384); + }; + let stdoutForwarder: SshStreamForwarder | null = null; + let stderrForwarder: SshStreamForwarder | null = null; + if (parsed.remoteCommand === null) { + child.stdout.pipe(process.stdout); + } else { + stdoutForwarder = createSshStdoutForwarder({ + invocation, + transport: "backend-core-broker", + }); + stderrForwarder = createSshStderrForwarder({ + invocation, + transport: "backend-core-broker", + }); + child.stdout.on("data", (chunk: Buffer) => { + const hint = stdoutForwarder.write(chunk); + if (hint !== null) { + appendStderrTail(hint); + process.stderr.write(hint); + } + }); + child.stderr.on("data", (chunk: Buffer) => { + appendStderrTail(chunk); + const hint = stderrForwarder.write(chunk); + if (hint !== null) { + appendStderrTail(hint); + process.stderr.write(hint); + } + }); + } + if (parsed.remoteCommand === null) { + child.stderr.on("data", (chunk: Buffer) => { + appendStderrTail(chunk); + process.stderr.write(chunk); + }); + } + + return await new Promise((resolve) => { + let settled = false; + let timedOut = false; + let killTimer: NodeJS.Timeout | null = null; + const runtimeTimer = setTimeout(() => { + if (settled) return; + timedOut = true; + const hint = sshRuntimeTimeoutHint({ + invocation, + transport: "backend-core-broker", + timeoutMs: runtimeTimeoutMs, + }); + const formatted = formatSshRuntimeTimeoutHint(hint); + appendStderrTail(formatted); + process.stderr.write(formatted); + try { + child.stdin.destroy(); + } catch { + // Ignore stdin teardown failures on the timeout path. + } + try { + child.kill("SIGTERM"); + } catch { + // Ignore kill failures and fall through to finish. + } + killTimer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + // Process may have already exited. + } + }, 2000); + finish(124); + }, runtimeTimeoutMs); + const restore = (): void => { + clearTimeout(runtimeTimer); + if (killTimer && !timedOut) clearTimeout(killTimer); + process.stdin.unpipe(child.stdin); + if (rawMode) process.stdin.setRawMode(false); + }; + const finish = (exitCode: number): void => { + if (settled) return; + settled = true; + restore(); + const hint = timedOut ? null : sshFailureHint(invocation.providerId, parsed, exitCode, stderrTail); + if (hint !== null) process.stderr.write(formatSshFailureHint(hint)); + if (!timedOut) { + const windowsPlaneHint = sshWindowsPlaneHint(invocation, exitCode, stderrTail); + if (windowsPlaneHint) process.stderr.write(windowsPlaneHint); + } + if (!timedOut) { + const tcpPoolHint = formatSshTcpPoolHint(sshTcpPoolHint({ + invocation, + transport: "backend-core-broker", + exitCode, + stderrText: stderrTail, + })); + if (tcpPoolHint) process.stderr.write(tcpPoolHint); + } + const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({ + invocation, + transport: "backend-core-broker", + exitCode, + startedAtMs, + })); + if (timingHint) process.stderr.write(timingHint); + const truncationSummary = formatSshTruncationCompletionSummary(sshTruncationCompletionSummary({ + invocation, + transport: "backend-core-broker", + exitCode, + timedOut, + startedAtMs, + stdout: stdoutForwarder?.summary() ?? null, + stderr: stderrForwarder?.summary() ?? null, + })); + if (truncationSummary) process.stderr.write(truncationSummary); + resolve(exitCode); + }; + child.on("error", (error) => { + const message = `unidesk ssh failed to start broker: ${error.message}\n`; + appendStderrTail(message); + process.stderr.write(message); + finish(255); + }); + child.on("close", (code) => { + finish(code ?? 255); + }); + }); +} diff --git a/scripts/src/ssh-windows-fs.ts b/scripts/src/ssh-windows-fs.ts new file mode 100644 index 00000000..b242d8f6 --- /dev/null +++ b/scripts/src/ssh-windows-fs.ts @@ -0,0 +1,77 @@ +import { readFileSync } from "node:fs"; +import { rootPath } from "./config"; + +export type WindowsFsReadOnlyOperation = "pwd" | "ls" | "cat" | "head" | "tail" | "stat" | "wc" | "rg"; + +export const windowsFsReadOnlyOperations = new Set(["pwd", "ls", "cat", "head", "tail", "stat", "wc", "rg"]); + +interface WindowsRgPolicy { + timeoutMs: number; + maxFiles: number; + maxMatches: number; + maxBytesPerFile: number; +} + +const configPath = "config/unidesk-cli.yaml"; +const commonScriptSource = loadScript("common.ps1"); +const operationScriptSources: Record = { + pwd: loadScript("pwd.ps1"), + ls: loadScript("ls.ps1"), + cat: loadScript("cat.ps1"), + head: loadScript("head-tail.ps1"), + tail: loadScript("head-tail.ps1"), + stat: loadScript("stat.ps1"), + wc: loadScript("wc.ps1"), + rg: loadScript("rg.ps1"), +}; + +export function isWindowsFsReadOnlyOperation(operation: string): operation is WindowsFsReadOnlyOperation { + return windowsFsReadOnlyOperations.has(operation); +} + +export function windowsFsReadOnlyScript(basePath: string | null, operation: WindowsFsReadOnlyOperation, args: string[]): string { + const rgPolicy = readWindowsRgPolicy(); + const variables = [ + `$basePath = ${powerShellSingleQuote(basePath ?? "")}`, + `$operation = ${powerShellSingleQuote(operation)}`, + `$argsJsonB64 = ${powerShellSingleQuote(Buffer.from(JSON.stringify(args), "utf8").toString("base64"))}`, + `$rgPolicyJsonB64 = ${powerShellSingleQuote(Buffer.from(JSON.stringify(rgPolicy), "utf8").toString("base64"))}`, + ].join("; "); + return `${variables}; ${commonScriptSource} ${operationScriptSources[operation]}`; +} + +function loadScript(name: string): string { + return readFileSync(new URL(`./ssh-windows-fs/${name}`, import.meta.url), "utf8"); +} + +function readWindowsRgPolicy(): WindowsRgPolicy { + const document = asRecord(Bun.YAML.parse(readFileSync(rootPath(configPath), "utf8")), configPath); + const trans = requiredRecord(document, "trans", configPath); + const windowsFs = requiredRecord(trans, "windowsFs", `${configPath}#trans`); + const rg = requiredRecord(windowsFs, "rg", `${configPath}#trans.windowsFs`); + return { + timeoutMs: positiveInt(rg, "timeoutMs", `${configPath}#trans.windowsFs.rg`), + maxFiles: positiveInt(rg, "maxFiles", `${configPath}#trans.windowsFs.rg`), + maxMatches: positiveInt(rg, "maxMatches", `${configPath}#trans.windowsFs.rg`), + maxBytesPerFile: positiveInt(rg, "maxBytesPerFile", `${configPath}#trans.windowsFs.rg`), + }; +} + +function asRecord(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); + return value as Record; +} + +function requiredRecord(value: Record, key: string, path: string): Record { + return asRecord(value[key], `${path}.${key}`); +} + +function positiveInt(value: Record, key: string, path: string): number { + const raw = value[key]; + if (!Number.isSafeInteger(raw) || Number(raw) <= 0) throw new Error(`${path}.${key} must be a positive integer`); + return Number(raw); +} + +function powerShellSingleQuote(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} diff --git a/scripts/src/ssh-windows-fs/cat.ps1 b/scripts/src/ssh-windows-fs/cat.ps1 new file mode 100644 index 00000000..ca6294de --- /dev/null +++ b/scripts/src/ssh-windows-fs/cat.ps1 @@ -0,0 +1,13 @@ +if ($operation -eq 'cat') { + $maxBytes = 262144; $paths = [Collections.Generic.List[string]]::new() + for ($i = 0; $i -lt $toolArgs.Count; $i++) { + $arg = $toolArgs[$i] + if ($arg -eq '--max-bytes') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--max-bytes requires a value' 2 }; $maxBytes = Parse-NonNegativeInt '--max-bytes' $toolArgs[$i] 16777216; continue } + if ($arg.StartsWith('--max-bytes=')) { $maxBytes = Parse-NonNegativeInt '--max-bytes' $arg.Substring(12) 16777216; continue } + if ($arg.StartsWith('-') -and $arg -ne '-') { Fail ('unsupported cat option on Windows route: ' + $arg) 2 } + $paths.Add($arg) + } + if ($paths.Count -ne 1) { Fail 'cat requires exactly one file path on Windows routes' 2 } + [Console]::Out.Write((Read-StrictText (Resolve-UnideskPath $paths[0]) $maxBytes)) + exit 0 +} diff --git a/scripts/src/ssh-windows-fs/common.ps1 b/scripts/src/ssh-windows-fs/common.ps1 new file mode 100644 index 00000000..e8d81843 --- /dev/null +++ b/scripts/src/ssh-windows-fs/common.ps1 @@ -0,0 +1,50 @@ +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +[Console]::InputEncoding = [System.Text.UTF8Encoding]::new() +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new() +$OutputEncoding = [System.Text.UTF8Encoding]::new() + +$toolArgs = @() +$decodedArgs = ConvertFrom-Json -InputObject ([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($argsJsonB64))) +if ($null -ne $decodedArgs) { foreach ($item in @($decodedArgs)) { $toolArgs += [string]$item } } +$rgPolicy = ConvertFrom-Json -InputObject ([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($rgPolicyJsonB64))) + +function Fail([string]$Message, [int]$Code) { [Console]::Error.WriteLine($Message); exit $Code } +function Resolve-UnideskPath([string]$Raw) { + if ([string]::IsNullOrWhiteSpace($Raw) -or $Raw -eq '.') { + if (-not [string]::IsNullOrWhiteSpace($basePath)) { return [IO.Path]::GetFullPath($basePath) } + return (Get-Location).ProviderPath + } + if ([IO.Path]::IsPathRooted($Raw)) { return [IO.Path]::GetFullPath($Raw) } + if (-not [string]::IsNullOrWhiteSpace($basePath)) { return [IO.Path]::GetFullPath([IO.Path]::Combine($basePath, $Raw)) } + return [IO.Path]::GetFullPath($Raw) +} +function Need-File([string]$Path) { if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { Fail ('file not found: ' + $Path) 1 } } +function Get-Sha256([string]$Path) { return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() } +function Parse-NonNegativeInt([string]$Name, [string]$Value, [int]$Max) { + $parsed = 0 + if (-not [int]::TryParse($Value, [ref]$parsed) -or $parsed -lt 0 -or $parsed -gt $Max) { Fail ($Name + ' must be an integer from 0 to ' + $Max) 2 } + return $parsed +} +function Read-StrictText([string]$Path, [long]$MaxBytes) { + Need-File $Path + $info = [IO.FileInfo]$Path + if ($MaxBytes -gt 0 -and $info.Length -gt $MaxBytes) { Fail ('file too large for windows fs read operation: ' + $Path + ' bytes=' + $info.Length + ' maxBytes=' + $MaxBytes + '; use head/tail/download or --max-bytes') 23 } + $bytes = [IO.File]::ReadAllBytes($Path) + if ([Array]::IndexOf($bytes, [byte]0) -ge 0) { Fail ('binary file refused by windows fs read operation: ' + $Path) 24 } + try { return [Text.UTF8Encoding]::new($false, $true).GetString($bytes) } catch { Fail ('file is not valid UTF-8: ' + $Path + ': ' + $_.Exception.Message) 25 } +} +function Try-Read-StrictText([string]$Path, [long]$MaxBytes) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + if (([IO.FileInfo]$Path).Length -gt $MaxBytes) { return $null } + $bytes = [IO.File]::ReadAllBytes($Path) + if ([Array]::IndexOf($bytes, [byte]0) -ge 0) { return $null } + try { return [Text.UTF8Encoding]::new($false, $true).GetString($bytes) } catch { return $null } +} +function Text-Lines([string]$Text) { + $lines = [Collections.Generic.List[string]]::new() + foreach ($line in ($Text -split "`r?`n", -1)) { $lines.Add($line) } + if ($lines.Count -gt 0 -and $lines[$lines.Count - 1] -eq '') { $lines.RemoveAt($lines.Count - 1) } + return [string[]]$lines +} +function Print-Lines([string[]]$Lines) { if ($Lines.Count -gt 0) { [Console]::Out.Write(([string]::Join("`n", $Lines)) + "`n") } } diff --git a/scripts/src/ssh-windows-fs/head-tail.ps1 b/scripts/src/ssh-windows-fs/head-tail.ps1 new file mode 100644 index 00000000..1bdbe695 --- /dev/null +++ b/scripts/src/ssh-windows-fs/head-tail.ps1 @@ -0,0 +1,15 @@ +if ($operation -in @('head', 'tail')) { + $linesWanted = 10; $paths = [Collections.Generic.List[string]]::new() + for ($i = 0; $i -lt $toolArgs.Count; $i++) { + $arg = $toolArgs[$i] + if ($arg -in @('-n', '--lines')) { $i += 1; if ($i -ge $toolArgs.Count) { Fail ($arg + ' requires a value') 2 }; $linesWanted = Parse-NonNegativeInt $arg $toolArgs[$i] 10000; continue } + if ($arg.StartsWith('-n') -and $arg.Length -gt 2) { $linesWanted = Parse-NonNegativeInt '-n' $arg.Substring(2) 10000; continue } + if ($arg.StartsWith('--lines=')) { $linesWanted = Parse-NonNegativeInt '--lines' $arg.Substring(8) 10000; continue } + if ($arg.StartsWith('-') -and $arg -ne '-') { Fail ('unsupported ' + $operation + ' option on Windows route: ' + $arg) 2 } + $paths.Add($arg) + } + if ($paths.Count -ne 1) { Fail ($operation + ' requires exactly one file path on Windows routes') 2 } + $lines = Text-Lines (Read-StrictText (Resolve-UnideskPath $paths[0]) 1048576) + if ($operation -eq 'head') { Print-Lines ([string[]]($lines | Select-Object -First $linesWanted)) } else { Print-Lines ([string[]]($lines | Select-Object -Last $linesWanted)) } + exit 0 +} diff --git a/scripts/src/ssh-windows-fs/ls.ps1 b/scripts/src/ssh-windows-fs/ls.ps1 new file mode 100644 index 00000000..c487c9e8 --- /dev/null +++ b/scripts/src/ssh-windows-fs/ls.ps1 @@ -0,0 +1,25 @@ +if ($operation -eq 'ls') { + $all = $false; $limit = 200; $path = '.' + for ($i = 0; $i -lt $toolArgs.Count; $i++) { + $arg = $toolArgs[$i] + if ($arg -in @('-a', '--all')) { $all = $true; continue } + if ($arg -in @('-l', '-la', '-al')) { if ($arg.Contains('a')) { $all = $true }; continue } + if ($arg -eq '--limit') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--limit requires a value' 2 }; $limit = Parse-NonNegativeInt '--limit' $toolArgs[$i] 5000; continue } + if ($arg.StartsWith('--limit=')) { $limit = Parse-NonNegativeInt '--limit' $arg.Substring(8) 5000; continue } + if ($arg.StartsWith('-') -and $arg -ne '-') { Fail ('unsupported ls option on Windows route: ' + $arg) 2 } + $path = $arg + } + $target = Resolve-UnideskPath $path + if (-not (Test-Path -LiteralPath $target)) { Fail ('path not found: ' + $target) 1 } + $items = if (Test-Path -LiteralPath $target -PathType Container) { @(Get-ChildItem -LiteralPath $target -Force:$all | Sort-Object Name) } else { @(Get-Item -LiteralPath $target) } + [Console]::Out.WriteLine('TYPE BYTES UPDATED NAME') + $count = 0 + foreach ($item in $items) { + if ($count -ge $limit) { [Console]::Error.WriteLine('UNIDESK_WINDOWS_FS_TRUNCATED ls limit=' + $limit); break } + $type = if ($item.PSIsContainer) { 'dir' } else { 'file' } + $bytes = if ($item.PSIsContainer) { '-' } else { [string]$item.Length } + [Console]::Out.WriteLine($type + ' ' + $bytes + ' ' + $item.LastWriteTimeUtc.ToString('o') + ' ' + $item.Name) + $count += 1 + } + exit 0 +} diff --git a/scripts/src/ssh-windows-fs/pwd.ps1 b/scripts/src/ssh-windows-fs/pwd.ps1 new file mode 100644 index 00000000..9e75d01b --- /dev/null +++ b/scripts/src/ssh-windows-fs/pwd.ps1 @@ -0,0 +1,5 @@ +if ($operation -eq 'pwd') { + if ($toolArgs.Count -ne 0) { Fail 'pwd accepts no arguments on Windows routes' 2 } + [Console]::Out.WriteLine((Resolve-UnideskPath '.')) + exit 0 +} diff --git a/scripts/src/ssh-windows-fs/rg.ps1 b/scripts/src/ssh-windows-fs/rg.ps1 new file mode 100644 index 00000000..ac0fc8a1 --- /dev/null +++ b/scripts/src/ssh-windows-fs/rg.ps1 @@ -0,0 +1,68 @@ +if ($operation -ne 'rg') { Fail ('unsupported Windows fs operation: ' + $operation) 2 } + +$ignoreCase = $false; $fixed = $false; $filesOnly = $false; $pattern = $null; $endOptions = $false +$maxCount = [int]$rgPolicy.maxMatches; $maxFiles = [int]$rgPolicy.maxFiles; $maxBytes = [int]$rgPolicy.maxBytesPerFile; $timeoutMs = [int]$rgPolicy.timeoutMs +$paths = [Collections.Generic.List[string]]::new(); $globs = [Collections.Generic.List[string]]::new() +for ($i = 0; $i -lt $toolArgs.Count; $i++) { + $arg = $toolArgs[$i] + if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--') { $endOptions = $true; continue } + if ($null -eq $pattern -and -not $endOptions -and $arg -in @('-i', '--ignore-case')) { $ignoreCase = $true; continue } + if ($null -eq $pattern -and -not $endOptions -and $arg -in @('-F', '--fixed-strings')) { $fixed = $true; continue } + if ($null -eq $pattern -and -not $endOptions -and $arg -in @('-n', '--line-number')) { continue } + if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--files') { $filesOnly = $true; continue } + if ($null -eq $pattern -and -not $endOptions -and $arg -in @('-g', '--glob')) { $i += 1; if ($i -ge $toolArgs.Count) { Fail ($arg + ' requires a value') 2 }; $globs.Add($toolArgs[$i]); continue } + if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--glob=')) { $globs.Add($arg.Substring(7)); continue } + if ($null -eq $pattern -and -not $endOptions -and $arg -in @('-m', '--max-count')) { $i += 1; if ($i -ge $toolArgs.Count) { Fail ($arg + ' requires a value') 2 }; $maxCount = Parse-NonNegativeInt $arg $toolArgs[$i] 10000; continue } + if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('-m') -and $arg.Length -gt 2) { $maxCount = Parse-NonNegativeInt '-m' $arg.Substring(2) 10000; continue } + if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--max-count=')) { $maxCount = Parse-NonNegativeInt '--max-count' $arg.Substring(12) 10000; continue } + if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--max-files') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--max-files requires a value' 2 }; $maxFiles = Parse-NonNegativeInt '--max-files' $toolArgs[$i] 50000; continue } + if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--max-files=')) { $maxFiles = Parse-NonNegativeInt '--max-files' $arg.Substring(12) 50000; continue } + if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--max-bytes') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--max-bytes requires a value' 2 }; $maxBytes = Parse-NonNegativeInt '--max-bytes' $toolArgs[$i] 16777216; continue } + if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--max-bytes=')) { $maxBytes = Parse-NonNegativeInt '--max-bytes' $arg.Substring(12) 16777216; continue } + if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--timeout-ms') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--timeout-ms requires a value' 2 }; $timeoutMs = Parse-NonNegativeInt '--timeout-ms' $toolArgs[$i] 60000; continue } + if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--timeout-ms=')) { $timeoutMs = Parse-NonNegativeInt '--timeout-ms' $arg.Substring(13) 60000; continue } + if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('-')) { Fail ('unsupported rg option on Windows route: ' + $arg + '; supported: --files -g/--glob -i -F -n -m/--max-count --max-files --max-bytes --timeout-ms') 2 } + if ($filesOnly) { $paths.Add($arg) } elseif ($null -eq $pattern) { $pattern = $arg } else { $paths.Add($arg) } +} +if (-not $filesOnly -and $null -eq $pattern) { Fail 'rg requires a pattern on Windows routes (or use --files)' 2 } +if ($paths.Count -eq 0) { $paths.Add('.') } + +$nativeRg = Get-Command rg.exe -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($null -eq $nativeRg) { Fail 'rg.exe is unavailable on this Windows plane; install ripgrep or use ps with a reviewed PowerShell search' 127 } +if ($null -ne $nativeRg) { + function Quote-NativeArg([string]$Value) { + if ($Value.Contains('"')) { Fail 'rg argument contains a quote; use ps for shell-reviewed quoting' 2 } + if ($Value -match '\s') { return '"' + $Value + '"' } + return $Value + } + $nativeArgs = [Collections.Generic.List[string]]::new() + $nativeArgs.Add('--color=never'); $nativeArgs.Add('--no-heading'); $nativeArgs.Add('--line-number') + $nativeArgs.Add('--max-filesize'); $nativeArgs.Add([string]$maxBytes) + if ($ignoreCase) { $nativeArgs.Add('--ignore-case') } + if ($fixed) { $nativeArgs.Add('--fixed-strings') } + foreach ($glob in $globs) { $nativeArgs.Add('--glob'); $nativeArgs.Add($glob) } + if ($filesOnly) { $nativeArgs.Add('--files') } else { $nativeArgs.Add($pattern) } + foreach ($raw in $paths) { $nativeArgs.Add((Resolve-UnideskPath $raw)) } + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $nativeRg.Source + $startInfo.Arguments = [string]::Join(' ', @($nativeArgs | ForEach-Object { Quote-NativeArg $_ })) + $startInfo.UseShellExecute = $false; $startInfo.CreateNoWindow = $true; $startInfo.RedirectStandardOutput = $true; $startInfo.RedirectStandardError = $true + $process = [Diagnostics.Process]::new(); $process.StartInfo = $startInfo + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + if (-not $process.Start()) { Fail 'failed to start native rg.exe' 2 } + $stdoutTask = $process.StandardOutput.ReadToEndAsync(); $stderrTask = $process.StandardError.ReadToEndAsync() + $timedOut = -not $process.WaitForExit($timeoutMs) + if ($timedOut) { try { $process.Kill() } catch {}; $process.WaitForExit() } + $stdoutText = $stdoutTask.Result; $stderrText = $stderrTask.Result; $stopwatch.Stop() + $lines = Text-Lines $stdoutText; $outputLimit = if ($filesOnly) { $maxFiles } else { $maxCount }; $selected = [string[]]($lines | Select-Object -First $outputLimit) + Print-Lines $selected + if (-not [string]::IsNullOrWhiteSpace($stderrText)) { [Console]::Error.Write($stderrText) } + $truncated = $lines.Count -gt $selected.Count + $matchedFiles = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + if (-not $filesOnly) { foreach ($line in $selected) { if ($line -match '^([A-Za-z]:\\.*?):\d+:') { [void]$matchedFiles.Add($Matches[1]) } } } + $fileCount = if ($filesOnly) { $selected.Count } else { $matchedFiles.Count } + [Console]::Error.WriteLine('UNIDESK_WINDOWS_RG_SUMMARY matched=' + $selected.Count + ' fileCount=' + $fileCount + ' elapsedMs=' + $stopwatch.ElapsedMilliseconds + ' timeout=' + $timedOut.ToString().ToLowerInvariant() + ' skipped=0 fileLimit=' + ($filesOnly -and $truncated).ToString().ToLowerInvariant() + ' matchLimit=' + ((-not $filesOnly) -and $truncated).ToString().ToLowerInvariant() + ' engine=native-rg config=config/unidesk-cli.yaml#trans.windowsFs.rg') + if ($timedOut) { exit 124 } + if ($process.ExitCode -ne 0) { exit $process.ExitCode } + exit 0 +} diff --git a/scripts/src/ssh-windows-fs/stat.ps1 b/scripts/src/ssh-windows-fs/stat.ps1 new file mode 100644 index 00000000..948db331 --- /dev/null +++ b/scripts/src/ssh-windows-fs/stat.ps1 @@ -0,0 +1,11 @@ +if ($operation -eq 'stat') { + if ($toolArgs.Count -eq 0) { Fail 'stat requires at least one path on Windows routes' 2 } + [Console]::Out.WriteLine('TYPE BYTES SHA256 PATH') + foreach ($raw in $toolArgs) { + $path = Resolve-UnideskPath $raw + if (-not (Test-Path -LiteralPath $path)) { Fail ('path not found: ' + $path) 1 } + $item = Get-Item -LiteralPath $path + if ($item.PSIsContainer) { [Console]::Out.WriteLine('dir - - ' + $path) } else { [Console]::Out.WriteLine('file ' + $item.Length + ' ' + (Get-Sha256 $path) + ' ' + $path) } + } + exit 0 +} diff --git a/scripts/src/ssh-windows-fs/wc.ps1 b/scripts/src/ssh-windows-fs/wc.ps1 new file mode 100644 index 00000000..370d6e3c --- /dev/null +++ b/scripts/src/ssh-windows-fs/wc.ps1 @@ -0,0 +1,23 @@ +if ($operation -eq 'wc') { + $showLines = $false; $showWords = $false; $showChars = $false; $showBytes = $false; $paths = [Collections.Generic.List[string]]::new() + foreach ($arg in $toolArgs) { + if ($arg -eq '-l' -or $arg -eq '--lines') { $showLines = $true; continue } + if ($arg -eq '-w' -or $arg -eq '--words') { $showWords = $true; continue } + if ($arg -eq '-m' -or $arg -eq '--chars') { $showChars = $true; continue } + if ($arg -eq '-c' -or $arg -eq '--bytes') { $showBytes = $true; continue } + if ($arg.StartsWith('-') -and $arg -ne '-') { Fail ('unsupported wc option on Windows route: ' + $arg) 2 } + $paths.Add($arg) + } + if ($paths.Count -eq 0) { Fail 'wc requires at least one file path on Windows routes' 2 } + if (-not ($showLines -or $showWords -or $showChars -or $showBytes)) { $showLines = $true; $showWords = $true; $showChars = $true; $showBytes = $true } + foreach ($raw in $paths) { + $path = Resolve-UnideskPath $raw; $text = Read-StrictText $path 1048576; $values = [Collections.Generic.List[string]]::new() + $lineCount = [regex]::Matches($text, "`n").Count; if ($text.Length -gt 0 -and -not $text.EndsWith("`n")) { $lineCount += 1 } + if ($showLines) { $values.Add([string]$lineCount) } + if ($showWords) { $values.Add([string]([regex]::Matches($text, '\S+').Count)) } + if ($showChars) { $values.Add([string]$text.Length) } + if ($showBytes) { $values.Add([string]([IO.FileInfo]$path).Length) } + $values.Add($path); [Console]::Out.WriteLine([string]::Join(' ', $values)) + } + exit 0 +} diff --git a/scripts/src/ssh.test.ts b/scripts/src/ssh.test.ts index 7c412222..e68170c0 100644 --- a/scripts/src/ssh.test.ts +++ b/scripts/src/ssh.test.ts @@ -16,6 +16,7 @@ import { sshStderrStreamMaxBytes, sshStdoutStreamMaxBytes, sshStdoutTruncationHint, + sshWindowsPlaneHint, windowsFsReadOnlyScript, windowsPowerShellScriptPrelude, } from "./ssh"; @@ -25,6 +26,12 @@ function sha256Hex(text: string): string { return createHash("sha256").update(text, "utf8").digest("hex"); } +function decodedWindowsPowerShellCommand(remoteCommand: string | null): string { + const encoded = remoteCommand?.match(/'([A-Za-z0-9+/=]+)'$/u)?.[1]; + if (encoded === undefined) throw new Error("Windows PowerShell command did not end in an encoded payload"); + return Buffer.from(encoded, "base64").toString("utf16le"); +} + function minimalConfig(publicHost = "203.0.113.10"): UniDeskConfig { return { network: { publicHost }, @@ -89,7 +96,22 @@ describe("ssh windows fs read-only operations", () => { expect(lsScript).toContain("TYPE BYTES UPDATED NAME"); expect(rgScript).toContain("$operation = 'rg';"); expect(rgScript).toContain("unsupported rg option on Windows route"); - expect(rgScript).toContain("UNIDESK_WINDOWS_FS_SKIPPED"); + expect(rgScript).toContain("UNIDESK_WINDOWS_RG_SUMMARY"); + expect(rgScript).toContain("engine=native-rg"); + expect(rgScript).toContain("config/unidesk-cli.yaml#trans.windowsFs.rg"); + expect(rgScript).toContain("--files"); + expect(rgScript).toContain("--glob"); + }); + + test("supports wc flags, exact skill lookup, and Windows cmd argv boundaries", () => { + const wcScript = windowsFsReadOnlyScript("F:\\Work\\demo", "wc", ["-l", "docs/read me.md"]); + const skills = parseSshInvocation("D601:win", ["skills", "--scope", "agents", "--name", "mdtodo-edit"]); + const cmd = parseSshInvocation("D601:win/F/Work/demo", ["cmd", "python", "tool.py", "71-FILTER 高频输出精度与频率跳变改进"]); + + expect(wcScript).toContain("$arg -eq '-l'"); + expect(wcScript).toContain("unsupported wc option on Windows route"); + expect(decodedWindowsPowerShellCommand(skills.parsed.remoteCommand)).toContain("$exactName = 'mdtodo-edit'"); + expect(decodedWindowsPowerShellCommand(cmd.parsed.remoteCommand)).toContain('python tool.py "71-FILTER 高频输出精度与频率跳变改进"'); }); test("rejects unsupported Windows read operations instead of treating them as POSIX", () => { @@ -102,6 +124,29 @@ describe("ssh windows fs read-only operations", () => { }); }); +describe("ssh direct argv boundaries and plane diagnostics", () => { + test("preserves a shell-formed argv token containing spaces and Chinese", () => { + const invocation = parseSshInvocation("G14-WSL:/mnt/d/Work/CONSTAR_workspace", [ + "python3", + "/tmp/mdtodo-edit-cli.py", + "add", + "docs/MDTODO/example.md", + "71-FILTER 高频输出精度与频率跳变改进", + ]); + + expect(invocation.parsed.remoteCommand).toContain("'python3' '/tmp/mdtodo-edit-cli.py' 'add' 'docs/MDTODO/example.md' '71-FILTER 高频输出精度与频率跳变改进'"); + }); + + test("suggests the matching Windows plane after WSL command-not-found", () => { + const invocation = parseSshInvocation("G14-WSL:/mnt/d/Work/CONSTAR_workspace", ["python", "--version"]); + const hint = sshWindowsPlaneHint(invocation, 127, "sh: python: command not found\n"); + + expect(hint).toContain("wsl-command-not-found-check-windows-plane"); + expect(hint).toContain("G14-WSL:win/d/Work/CONSTAR_workspace"); + expect(sshWindowsPlaneHint(invocation, 1, "no match")).toBe(""); + }); +}); + describe("ssh host apply-patch fs backend", () => { test("uses POSIX bulk fs operations for host routes", async () => { const invocation = parseSshInvocation("D601:/mnt/f/Work/ConStart", ["apply-patch"]); diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index a28782e0..0d1913b7 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -1,30 +1,8 @@ -import { spawn, spawnSync } from "node:child_process"; -import { createHash, randomBytes } from "node:crypto"; -import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { type UniDeskConfig, repoRoot } from "./config"; -import { - ApplyPatchV2Error, - applyPatchV2RemoteCommand, - decodeApplyPatchV2BulkRead, - formatApplyPatchV2BulkReplacementPayload, - isApplyPatchV2HelpArgs, - runApplyPatchV2, - type ApplyPatchV2BulkReplacementWritePlan, - type ApplyPatchV2Executor, - type ApplyPatchV2FileSystem, - type ApplyPatchV2RemoteOperation, -} from "./apply-patch-v2"; -import { - isSshFileTransferOperation, - runSshFileTransferOperation, - type SshRemoteCommandExecutor, - type SshRemoteCommandStreamHandlers, -} from "./ssh-file-transfer"; +import { isApplyPatchV2HelpArgs } from "./apply-patch-v2"; +import { isSshFileTransferOperation } from "./ssh-file-transfer"; +import { isWindowsFsReadOnlyOperation, windowsFsReadOnlyScript } from "./ssh-windows-fs"; +import { remoteApplyPatchSource, remoteGlobSource, remoteSkillDiscoverSource } from "./ssh-remote-tools"; import { readTransHostProxyEnvRule, type TransHostProxyEnvRule } from "./trans-host-proxy"; -import { readTransSshBackendConfig, type TransSshBackendConfig } from "./trans-config"; -import { readCliOutputPolicy } from "./output"; -import { readHostK8sPublicHost } from "./host-k8s-config"; export interface ParsedSshArgs { remoteCommand: string | null; @@ -55,7 +33,7 @@ export interface ParsedSshInvocation { parsed: ParsedSshArgs; } -interface EffectiveApplyPatchV2Invocation { +export interface EffectiveApplyPatchV2Invocation { invocation: ParsedSshInvocation; argv: string[]; } @@ -278,708 +256,6 @@ const legacyK3sOperationRouteSegments = new Set([ "api-versions", ]); -export const remoteApplyPatchSource = String.raw`#!/bin/sh -set -eu - -allow_loose=0 - -die() { - printf 'apply_patch: %s\n' "$*" >&2 - exit 1 -} - -mk_temp() { - mktemp "${"$"}{TMPDIR:-/tmp}/unidesk-apply-patch.XXXXXX" || exit 1 -} - -ensure_parent() { - case "$1" in - */*) - dir=${"$"}{1%/*} - [ -n "$dir" ] || dir=/ - mkdir -p "$dir" - ;; - esac -} - -read_text_preserve_newlines() { - marker="__UNIDESK_APPLY_PATCH_EOF_$$__" - text=$(cat "$1"; printf '%s' "$marker") || die "failed to read $1" - printf '%s' "${"$"}{text%"$marker"}" -} - -write_file() { - target=$1 - source=$2 - ensure_parent "$target" - cp "$source" "$target" || die "failed to write $target" -} - -line_number_for_prefix() { - newline_count=$(printf '%s' "$1" | tr -cd '\n' | wc -c | tr -d ' ') - 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 - replace_file=$3 - 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"} - search=$(cat "$search_file"; printf '%s' "$marker") || die "failed to read hunk search" - search=${"$"}{search%"$marker"} - replacement=$(cat "$replace_file"; printf '%s' "$marker") || die "failed to read hunk replacement" - replacement=${"$"}{replacement%"$marker"} - - if [ -z "$search" ]; then - [ "$allow_loose" = 1 ] || die "hunk $hunk_id in $target has no context; add unchanged/deleted anchor lines or pass --allow-loose after manual review" - printf 'apply_patch: hunk %s matched %s:1 (loose)\n' "$hunk_id" "$target" >&2 - new=$replacement$old - else - case "$old" in - *"$search"*) - prefix=${"$"}{old%%"$search"*} - suffix=${"$"}{old#*"$search"} - case "$suffix" in - *"$search"*) - [ "$allow_loose" = 1 ] || die "hunk $hunk_id context matched multiple locations in $target; add more unchanged context or pass --allow-loose after manual review" - ;; - esac - printf 'apply_patch: hunk %s matched %s:%s\n' "$hunk_id" "$target" "$(line_number_for_prefix "$prefix")" >&2 - new=$prefix$replacement$suffix - ;; - *) - die "hunk $hunk_id context not found in $target" - ;; - esac - fi - - out=$(mk_temp) - printf '%s' "$new" > "$out" || die "failed to render patched file" - write_file "$target" "$out" - rm -f "$out" -} - -apply_update() { - target=$1 - body=$2 - in_hunk=0 - search_file= - replace_file= - changed=0 - hunk_number=0 - has_add=0 - has_delete=0 - seen_add=0 - anchor_before_add=0 - anchor_after_add=0 - explicit_eof=0 - - finish_hunk() { - [ "$in_hunk" = 1 ] || return 0 - if [ "$allow_loose" != 1 ]; then - [ -s "$search_file" ] || die "hunk $hunk_number in $target has no context; add unchanged/deleted anchor lines or pass --allow-loose after manual review" - if [ "$has_add" = 1 ] && [ "$has_delete" = 0 ]; then - if [ "$anchor_before_add" != 1 ] || { [ "$anchor_after_add" != 1 ] && [ "$explicit_eof" != 1 ]; }; then - die "hunk $hunk_number in $target is insert-only without both leading and trailing context; include nearby unchanged lines after the insertion or pass --allow-loose after manual review" - fi - fi - fi - replace_once "$target" "$search_file" "$replace_file" "$hunk_number" - rm -f "$search_file" "$replace_file" - search_file= - replace_file= - in_hunk=0 - changed=1 - } - - while IFS= read -r line || [ -n "$line" ]; do - case "$line" in - "*** End of File"*) - continue - ;; - "@@"*) - finish_hunk - search_file=$(mk_temp) - replace_file=$(mk_temp) - hunk_number=$((hunk_number + 1)) - has_add=0 - has_delete=0 - seen_add=0 - anchor_before_add=0 - anchor_after_add=0 - explicit_eof=0 - in_hunk=1 - continue - ;; - esac - [ "$in_hunk" = 1 ] || die "expected hunk header in $target" - case "$line" in - " "*) - text=${"$"}{line#?} - printf '%s\n' "$text" >> "$search_file" - printf '%s\n' "$text" >> "$replace_file" - if [ "$seen_add" = 1 ]; then anchor_after_add=1; else anchor_before_add=1; fi - ;; - "-"*) - text=${"$"}{line#?} - printf '%s\n' "$text" >> "$search_file" - has_delete=1 - if [ "$seen_add" = 1 ]; then anchor_after_add=1; else anchor_before_add=1; fi - ;; - "+"*) - text=${"$"}{line#?} - printf '%s\n' "$text" >> "$replace_file" - has_add=1 - seen_add=1 - ;; - "\\ No newline at end of file") - ;; - "*** End of File"*) - explicit_eof=1 - ;; - *) - die "bad hunk line in $target: $line" - ;; - esac - done < "$body" - - finish_hunk - [ "$changed" = 1 ] || [ -e "$target" ] || die "file not found: $target" -} - -is_top_header() { - case "$1" in - "*** Add File: "*|"*** Update File: "*|"*** Delete File: "*|"*** End Patch") - return 0 - ;; - *) - return 1 - ;; - esac -} - -pushed=0 -pushed_line= -next_patch_line() { - if [ "$pushed" = 1 ]; then - line=$pushed_line - pushed=0 - pushed_line= - return 0 - fi - if IFS= read -r line; then - return 0 - fi - [ -n "${"$"}{line:-}" ] -} - -push_patch_line() { - pushed_line=$1 - pushed=1 -} - -parse_add_file() { - target=$1 - [ ! -e "$target" ] || die "file already exists: $target" - tmp=$(mk_temp) - while next_patch_line; do - if is_top_header "$line"; then - push_patch_line "$line" - break - fi - case "$line" in - "+"*) - printf '%s\n' "${"$"}{line#?}" >> "$tmp" - ;; - *) - rm -f "$tmp" - die "add file lines must start with + for $target" - ;; - esac - done - write_file "$target" "$tmp" - rm -f "$tmp" -} - -parse_update_file() { - target=$1 - move_to= - body=$(mk_temp) - if next_patch_line; then - case "$line" in - "*** Move to: "*) - move_to=${"$"}{line#"*** Move to: "} - ;; - *) - push_patch_line "$line" - ;; - esac - fi - while next_patch_line; do - if is_top_header "$line"; then - push_patch_line "$line" - break - fi - case "$line" in - "*** Move to: "*) - rm -f "$body" - die "move marker must appear before update hunks" - ;; - *) - printf '%s\n' "$line" >> "$body" - ;; - esac - done - if [ -s "$body" ]; then - apply_update "$target" "$body" - elif [ -z "$move_to" ] && [ ! -e "$target" ]; then - rm -f "$body" - die "file not found: $target" - fi - rm -f "$body" - if [ -n "$move_to" ]; then - [ -e "$target" ] || die "file not found: $target" - [ ! -e "$move_to" ] || die "target file already exists: $move_to" - ensure_parent "$move_to" - mv "$target" "$move_to" || die "failed to move $target to $move_to" - fi -} - -main() { - while [ "$#" -gt 0 ]; do - case "${"$"}1" in - -h|--help) - printf 'apply_patch: sh-only helper; reads *** Begin Patch format from stdin; --allow-loose bypasses low-context hunk guards\n' - return 0 - ;; - --allow-loose) - allow_loose=1 - shift - ;; - *) - die "unsupported option: ${"$"}1" - ;; - esac - done - next_patch_line || die "patch must start with *** Begin Patch" - [ "$line" = "*** Begin Patch" ] || die "patch must start with *** Begin Patch" - while next_patch_line; do - case "$line" in - "*** End Patch") - printf 'Done!\n' - return 0 - ;; - "*** Add File: "*) - parse_add_file "${"$"}{line#"*** Add File: "}" - ;; - "*** Delete File: "*) - target=${"$"}{line#"*** Delete File: "} - rm -f "$target" || die "failed to delete $target" - ;; - "*** Update File: "*) - parse_update_file "${"$"}{line#"*** Update File: "}" - ;; - *) - die "unexpected patch line: $line" - ;; - esac - done - die "missing *** End Patch" -} - -main "$@" -`; - -const remoteGlobSource = String.raw`#!/usr/bin/env python3 -import argparse -import glob -import os -import sys - - -def main(): - parser = argparse.ArgumentParser(description="remote glob helper for UniDesk ssh passthrough") - parser.add_argument("patterns", nargs="*", help="glob patterns relative to --root unless absolute") - parser.add_argument("--root", default=".", help="base directory for relative patterns") - parser.add_argument("--pattern", action="append", default=[], help="additional glob pattern") - parser.add_argument("--contains", action="append", default=[], help="match path names containing text") - parser.add_argument("--icontains", action="append", default=[], help="case-insensitive contains match") - parser.add_argument("--type", choices=["any", "f", "d"], default="any", help="filter by any/file/dir") - parser.add_argument("--limit", type=int, default=0, help="maximum number of rows to print") - parser.add_argument("--sort", action="store_true", help="sort output") - parser.add_argument("--absolute", action="store_true", help="print absolute paths") - args = parser.parse_args() - - if args.limit < 0: - print("glob: --limit must be >= 0", file=sys.stderr) - return 2 - - root = os.path.abspath(args.root) - patterns = list(args.patterns) + list(args.pattern) - for text in args.contains: - patterns.append(f"**/*{text}*") - for text in args.icontains: - # Python glob is case-sensitive on Linux, so filter from a broad recursive scan. - patterns.append("**/*") - if not patterns: - patterns = ["*"] - - seen = set() - rows = [] - lowered_contains = [text.lower() for text in args.icontains] - for pattern in patterns: - effective = pattern if os.path.isabs(pattern) else os.path.join(root, pattern) - for path in glob.iglob(effective, recursive=True): - full = os.path.abspath(path) - if full in seen: - continue - if lowered_contains and not any(text in os.path.basename(full).lower() or text in full.lower() for text in lowered_contains): - continue - if args.type == "f" and not os.path.isfile(full): - continue - if args.type == "d" and not os.path.isdir(full): - continue - seen.add(full) - rows.append(full if args.absolute else os.path.relpath(full, root)) - - if args.sort: - rows.sort() - if args.limit > 0: - rows = rows[:args.limit] - for row in rows: - print(row) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) -`; - -const remoteSkillDiscoverSource = String.raw`#!/usr/bin/env python3 -import argparse -import getpass -import json -import os -import platform -import socket -import sys -from datetime import datetime, timezone -from pathlib import Path - - -SKIP_PARTS = {"node_modules", ".git", ".state", "logs", "references", "__pycache__"} - - -def is_wsl(): - try: - release = Path("/proc/sys/kernel/osrelease").read_text(errors="ignore").lower() - except Exception: - release = "" - return "microsoft" in release or "wsl" in release or "WSL_INTEROP" in os.environ - - -def to_windows_path(path): - text = str(path) - if text.startswith("/mnt/") and len(text) >= 7 and text[5].isalpha() and text[6] == "/": - drive = text[5].upper() - rest = text[7:].replace("/", "\\") - return drive + ":\\" + rest - return None - - -def read_bounded(path, limit=16384): - try: - data = path.read_bytes()[:limit] - return data.decode("utf-8", errors="replace") - except Exception: - return "" - - -def frontmatter_value(line): - if ":" not in line: - return None - key, value = line.split(":", 1) - return key.strip().lower(), value.strip().strip("\"'") - - -def parse_skill_metadata(skill_md): - text = read_bounded(skill_md) - name = skill_md.parent.name - description = "" - lines = text.splitlines() - if lines and lines[0].strip() == "---": - for line in lines[1:]: - if line.strip() == "---": - break - item = frontmatter_value(line) - if item is None: - continue - key, value = item - if key == "name" and value: - name = value - if key == "description" and value: - description = value - if not description: - for line in lines: - stripped = line.strip() - if stripped and not stripped.startswith("---") and not stripped.startswith("#"): - description = stripped - break - return name, description - - -def iter_skill_files(root, max_depth): - try: - iterator = root.rglob("SKILL.md") - for skill_md in iterator: - try: - rel = skill_md.relative_to(root) - except ValueError: - continue - directory_parts = rel.parts[:-1] - if len(directory_parts) == 0 or len(directory_parts) > max_depth: - continue - if any(part in SKIP_PARTS for part in directory_parts): - continue - yield skill_md - except Exception as exc: - raise RuntimeError(str(exc)) from exc - - -def unique_paths(paths): - seen = set() - output = [] - for raw in paths: - path = Path(raw).expanduser() - key = str(path) - if key in seen: - continue - seen.add(key) - output.append(path) - return output - - -def default_wsl_roots(): - home = Path.home() - roots = [home / ".agents" / "skills", home / ".codex" / "skills"] - for raw in ("/root/.agents/skills", "/root/.codex/skills"): - path = Path(raw) - if str(path) not in {str(item) for item in roots}: - roots.append(path) - return roots - - -def default_windows_roots(): - if not is_wsl(): - return [] - users = Path("/mnt/c/Users") - roots = [] - try: - children = list(users.iterdir()) if users.exists() else [] - except Exception: - children = [] - for child in children: - try: - if not child.is_dir(): - continue - except Exception: - continue - lower = child.name.lower() - if lower in {"all users", "default", "default user", "public"}: - continue - roots.append(child / ".agents" / "skills") - roots.append(child / ".codex" / "skills") - return roots - - -def scan_root(scope, root, max_depth): - try: - root_exists = root.exists() - root_error = None - except Exception as exc: - root_exists = False - root_error = str(exc) - record = { - "scope": scope, - "path": str(root), - "windowsPath": to_windows_path(root), - "exists": root_exists, - "skillCount": 0, - "error": root_error, - } - skills = [] - if not record["exists"]: - return record, skills - try: - for skill_md in iter_skill_files(root, max_depth): - name, description = parse_skill_metadata(skill_md) - skill = { - "scope": scope, - "name": name, - "description": description, - "path": str(skill_md.parent), - "skillMd": str(skill_md), - "windowsPath": to_windows_path(skill_md.parent), - "root": str(root), - } - skills.append(skill) - except Exception as exc: - record["error"] = str(exc) - record["skillCount"] = len(skills) - return record, skills - - -def main(): - parser = argparse.ArgumentParser(description="discover WSL/Linux and Windows skill directories from a UniDesk ssh passthrough session") - parser.add_argument("--scope", choices=["all", "wsl", "windows"], default="all", help="which skill roots to scan") - parser.add_argument("--max-depth", type=int, default=4, help="maximum directory depth below each skill root") - parser.add_argument("--limit", type=int, default=0, help="maximum skill rows to return; 0 means unlimited") - parser.add_argument("--root", action="append", default=[], help="extra WSL/Linux skill root") - parser.add_argument("--windows-root", action="append", default=[], help="extra Windows skill root, expressed as /mnt//...") - args = parser.parse_args() - - if args.max_depth <= 0: - print(json.dumps({"ok": False, "error": "--max-depth must be positive"}, ensure_ascii=False)) - return 2 - if args.limit < 0: - print(json.dumps({"ok": False, "error": "--limit must be >= 0"}, ensure_ascii=False)) - return 2 - - roots = [] - if args.scope in ("all", "wsl"): - roots.extend(("wsl", path) for path in default_wsl_roots()) - roots.extend(("wsl", Path(raw).expanduser()) for raw in args.root) - if args.scope in ("all", "windows"): - roots.extend(("windows", path) for path in default_windows_roots()) - roots.extend(("windows", Path(raw).expanduser()) for raw in args.windows_root) - - seen = set() - unique = [] - for scope, path in roots: - key = (scope, str(path)) - if key in seen: - continue - seen.add(key) - unique.append((scope, path)) - - root_records = [] - skills = [] - for scope, root in unique: - record, found = scan_root(scope, root, args.max_depth) - root_records.append(record) - skills.extend(found) - - scope_order = {"wsl": 0, "windows": 1} - skills.sort(key=lambda item: (scope_order.get(str(item["scope"]), 9), str(item["name"]).lower(), str(item["path"]))) - total_before_limit = len(skills) - if args.limit > 0: - skills = skills[:args.limit] - - counts = {"total": len(skills), "totalBeforeLimit": total_before_limit, "wsl": 0, "windows": 0} - for skill in skills: - scope = str(skill["scope"]) - if scope in counts: - counts[scope] += 1 - - payload = { - "ok": True, - "command": "unidesk ssh skills", - "generatedAt": datetime.now(timezone.utc).isoformat(), - "node": { - "hostname": socket.gethostname(), - "user": getpass.getuser(), - "home": str(Path.home()), - "platform": platform.platform(), - "isWsl": is_wsl(), - "python": sys.version.split()[0], - }, - "counts": counts, - "roots": root_records, - "skills": skills, - } - print(json.dumps(payload, ensure_ascii=False, indent=2)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) -`; - const sshOptionsWithValue = new Set([ "-B", "-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J", "-L", "-l", "-m", "-O", "-o", "-p", "-Q", "-R", "-S", "-W", "-w", ]); @@ -1061,7 +337,7 @@ export function parseSshArgs(args: string[], routeRaw = ""): ParsedSshArg remote.push(arg); } return { - remoteCommand: remote.length === 0 ? null : remote.join(" "), + remoteCommand: remote.length === 0 ? null : shellArgv(remote), requiresStdin: false, invocationKind: remote.length === 0 ? "interactive" : "ssh-like", }; @@ -1257,7 +533,7 @@ function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs }; } return { - remoteCommand: buildWindowsPowerShellInvocation(buildWindowsCmdLauncherScript(buildWindowsCmdLine(commandArgs.join(" "), route.workspace))), + remoteCommand: buildWindowsPowerShellInvocation(buildWindowsCmdLauncherScript(buildWindowsCmdLine(commandArgs.map((value) => windowsCmdArgument(value, route)).join(" "), route.workspace))), requiresStdin: false, invocationKind: "argv", }; @@ -1313,51 +589,6 @@ function validateWindowsGitCommitArgs(route: ParsedSshRoute, gitArgs: string[]): throw new Error(`ssh win git commit would open an editor; use ${entrypoint} ${route.raw} git commit -m , ${entrypoint} ${route.raw} git commit --no-edit, or wrap an interactive command with ${entrypoint} ${route.raw} ps <<'PS'`); } -type WindowsFsReadOnlyOperation = "pwd" | "ls" | "cat" | "head" | "tail" | "stat" | "wc" | "rg"; -const windowsFsReadOnlyOperations = new Set(["pwd", "ls", "cat", "head", "tail", "stat", "wc", "rg"]); -const windowsFsReadOnlyDefaultMaxBytes = 256 * 1024; - -function isWindowsFsReadOnlyOperation(operation: string): operation is WindowsFsReadOnlyOperation { - return windowsFsReadOnlyOperations.has(operation); -} - -export function windowsFsReadOnlyScript(basePath: string | null, operation: WindowsFsReadOnlyOperation, args: string[]): string { - const argsJsonB64 = Buffer.from(JSON.stringify(args), "utf8").toString("base64"); - const commonScript = [ - "$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)};`, - `$argsJsonB64 = ${powerShellSingleQuote(argsJsonB64)};`, - "$toolArgs = @();", - "if (-not [string]::IsNullOrEmpty($argsJsonB64)) { $json = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($argsJsonB64)); $decoded = ConvertFrom-Json -InputObject $json; if ($null -ne $decoded) { foreach ($item in @($decoded)) { $toolArgs += [string]$item } } }", - "function Fail([string]$Message, [int]$Code) { [Console]::Error.WriteLine($Message); exit $Code }", - "function Resolve-UnideskPath([string]$Raw) { if ([string]::IsNullOrWhiteSpace($Raw) -or $Raw -eq '.') { if (-not [string]::IsNullOrWhiteSpace($basePath)) { return [System.IO.Path]::GetFullPath($basePath) }; return (Get-Location).ProviderPath }; 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 Need-File([string]$Path) { if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { Fail ('file not found: ' + $Path) 1 } }", - "function Get-Sha256([string]$Path) { return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() }", - "function Read-StrictText([string]$Path, [Int64]$MaxBytes) { Need-File $Path; $info = [System.IO.FileInfo]$Path; if ($MaxBytes -gt 0 -and $info.Length -gt $MaxBytes) { Fail ('file too large for windows fs read operation: ' + $Path + ' bytes=' + $info.Length + ' maxBytes=' + $MaxBytes + '; use head/tail/download or --max-bytes') 23 }; $bytes = [System.IO.File]::ReadAllBytes($Path); if ([Array]::IndexOf($bytes, [byte]0) -ge 0) { Fail ('binary file refused by windows fs read operation: ' + $Path) 24 }; $utf8 = [System.Text.UTF8Encoding]::new($false, $true); try { return $utf8.GetString($bytes) } catch { Fail ('file is not valid UTF-8: ' + $Path + ': ' + $_.Exception.Message) 25 } }", - "function Try-Read-StrictText([string]$Path, [Int64]$MaxBytes) { if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null }; $info = [System.IO.FileInfo]$Path; if ($MaxBytes -gt 0 -and $info.Length -gt $MaxBytes) { return $null }; $bytes = [System.IO.File]::ReadAllBytes($Path); if ([Array]::IndexOf($bytes, [byte]0) -ge 0) { return $null }; $utf8 = [System.Text.UTF8Encoding]::new($false, $true); try { return $utf8.GetString($bytes) } catch { return $null } }", - "function Parse-PositiveInt([string]$Name, [string]$Value, [int]$Max) { $parsed = 0; if (-not [Int32]::TryParse($Value, [ref]$parsed) -or $parsed -lt 0 -or ($Max -gt 0 -and $parsed -gt $Max)) { Fail ($Name + ' must be an integer from 0 to ' + $Max) 2 }; return $parsed }", - "function Print-Lines([string[]]$Lines) { if ($Lines.Count -gt 0) { [Console]::Out.Write(([string]::Join(\"`n\", $Lines)) + \"`n\") } }", - "function Text-Lines([string]$Text) { $lines = New-Object System.Collections.Generic.List[string]; foreach ($line in ($Text -split \"`r?`n\", -1)) { $lines.Add($line) | Out-Null }; if ($lines.Count -gt 0 -and $lines[$lines.Count - 1] -eq '') { $lines.RemoveAt($lines.Count - 1) }; return [string[]]$lines }", - "function Path-Args([int]$Start) { $paths = New-Object System.Collections.Generic.List[string]; for ($i = $Start; $i -lt $toolArgs.Count; $i++) { $paths.Add([string]$toolArgs[$i]) | Out-Null }; return [string[]]$paths }", - ]; - const operationScript: Record = { - pwd: ["if ($toolArgs.Count -ne 0) { Fail 'pwd accepts no arguments on Windows routes' 2 }; [Console]::Out.WriteLine((Resolve-UnideskPath '.'))"], - ls: ["$all = $false; $limit = 200; $path = '.'; for ($i = 0; $i -lt $toolArgs.Count; $i++) { $arg = [string]$toolArgs[$i]; if ($arg -eq '-a' -or $arg -eq '--all') { $all = $true; continue }; if ($arg -eq '-l' -or $arg -eq '-la' -or $arg -eq '-al') { if ($arg.Contains('a')) { $all = $true }; continue }; if ($arg -eq '--limit') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--limit requires a value' 2 }; $limit = Parse-PositiveInt '--limit' ([string]$toolArgs[$i]) 5000; continue }; if ($arg.StartsWith('--limit=')) { $limit = Parse-PositiveInt '--limit' $arg.Substring(8) 5000; continue }; if ($arg.StartsWith('-') -and $arg -ne '-') { Fail ('unsupported ls option on Windows route: ' + $arg) 2 }; $path = $arg }; $target = Resolve-UnideskPath $path; if (-not (Test-Path -LiteralPath $target)) { Fail ('path not found: ' + $target) 1 }; $items = if (Test-Path -LiteralPath $target -PathType Container) { if ($all) { @(Get-ChildItem -LiteralPath $target -Force | Sort-Object -Property Name) } else { @(Get-ChildItem -LiteralPath $target | Sort-Object -Property Name) } } else { @(Get-Item -LiteralPath $target) }; [Console]::Out.WriteLine('TYPE BYTES UPDATED NAME'); $count = 0; foreach ($item in $items) { if ($count -ge $limit) { [Console]::Error.WriteLine('UNIDESK_WINDOWS_FS_TRUNCATED ls limit=' + $limit); break }; $type = if ($item.PSIsContainer) { 'dir' } else { 'file' }; $bytes = if ($item.PSIsContainer) { '-' } else { [string]$item.Length }; [Console]::Out.WriteLine($type + ' ' + $bytes + ' ' + $item.LastWriteTimeUtc.ToString('o') + ' ' + $item.Name); $count += 1 }"], - cat: ["$maxBytes = " + String(windowsFsReadOnlyDefaultMaxBytes) + "; $paths = New-Object System.Collections.Generic.List[string]; for ($i = 0; $i -lt $toolArgs.Count; $i++) { $arg = [string]$toolArgs[$i]; if ($arg -eq '--max-bytes') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--max-bytes requires a value' 2 }; $maxBytes = Parse-PositiveInt '--max-bytes' ([string]$toolArgs[$i]) 16777216; continue }; if ($arg.StartsWith('--max-bytes=')) { $maxBytes = Parse-PositiveInt '--max-bytes' $arg.Substring(12) 16777216; continue }; if ($arg.StartsWith('-') -and $arg -ne '-') { Fail ('unsupported cat option on Windows route: ' + $arg) 2 }; $paths.Add($arg) | Out-Null }; if ($paths.Count -ne 1) { Fail 'cat requires exactly one file path on Windows routes' 2 }; [Console]::Out.Write((Read-StrictText (Resolve-UnideskPath $paths[0]) $maxBytes))"], - head: ["$linesWanted = 10; $paths = New-Object System.Collections.Generic.List[string]; for ($i = 0; $i -lt $toolArgs.Count; $i++) { $arg = [string]$toolArgs[$i]; if ($arg -eq '-n' -or $arg -eq '--lines') { $i += 1; if ($i -ge $toolArgs.Count) { Fail ($arg + ' requires a value') 2 }; $linesWanted = Parse-PositiveInt $arg ([string]$toolArgs[$i]) 10000; continue }; if ($arg.StartsWith('-n') -and $arg.Length -gt 2) { $linesWanted = Parse-PositiveInt '-n' $arg.Substring(2) 10000; continue }; if ($arg.StartsWith('--lines=')) { $linesWanted = Parse-PositiveInt '--lines' $arg.Substring(8) 10000; continue }; if ($arg.StartsWith('-') -and $arg -ne '-') { Fail ('unsupported head option on Windows route: ' + $arg) 2 }; $paths.Add($arg) | Out-Null }; if ($paths.Count -ne 1) { Fail 'head requires exactly one file path on Windows routes' 2 }; $lines = Text-Lines (Read-StrictText (Resolve-UnideskPath $paths[0]) 1048576); Print-Lines ([string[]]($lines | Select-Object -First $linesWanted))"], - tail: ["$linesWanted = 10; $paths = New-Object System.Collections.Generic.List[string]; for ($i = 0; $i -lt $toolArgs.Count; $i++) { $arg = [string]$toolArgs[$i]; if ($arg -eq '-n' -or $arg -eq '--lines') { $i += 1; if ($i -ge $toolArgs.Count) { Fail ($arg + ' requires a value') 2 }; $linesWanted = Parse-PositiveInt $arg ([string]$toolArgs[$i]) 10000; continue }; if ($arg.StartsWith('-n') -and $arg.Length -gt 2) { $linesWanted = Parse-PositiveInt '-n' $arg.Substring(2) 10000; continue }; if ($arg.StartsWith('--lines=')) { $linesWanted = Parse-PositiveInt '--lines' $arg.Substring(8) 10000; continue }; if ($arg.StartsWith('-') -and $arg -ne '-') { Fail ('unsupported tail option on Windows route: ' + $arg) 2 }; $paths.Add($arg) | Out-Null }; if ($paths.Count -ne 1) { Fail 'tail requires exactly one file path on Windows routes' 2 }; $lines = Text-Lines (Read-StrictText (Resolve-UnideskPath $paths[0]) 1048576); Print-Lines ([string[]]($lines | Select-Object -Last $linesWanted))"], - rg: ["$ignoreCase = $false; $fixed = $false; $maxCount = 1000; $maxFiles = 5000; $maxBytes = 1048576; $pattern = $null; $paths = New-Object System.Collections.Generic.List[string]; $endOptions = $false; for ($i = 0; $i -lt $toolArgs.Count; $i++) { $arg = [string]$toolArgs[$i]; if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--') { $endOptions = $true; continue }; if ($null -eq $pattern -and -not $endOptions -and ($arg -eq '-i' -or $arg -eq '--ignore-case')) { $ignoreCase = $true; continue }; if ($null -eq $pattern -and -not $endOptions -and ($arg -eq '-F' -or $arg -eq '--fixed-strings')) { $fixed = $true; continue }; if ($null -eq $pattern -and -not $endOptions -and ($arg -eq '-n' -or $arg -eq '--line-number')) { continue }; if ($null -eq $pattern -and -not $endOptions -and ($arg -eq '-m' -or $arg -eq '--max-count')) { $i += 1; if ($i -ge $toolArgs.Count) { Fail ($arg + ' requires a value') 2 }; $maxCount = Parse-PositiveInt $arg ([string]$toolArgs[$i]) 10000; continue }; if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--max-count=')) { $maxCount = Parse-PositiveInt '--max-count' $arg.Substring(12) 10000; continue }; if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('-m') -and $arg.Length -gt 2) { $maxCount = Parse-PositiveInt '-m' $arg.Substring(2) 10000; continue }; if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--max-files') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--max-files requires a value' 2 }; $maxFiles = Parse-PositiveInt '--max-files' ([string]$toolArgs[$i]) 50000; continue }; if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--max-files=')) { $maxFiles = Parse-PositiveInt '--max-files' $arg.Substring(12) 50000; continue }; if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--max-bytes') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--max-bytes requires a value' 2 }; $maxBytes = Parse-PositiveInt '--max-bytes' ([string]$toolArgs[$i]) 16777216; continue }; if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--max-bytes=')) { $maxBytes = Parse-PositiveInt '--max-bytes' $arg.Substring(12) 16777216; continue }; if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('-')) { Fail ('unsupported rg option on Windows route: ' + $arg) 2 }; if ($null -eq $pattern) { $pattern = $arg } else { $paths.Add($arg) | Out-Null } }; if ($null -eq $pattern) { Fail 'rg requires a pattern on Windows routes' 2 }; if ($paths.Count -eq 0) { $paths.Add('.') | Out-Null }; $regexPattern = if ($fixed) { [regex]::Escape($pattern) } else { $pattern }; $regexOptions = [System.Text.RegularExpressions.RegexOptions]::CultureInvariant; if ($ignoreCase) { $regexOptions = $regexOptions -bor [System.Text.RegularExpressions.RegexOptions]::IgnoreCase }; try { $regex = [regex]::new($regexPattern, $regexOptions) } catch { Fail ('invalid rg pattern on Windows route: ' + $_.Exception.Message) 2 }; $files = New-Object System.Collections.Generic.List[string]; $fileTruncated = $false; foreach ($raw in $paths) { $target = Resolve-UnideskPath $raw; if (Test-Path -LiteralPath $target -PathType Leaf) { if ($files.Count -lt $maxFiles) { $files.Add($target) | Out-Null } else { $fileTruncated = $true; break } } elseif (Test-Path -LiteralPath $target -PathType Container) { foreach ($child in Get-ChildItem -LiteralPath $target -Recurse -File -ErrorAction SilentlyContinue) { if ($files.Count -ge $maxFiles) { $fileTruncated = $true; break }; $files.Add($child.FullName) | Out-Null } } else { Fail ('path not found: ' + $target) 1 }; if ($fileTruncated) { break } }; if ($fileTruncated) { [Console]::Error.WriteLine('UNIDESK_WINDOWS_FS_TRUNCATED rg maxFiles=' + $maxFiles) }; $matchCount = 0; $skipped = 0; $truncated = $false; foreach ($file in $files) { $text = Try-Read-StrictText $file $maxBytes; if ($null -eq $text) { $skipped += 1; continue }; $lines = Text-Lines $text; for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex += 1) { if ($regex.IsMatch($lines[$lineIndex])) { [Console]::Out.WriteLine($file + ':' + ($lineIndex + 1) + ':' + $lines[$lineIndex]); $matchCount += 1; if ($matchCount -ge $maxCount) { $truncated = $true; break } } }; if ($truncated) { break } }; if ($skipped -gt 0) { [Console]::Error.WriteLine('UNIDESK_WINDOWS_FS_SKIPPED rg files=' + $skipped + ' reason=binary-invalid-utf8-or-too-large') }; if ($truncated) { [Console]::Error.WriteLine('UNIDESK_WINDOWS_FS_TRUNCATED rg maxCount=' + $maxCount) }; if ($matchCount -eq 0) { exit 1 }"], - stat: ["$paths = Path-Args 0; if ($paths.Count -eq 0) { Fail 'stat requires at least one path on Windows routes' 2 }; [Console]::Out.WriteLine('TYPE BYTES SHA256 PATH'); foreach ($raw in $paths) { $path = Resolve-UnideskPath $raw; if (-not (Test-Path -LiteralPath $path)) { Fail ('path not found: ' + $path) 1 }; $item = Get-Item -LiteralPath $path; if ($item.PSIsContainer) { [Console]::Out.WriteLine('dir - - ' + $path) } else { [Console]::Out.WriteLine('file ' + $item.Length + ' ' + (Get-Sha256 $path) + ' ' + $path) } }"], - wc: ["$paths = Path-Args 0; if ($paths.Count -eq 0) { Fail 'wc requires at least one file path on Windows routes' 2 }; [Console]::Out.WriteLine('LINES WORDS CHARS BYTES PATH'); foreach ($raw in $paths) { $path = Resolve-UnideskPath $raw; $text = Read-StrictText $path 1048576; $bytes = ([System.IO.FileInfo]$path).Length; $lineCount = [regex]::Matches($text, \"`n\").Count; if ($text.Length -gt 0 -and -not $text.EndsWith(\"`n\")) { $lineCount += 1 }; $wordCount = [regex]::Matches($text, '\\S+').Count; [Console]::Out.WriteLine(([string]$lineCount) + ' ' + $wordCount + ' ' + $text.Length + ' ' + $bytes + ' ' + $path) }"], - }; - return [...commonScript, ...operationScript[operation]].join(" "); -} - function buildWindowsCmdLine(userCommand: string, cwd: string | null): string { const parts = [ "chcp 65001>nul", @@ -1383,9 +614,11 @@ function windowsCmdArgument(value: string, route: ParsedSshRoute): string { return /\s/u.test(value) ? `"${value}"` : value; } -function parseWindowsSkillsOptions(args: string[]): { limit: number; scopes: Array<"agents" | "codex"> } { +function parseWindowsSkillsOptions(args: string[]): { limit: number; scopes: Array<"agents" | "codex">; name: string | null; filter: string | null } { let limit = 100; let scope: "agents" | "codex" | "all" = "agents"; + let name: string | null = null; + let filter: string | null = null; for (let index = 0; index < args.length; index += 1) { const arg = args[index] ?? ""; if (arg === "--limit") { @@ -1407,9 +640,18 @@ function parseWindowsSkillsOptions(args: string[]): { limit: number; scopes: Arr scope = "all"; continue; } + if (arg === "--name" || arg === "--filter") { + const value = args[index + 1]; + if (value === undefined || value.length === 0) throw new Error(`ssh win skills ${arg} requires a value`); + if (arg === "--name") name = value; + else filter = value; + index += 1; + continue; + } throw new Error(`unsupported ssh win skills option: ${arg}`); } - return { limit, scopes: scope === "all" ? ["agents", "codex"] : [scope] }; + if (name !== null && filter !== null) throw new Error("ssh win skills accepts only one of --name or --filter"); + return { limit, scopes: scope === "all" ? ["agents", "codex"] : [scope], name, filter }; } function buildWindowsSkillsDiscoveryScript(args: string[]): string { @@ -1425,6 +667,8 @@ function buildWindowsSkillsDiscoveryScript(args: string[]): string { "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new();", "$OutputEncoding = [System.Text.UTF8Encoding]::new();", `$limit = ${options.limit};`, + `$exactName = ${powerShellSingleQuote(options.name ?? "")};`, + `$filter = ${powerShellSingleQuote(options.filter ?? "")};`, `$roots = @(${rootEntries});`, "$rootRecords = @();", "$skills = @();", @@ -1445,15 +689,17 @@ function buildWindowsSkillsDiscoveryScript(args: string[]): string { " if ($line -match '^description:\\s*(.+)$') { $description = $Matches[1].Trim(); continue };", " }", " }", + " if (-not [string]::IsNullOrWhiteSpace($exactName) -and $name -ine $exactName -and $dir.Name -ine $exactName) { continue };", + " if (-not [string]::IsNullOrWhiteSpace($filter) -and $name.IndexOf($filter, [StringComparison]::OrdinalIgnoreCase) -lt 0 -and $dir.Name.IndexOf($filter, [StringComparison]::OrdinalIgnoreCase) -lt 0 -and $description.IndexOf($filter, [StringComparison]::OrdinalIgnoreCase) -lt 0) { continue };", " $skills += [pscustomobject]@{ scope = $root.Scope; name = $name; directoryName = $dir.Name; path = $dir.FullName; skillFile = $skillFile; hasSkillMd = $hasSkillMd; description = $description };", " }", "}", - "$payload = [pscustomobject]@{ ok = $true; command = 'unidesk ssh win skills'; generatedAt = (Get-Date).ToUniversalTime().ToString('o'); user = $env:USERNAME; userProfile = $env:USERPROFILE; counts = [pscustomobject]@{ roots = $rootRecords.Count; skills = $skills.Count; limit = $limit }; roots = $rootRecords; skills = @($skills) };", + "$payload = [pscustomobject]@{ ok = $true; command = 'unidesk ssh win skills'; generatedAt = (Get-Date).ToUniversalTime().ToString('o'); user = $env:USERNAME; userProfile = $env:USERPROFILE; query = [pscustomobject]@{ name = $exactName; filter = $filter }; counts = [pscustomobject]@{ roots = $rootRecords.Count; skills = $skills.Count; limit = $limit }; roots = $rootRecords; skills = @($skills) };", "$payload | ConvertTo-Json -Depth 6;", ].join(" "); } -function powerShellSingleQuote(value: string): string { +export function powerShellSingleQuote(value: string): string { return `'${value.replace(/'/g, "''")}'`; } @@ -1969,7 +1215,7 @@ function k3sRouteExecOperationArgs(args: string[]): string[] { throw new Error("ssh k3s target exec operation requires a command to exec"); } -function effectiveApplyPatchV2Invocation(invocation: ParsedSshInvocation, argv: string[]): EffectiveApplyPatchV2Invocation { +export function effectiveApplyPatchV2Invocation(invocation: ParsedSshInvocation, argv: string[]): EffectiveApplyPatchV2Invocation { if (invocation.route.plane !== "k3s" || isApplyPatchV2HelpArgs(argv)) return { invocation, argv }; let container: string | null = null; let workspace: string | null = null; @@ -2584,1466 +1830,37 @@ export function wrapSshRemoteCommand(command: string | null, helpers: readonly S return `${prefix}stty -echo 2>/dev/null || true; ${command}`; } -function safeProviderId(providerId: string): string { +export function safeProviderId(providerId: string): string { return /^[A-Za-z0-9_.-]{1,64}$/u.test(providerId) ? providerId : ""; } -function classifySshLikeFailure(exitCode: number, stderrText: string): SshFailureHint["trigger"] | null { - const normalized = stderrText.toLowerCase(); - if ( - normalized.includes("kex_exchange_identification") - || normalized.includes("ssh_exchange_identification") - || normalized.includes("connection closed by remote host") - || normalized.includes("connection reset by peer") - || normalized.includes("connection timed out") - || normalized.includes("operation timed out") - || normalized.includes("timed out waiting for provider session") - || normalized.includes("the operation was aborted") - ) { - return "timeout-or-kex"; - } - return exitCode === 255 ? "exit-255" : null; -} - -export function sshFailureHint(providerId: string, parsed: ParsedSshArgs, exitCode: number, stderrText: string): SshFailureHint | null { - if (parsed.invocationKind !== "ssh-like") return null; - const trigger = classifySshLikeFailure(exitCode, stderrText); - if (trigger === null) return null; - const shownProviderId = safeProviderId(providerId); - return { - code: "ssh-like-command-friction", - providerId: shownProviderId, - trigger, - exitCode, - message: "ssh-like remote command failed before proving Host SSH is globally unavailable; prefer structured argv or explicit sh/bash stdin passthrough for non-interactive commands.", - try: `trans ${shownProviderId} sh <<'SH'`, - triage: `bun scripts/cli.ts provider triage ${shownProviderId} --observed-scope ssh --observed-error ''`, - note: "This hint intentionally does not echo the original remote command.", - }; -} - -export function formatSshFailureHint(hint: SshFailureHint): string { - return `UNIDESK_SSH_HINT ${JSON.stringify(hint)}\n`; -} - -export function sshSlowWarningThresholdMs(env: NodeJS.ProcessEnv = process.env): number { - const raw = env.UNIDESK_SSH_SLOW_WARNING_MS; - const parsed = raw === undefined ? NaN : Number(raw); - if (!Number.isFinite(parsed) || parsed <= 0) return defaultSshSlowWarningMs; - return Math.max(1000, Math.trunc(parsed)); -} - -export function sshRuntimeTimingHint(options: { - invocation: ParsedSshInvocation; - transport: SshRuntimeTimingHint["transport"]; - exitCode: number; - startedAtMs: number; - finishedAtMs?: number; - thresholdMs?: number; -}): SshRuntimeTimingHint { - const finishedAtMs = options.finishedAtMs ?? Date.now(); - const thresholdMs = options.thresholdMs ?? sshSlowWarningThresholdMs(); - const elapsedMs = Math.max(0, Math.round(finishedAtMs - options.startedAtMs)); - const elapsedSeconds = Number((elapsedMs / 1000).toFixed(3)); - const slow = elapsedMs > thresholdMs; - const thresholdSeconds = Number((thresholdMs / 1000).toFixed(3)); - return { - code: "ssh-runtime-timing", - level: slow ? "warning" : "info", - providerId: safeProviderId(options.invocation.providerId), - route: options.invocation.route.raw, - transport: options.transport, - invocationKind: options.invocation.parsed.invocationKind, - exitCode: options.exitCode, - elapsedMs, - elapsedSeconds, - thresholdMs, - slow, - message: slow - ? `ssh operation took ${elapsedSeconds}s, above the ${thresholdSeconds}s warning threshold; consider checking provider/session latency, remote command cost, helper bootstrap, or tran/apply-patch optimization before repeating high-frequency operations.` - : `ssh operation completed in ${elapsedSeconds}s.`, - note: "Timing hint is written to stderr and intentionally does not echo the original remote command.", - }; -} - -export function formatSshRuntimeTimingHint(hint: SshRuntimeTimingHint): string { - if (!hint.slow) return ""; - return `UNIDESK_SSH_TIMING ${JSON.stringify(hint)}\n`; -} - -export function sshRuntimeTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { - const raw = env.UNIDESK_SSH_RUNTIME_TIMEOUT_MS ?? env.UNIDESK_TRAN_RUNTIME_TIMEOUT_MS; - const parsed = raw === undefined ? NaN : Number(raw); - if (!Number.isFinite(parsed) || parsed <= 0) return defaultSshRuntimeTimeoutMs; - return Math.min(maxSshRuntimeTimeoutMs, Math.max(1000, Math.trunc(parsed))); -} - -export function sshRuntimeTimeoutHint(options: { - invocation: ParsedSshInvocation; - transport: SshRuntimeTimeoutHint["transport"]; - timeoutMs: number; -}): SshRuntimeTimeoutHint { - const timeoutSeconds = Number((options.timeoutMs / 1000).toFixed(3)); - return { - code: "ssh-runtime-timeout", - level: "warning", - providerId: safeProviderId(options.invocation.providerId), - route: options.invocation.route.raw, - transport: options.transport, - invocationKind: options.invocation.parsed.invocationKind, - timeoutMs: options.timeoutMs, - timeoutSeconds, - message: `ssh/tran operation exceeded the ${timeoutSeconds}s top-level runtime limit and was disconnected.`, - action: "Use short query plus poll semantics; do not keep tran open waiting for long CI/CD, trace, logs, or build progress.", - note: "Timeout hint is written to stderr and intentionally does not echo the original remote command.", - }; -} - -export function formatSshRuntimeTimeoutHint(hint: SshRuntimeTimeoutHint): string { - return `UNIDESK_SSH_RUNTIME_TIMEOUT ${JSON.stringify(hint)}\n`; -} - -export function sshStdoutStreamMaxBytes(env: NodeJS.ProcessEnv = process.env): number { - const raw = env.UNIDESK_SSH_STDOUT_STREAM_MAX_BYTES ?? env.UNIDESK_TRAN_STDOUT_STREAM_MAX_BYTES; - const parsed = raw === undefined ? NaN : Number(raw); - if (!Number.isFinite(parsed) || parsed <= 0) return readCliOutputPolicy().maxStdoutBytes; - return Math.min(maxSshStdoutStreamMaxBytes, Math.max(minSshStdoutStreamMaxBytes, Math.trunc(parsed))); -} - -export function sshStderrStreamMaxBytes(env: NodeJS.ProcessEnv = process.env): number { - const raw = env.UNIDESK_SSH_STDERR_STREAM_MAX_BYTES ?? env.UNIDESK_TRAN_STDERR_STREAM_MAX_BYTES; - const parsed = raw === undefined ? NaN : Number(raw); - if (!Number.isFinite(parsed) || parsed <= 0) return readCliOutputPolicy().maxStdoutBytes; - return Math.min(maxSshStdoutStreamMaxBytes, Math.max(minSshStdoutStreamMaxBytes, Math.trunc(parsed))); -} - -export function sshStdoutTruncationHint(options: { - invocation: ParsedSshInvocation; - transport: SshStdoutTruncationHint["transport"]; - stream?: SshStdoutTruncationHint["stream"]; - thresholdBytes: number; - observedBytesAtTruncation: number; - forwardedBytes?: number; - dumpPath: string | null; - dumpError?: string | null; -}): SshStdoutTruncationHint { - const stream = options.stream ?? "stdout"; - const policy = readCliOutputPolicy(); - return { - code: stream === "stdout" ? "ssh-stdout-truncated" : "ssh-stderr-truncated", - level: "warning", - stream, - providerId: safeProviderId(options.invocation.providerId), - route: options.invocation.route.raw, - transport: options.transport, - invocationKind: options.invocation.parsed.invocationKind, - thresholdBytes: options.thresholdBytes, - observedBytesAtTruncation: options.observedBytesAtTruncation, - forwardedBytes: options.forwardedBytes ?? Math.min(options.thresholdBytes, options.observedBytesAtTruncation), - dumpPath: options.dumpPath, - dumpError: options.dumpError ?? null, - disclosurePolicy: { - name: "unified-cli-dump-preview", - configPath: policy.configPath, - maxPreviewBytes: policy.maxStdoutBytes, - dumpDir: policy.dumpDir, - }, - recommendedRerun: [ - "Use rg -m/--max-count, sed -n, head, tail, --limit, --tail-bytes, or an id-specific query.", - "Use --full, --raw, --tail-bytes, --limit, or UNIDESK_*_STREAM_MAX_BYTES only when you intentionally need a wider one-off disclosure.", - "Read the dumpPath for the complete captured stream.", - ], - message: `ssh ${stream} exceeded the YAML-configured preview budget; ${stream} is bounded and the complete stream is written to a local dump when possible.`, - action: "Inspect dumpPath for the complete stream, or rerun a narrower remote command instead of emitting full logs, huge JSON, or broad search output.", - note: "This hint is written to stderr and intentionally does not echo the original remote command.", - }; -} - -export function formatSshStdoutTruncationHint(hint: SshStdoutTruncationHint): string { - const marker = hint.stream === "stderr" ? "UNIDESK_SSH_STDERR_TRUNCATED" : "UNIDESK_SSH_STDOUT_TRUNCATED"; - return `${marker} ${JSON.stringify(hint)}\n`; -} - -export function sshTruncationCompletionSummary(options: { - invocation: ParsedSshInvocation; - transport: SshTruncationCompletionSummary["transport"]; - exitCode: number; - timedOut: boolean; - startedAtMs: number; - stdout: SshStreamTruncationSummary | null; - stderr: SshStreamTruncationSummary | null; -}): SshTruncationCompletionSummary | null { - if (options.stdout === null && options.stderr === null) return null; - const elapsedMs = Math.max(0, Date.now() - options.startedAtMs); - return { - code: "ssh-truncation-summary", - level: "info", - providerId: safeProviderId(options.invocation.providerId), - route: options.invocation.route.raw, - transport: options.transport, - invocationKind: options.invocation.parsed.invocationKind, - exitCode: options.exitCode, - timedOut: options.timedOut, - elapsedMs, - elapsedSeconds: Math.round(elapsedMs / 100) / 10, - commandOmitted: true, - stdout: options.stdout, - stderr: options.stderr, - message: "SSH output was truncated, but the command has finished; use this completion summary for closeout status and inspect dumpPath only when full output is needed.", - action: "Use exitCode/timedOut plus dumpPath from this summary instead of manually inferring success from a long truncated stream.", - }; -} - -export function formatSshTruncationCompletionSummary(summary: SshTruncationCompletionSummary | null): string { - return summary === null ? "" : `UNIDESK_SSH_TRUNCATION_SUMMARY ${JSON.stringify(summary)}\n`; -} - -export function classifySshTcpPoolFailure(text: string): SshTcpPoolFailureKind | null { - const normalized = text.toLowerCase(); - if (normalized.includes("ssh tcp data channel closed")) return "provider-data-channel-closed"; - if ( - normalized.includes("requested ssh tcp data channel is not ready") - || normalized.includes("ssh tcp data channel is not available") - || normalized.includes('"failurekind":"provider-data-channel-missing"') - ) { - return "provider-data-channel-missing"; - } - if ( - normalized.includes("provider ssh tcp data pool has no idle channel") - || normalized.includes('"failurekind":"provider-data-pool-exhausted"') - ) { - return "provider-data-pool-exhausted"; - } - return null; -} - -export function sshTcpPoolHint(options: { - invocation: ParsedSshInvocation; - transport: SshTcpPoolHint["transport"]; - exitCode: number; - stderrText: string; -}): SshTcpPoolHint | null { - if (options.exitCode === 0) return null; - const failureKind = classifySshTcpPoolFailure(options.stderrText); - if (failureKind === null) return null; - const providerId = safeProviderId(options.invocation.providerId); - return { - code: "ssh-tcp-pool-transient", - level: "warning", - providerId, - route: options.invocation.route.raw, - transport: options.transport, - invocationKind: options.invocation.parsed.invocationKind, - failureKind, - exitCode: options.exitCode, - message: "host.ssh.tcp-pool data channel failed during an SSH operation; classify this as transport/data-pool transient until pool labels and a retry prove otherwise.", - action: "Inspect providerGatewaySshData* labels, then rerun the same idempotent command through the controlled CLI. Do not treat this alone as HWLAB runtime configuration failure.", - retry: `trans ${providerId} argv true`, - diagnostics: { - poolStatus: `bun scripts/cli.ts debug ssh-pool ${providerId}`, - fullHealth: "bun scripts/cli.ts debug health", - }, - note: "Hint is written to stderr and intentionally does not echo the original remote command.", - }; -} - -export function formatSshTcpPoolHint(hint: SshTcpPoolHint | null): string { - return hint === null ? "" : `UNIDESK_SSH_TCP_POOL_HINT ${JSON.stringify(hint)}\n`; -} - -function sshStreamDumpPath(invocation: ParsedSshInvocation, stream: SshStdoutTruncationHint["stream"]): string { - const policy = readCliOutputPolicy(); - mkdirSync(policy.dumpDir, { recursive: true, mode: 0o700 }); - const timestamp = new Date().toISOString().replace(/[:.]/gu, "-"); - const suffix = randomBytes(4).toString("hex"); - const slug = `${invocation.providerId}-${invocation.route.raw}-${invocation.route.plane}` - .replace(/[^A-Za-z0-9._-]+/gu, "-") - .replace(/^-+|-+$/gu, "") - .slice(0, 80) || "ssh"; - return join(policy.dumpDir, `${timestamp}-${process.pid}-${suffix}-${slug}.${stream}.bin`); -} - -export interface SshStreamForwarder { - write: (chunk: Buffer) => string | null; - summary: () => SshStreamTruncationSummary | null; -} - -export function createSshStdoutForwarder(options: { - invocation: ParsedSshInvocation; - transport: SshStdoutTruncationHint["transport"]; - maxBytes?: number; - stdout?: NodeJS.WritableStream; -}): SshStreamForwarder { - return createSshStreamForwarder({ - invocation: options.invocation, - transport: options.transport, - stream: "stdout", - maxBytes: options.maxBytes ?? sshStdoutStreamMaxBytes(), - target: options.stdout ?? process.stdout, - }); -} - -export function createSshStderrForwarder(options: { - invocation: ParsedSshInvocation; - transport: SshStdoutTruncationHint["transport"]; - maxBytes?: number; - stderr?: NodeJS.WritableStream; -}): SshStreamForwarder { - return createSshStreamForwarder({ - invocation: options.invocation, - transport: options.transport, - stream: "stderr", - maxBytes: options.maxBytes ?? sshStderrStreamMaxBytes(), - target: options.stderr ?? process.stderr, - }); -} - -function createSshStreamForwarder(options: { - invocation: ParsedSshInvocation; - transport: SshStdoutTruncationHint["transport"]; - stream: SshStdoutTruncationHint["stream"]; - maxBytes: number; - target: NodeJS.WritableStream; -}): SshStreamForwarder { - let observedBytes = 0; - let forwardedBytes = 0; - let truncated = false; - let dumpPath: string | null = null; - let dumpError: string | null = null; - let lastHint: SshStdoutTruncationHint | null = null; - const bufferedChunks: Buffer[] = []; - - const appendDump = (chunk: Buffer): void => { - if (dumpError !== null) return; - try { - if (dumpPath === null) { - dumpPath = sshStreamDumpPath(options.invocation, options.stream); - writeFileSync(dumpPath, Buffer.alloc(0), { mode: 0o600 }); - for (const buffered of bufferedChunks) appendFileSync(dumpPath, buffered); - bufferedChunks.length = 0; - } - appendFileSync(dumpPath, chunk); - } catch (error) { - dumpError = error instanceof Error ? error.message : String(error); - dumpPath = null; - } - }; - - return { - write(chunk: Buffer): string | null { - observedBytes += chunk.length; - if (!truncated && observedBytes <= options.maxBytes) { - bufferedChunks.push(Buffer.from(chunk)); - options.target.write(chunk); - forwardedBytes += chunk.length; - return null; - } - - if (!truncated) { - truncated = true; - const remaining = Math.max(0, options.maxBytes - forwardedBytes); - if (remaining > 0) { - const forwarded = chunk.subarray(0, remaining); - options.target.write(forwarded); - forwardedBytes += remaining; - } - appendDump(chunk); - lastHint = sshStdoutTruncationHint({ - invocation: options.invocation, - transport: options.transport, - stream: options.stream, - thresholdBytes: options.maxBytes, - observedBytesAtTruncation: observedBytes, - forwardedBytes, - dumpPath, - dumpError, - }); - return formatSshStdoutTruncationHint(lastHint); - } - - appendDump(chunk); - return null; - }, - summary(): SshStreamTruncationSummary | null { - if (lastHint === null) return null; - return { - stream: lastHint.stream, - thresholdBytes: lastHint.thresholdBytes, - observedBytesAtTruncation: lastHint.observedBytesAtTruncation, - forwardedBytes: lastHint.forwardedBytes, - dumpPath, - dumpError, - }; - }, - }; -} - -function brokerSource(): string { - return String.raw` -const open = JSON.parse(process.argv[2] || process.argv[1] || "{}"); -const token = process.env.PROVIDER_TOKEN || process.env.UNIDESK_PROVIDER_TOKEN || ""; -const baseUrl = process.env.UNIDESK_SSH_BROKER_URL || "ws://backend-core:8080/ws/ssh"; -const url = baseUrl + "?token=" + encodeURIComponent(token); -const ws = new WebSocket(url); -let exitCode = 255; -let canSend = false; -let sessionReady = false; -let opened = false; -const pending = []; -const pendingInput = []; -const openTimer = setTimeout(() => { - if (opened) return; - process.stderr.write("unidesk ssh bridge timed out waiting for provider session\n"); - try { ws.close(); } catch {} - process.exit(255); -}, Number(open.openTimeoutMs || 15000)); -const runtimeTimeoutMs = Number(open.runtimeTimeoutMs || 60000); -const runtimeTimeoutMode = open.runtimeTimeoutMode === "inactivity" ? "inactivity" : "wall-clock"; -let runtimeTimer = null; -function armRuntimeTimer() { - if (runtimeTimer !== null) clearTimeout(runtimeTimer); - runtimeTimer = setTimeout(() => { - const noun = runtimeTimeoutMode === "inactivity" ? "inactivity timeout" : "runtime timeout"; - process.stderr.write("unidesk ssh bridge " + noun + "; use short query plus poll semantics for long non-streaming work\n"); - exitCode = 124; - try { ws.close(); } catch {} - setTimeout(() => process.exit(124), 250).unref?.(); - }, runtimeTimeoutMs); -} -function clearRuntimeTimer() { - if (runtimeTimer !== null) clearTimeout(runtimeTimer); - runtimeTimer = null; -} -armRuntimeTimer(); - -function send(value) { - const text = JSON.stringify(value); - if (!canSend || ws.readyState !== WebSocket.OPEN) { - pending.push(text); - return; - } - ws.send(text); -} - -function flush() { - while (pending.length > 0 && ws.readyState === WebSocket.OPEN) { - ws.send(pending.shift()); - } -} - -function sendInput(value) { - const text = JSON.stringify(value); - if (!sessionReady || ws.readyState !== WebSocket.OPEN) { - pendingInput.push(text); - return; - } - ws.send(text); -} - -function flushInput() { - if (!sessionReady || ws.readyState !== WebSocket.OPEN) return; - while (pendingInput.length > 0) { - ws.send(pendingInput.shift()); - } -} - -function decodeData(data) { - return typeof data === "string" ? data : Buffer.from(data).toString("utf8"); -} - -ws.addEventListener("open", () => { - canSend = true; - send({ - type: "ssh.open", - providerId: open.providerId, - command: open.command || undefined, - cwd: open.cwd || undefined, - tty: open.tty === true, - cols: open.cols || 100, - rows: open.rows || 30, - }); - flush(); -}); - -ws.addEventListener("message", (event) => { - const message = JSON.parse(decodeData(event.data)); - if (runtimeTimeoutMode === "inactivity") armRuntimeTimer(); - if (message.type === "ssh.data") { - opened = true; - const chunk = Buffer.from(message.data || "", "base64"); - if (message.stream === "stderr") process.stderr.write(chunk); - else process.stdout.write(chunk); - return; - } - if (message.type === "ssh.opened") { - opened = true; - sessionReady = true; - clearTimeout(openTimer); - if (open.stdinEotOnEnd === true) setTimeout(flushInput, 200); - else flushInput(); - return; - } - if (message.type === "ssh.dispatched") { - return; - } - if (message.type === "ssh.error") { - clearTimeout(openTimer); - clearRuntimeTimer(); - process.stderr.write(String(message.message || "ssh bridge error") + "\n"); - if (message.failureKind || message.providerId || message.dataChannelId || message.dataPool) { - process.stderr.write("UNIDESK_SSH_ERROR " + JSON.stringify({ - failureKind: message.failureKind || null, - providerId: message.providerId || null, - dataChannelId: message.dataChannelId || null, - dataPool: message.dataPool || null, - transport: message.transport || null, - controlFallback: message.controlFallback === true - }) + "\n"); - } - exitCode = 255; - ws.close(); - return; - } - if (message.type === "ssh.exit") { - clearTimeout(openTimer); - clearRuntimeTimer(); - exitCode = Number.isInteger(message.exitCode) ? message.exitCode : 255; - ws.close(); - } -}); - -ws.addEventListener("close", () => { - clearTimeout(openTimer); - clearRuntimeTimer(); - process.exit(exitCode); -}); - -ws.addEventListener("error", () => { - process.stderr.write("unidesk ssh bridge websocket error\n"); - process.exit(255); -}); - -process.stdin.on("data", (chunk) => { - sendInput({ type: "ssh.input", data: Buffer.from(chunk).toString("base64"), encoding: "base64" }); -}); -process.stdin.on("end", () => { - if (open.stdinEotOnEnd === true) { - sendInput({ type: "ssh.input", data: Buffer.from([4]).toString("base64"), encoding: "base64" }); - } - sendInput({ type: "ssh.eof" }); -}); -`; -} - -function terminalSize(): { cols: number; rows: number } { - return { - cols: Number(process.stdout.columns) > 0 ? Number(process.stdout.columns) : 100, - rows: Number(process.stdout.rows) > 0 ? Number(process.stdout.rows) : 30, - }; -} - -export function remoteCommandForRoute(route: ParsedSshRoute, command: string[], options: { stdin?: boolean } = {}): string { - if (route.plane === "k3s") return buildK3sTargetCommand(route, command, options); - if (route.plane === "win") throw new Error(`ssh apply-patch does not support win routes yet: ${route.raw}`); - return shellArgv(command); -} - -type WindowsApplyPatchFsOperation = - | "stat" - | "read-b64-block" - | "read-bulk-b64" - | "apply-replacements-bulk-stdin" - | "write-b64-stdin" - | "write-b64-begin" - | "write-b64-append-stdin" - | "write-b64-commit" - | "delete"; - -const windowsApplyPatchWriteB64ChunkChars = 12_000; -const posixApplyPatchWriteB64ChunkChars = 12_000; - -type PosixApplyPatchFsOperation = Exclude; - -export function createPosixApplyPatchFileSystem(invocation: ParsedSshInvocation, runRemoteCommand: (command: string[], input?: string) => Promise): ApplyPatchV2FileSystem { - async function checked(operation: PosixApplyPatchFsOperation, args: string[], input?: string): Promise { - const startedAtMs = Date.now(); - let result: SshCaptureResult; - try { - result = await runRemoteCommand(applyPatchV2RemoteCommand(operation, args), input); - } catch (error) { - throw new ApplyPatchV2Error(`posix apply-patch fs operation failed: ${operation}`, applyPatchFsFailureDetails({ - operation, - args, - input, - invocation, - remoteElapsedMs: Math.max(0, Date.now() - startedAtMs), - cause: error instanceof Error ? { name: error.name, message: error.message } : { message: String(error) }, - })); - } - if (result.exitCode === 0) return result; - throw new ApplyPatchV2Error(`posix apply-patch fs operation failed: ${operation}`, applyPatchFsFailureDetails({ - operation, - args, - input, - invocation, - result, - remoteElapsedMs: Math.max(0, Date.now() - startedAtMs), - })); - } - - 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(`posix 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 - .split(/\r?\n/u) - .filter((line) => !line.startsWith("UNIDESK_APPLY_PATCH_V2_BLOCK ")) - .join("") - .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, posixApplyPatchWriteB64ChunkChars)) { - await checked("write-b64-append", [filePath, token, chunk]); - } - await checked("write-b64-commit", [filePath, token, expectedBytes, expectedSha256]); - } - }, - async deleteFile(filePath) { - await checked("delete", [filePath]); - }, - async readFiles(filePaths) { - const result = await checked("read-bulk-b64", filePaths); - return decodeApplyPatchV2BulkRead(result.stdout, filePaths); - }, - async applyReplacementsBulk(filePaths, plans: Map) { - const { targets, payload } = formatApplyPatchV2BulkReplacementPayload(filePaths, plans); - if (targets.length === 0) return; - await checked("apply-replacements-bulk-stdin", [String(targets.length)], payload); - }, - }; -} - -export function createWindowsApplyPatchFileSystem(invocation: ParsedSshInvocation, runRemoteCommand: (remoteCommand: string, input?: string) => Promise): ApplyPatchV2FileSystem { - async function checked(operation: WindowsApplyPatchFsOperation, args: string[], input?: string): Promise { - const command = buildWindowsPowerShellInvocation(windowsApplyPatchFsScript(invocation.route.workspace, operation, args)); - const startedAtMs = Date.now(); - let result: SshCaptureResult; - try { - result = await runRemoteCommand(command, input); - } catch (error) { - throw new ApplyPatchV2Error(`windows apply-patch fs operation failed: ${operation}`, applyPatchFsFailureDetails({ - operation, - args, - input, - invocation, - remoteElapsedMs: Math.max(0, Date.now() - startedAtMs), - cause: error instanceof Error ? { name: error.name, message: error.message } : { message: String(error) }, - })); - } - if (result.exitCode === 0) return result; - throw new ApplyPatchV2Error(`windows apply-patch fs operation failed: ${operation}`, applyPatchFsFailureDetails({ - operation, - args, - input, - invocation, - result, - remoteElapsedMs: Math.max(0, Date.now() - startedAtMs), - })); - } - - 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]); - }, - async readFiles(filePaths) { - const result = await checked("read-bulk-b64", [String(filePaths.length)], `${filePaths.join("\n")}\n`); - return decodeApplyPatchV2BulkRead(result.stdout, filePaths); - }, - async applyReplacementsBulk(filePaths, plans: Map) { - const { targets, payload } = formatApplyPatchV2BulkReplacementPayload(filePaths, plans); - if (targets.length === 0) return; - await checked("apply-replacements-bulk-stdin", [String(targets.length)], payload); - }, - }; -} - -function applyPatchFsFailureDetails(options: { - operation: string; - args: string[]; - input?: string; - invocation: ParsedSshInvocation; - result?: SshCaptureResult; - remoteElapsedMs: number; - cause?: Record; -}): Record { - const { operation, args, input, invocation, result } = options; - const inputBytes = input === undefined ? 0 : Buffer.byteLength(input, "utf8"); - const expected = applyPatchFsExpectedWrite(operation, args); - const targetCount = applyPatchFsBulkTargetCount(operation, args); - return { - operation, - route: invocation.route.raw, - ...(operation === "read-bulk-b64" || operation === "apply-replacements-bulk-stdin" ? {} : { filePath: args[0] ?? "" }), - ...(targetCount === null ? {} : { targetCount }), - args: args.slice(0, 4), - inputBytes, - ...(inputBytes === 0 ? {} : { inputSha256: createHash("sha256").update(input, "utf8").digest("hex") }), - ...(expected.expectedBytes === undefined ? {} : { expectedBytes: expected.expectedBytes }), - ...(expected.expectedSha256 === undefined ? {} : { expectedSha256: expected.expectedSha256 }), - remoteElapsedMs: options.remoteElapsedMs, - landed: false, - ...(result === undefined ? {} : { - exitCode: result.exitCode, - stdoutTail: result.stdout.slice(-2000), - stderrTail: result.stderr.slice(-4000), - }), - ...(options.cause === undefined ? {} : { cause: options.cause }), - }; -} - -function applyPatchFsExpectedWrite(operation: string, args: string[]): { expectedBytes?: string; expectedSha256?: string } { - if (operation === "write-b64-stdin") return { expectedBytes: args[1], expectedSha256: args[2] }; - if (operation === "write-b64-commit") return { expectedBytes: args[2], expectedSha256: args[3] }; - return {}; -} - -function applyPatchFsBulkTargetCount(operation: string, args: string[]): number | null { - if (operation === "read-bulk-b64") { - const count = Number(args[0]); - return Number.isSafeInteger(count) && count >= 0 ? count : args.length; - } - if (operation !== "apply-replacements-bulk-stdin") return null; - const count = Number(args[0]); - return Number.isSafeInteger(count) && count >= 0 ? count : null; -} - -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 Get-Sha256Bytes([byte[]]$Bytes) { $sha = [System.Security.Cryptography.SHA256]::Create(); try { return ([System.BitConverter]::ToString($sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() } }", - "function Decode-Utf8([string]$Encoded) { return [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Encoded)) }", - "function Encode-Utf8([string]$Value) { return [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Value)) }", - "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 }", - " 'read-bulk-b64' { $expectedCount = [Int32]$targetArg; $items = @([Console]::In.ReadToEnd() -split \"`r?`n\" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }); if ($items.Count -ne $expectedCount) { Fail ('bulk read record count mismatch: expected=' + $expectedCount + ' actual=' + $items.Count) 23 }; [Console]::Out.WriteLine('UNIDESK_APPLY_PATCH_V2_BULK_READ ' + $items.Count); foreach ($item in $items) { $path = Resolve-UnideskPath $item; if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { Fail ('file not found: ' + $path) 1 }; $bytes = [System.IO.File]::ReadAllBytes($path); $pathB64 = Encode-Utf8 $item; $digest = Get-Sha256Bytes $bytes; [Console]::Out.Write($pathB64 + ' ' + $bytes.Length + ' ' + $digest + ' '); [Console]::Out.Write([System.Convert]::ToBase64String($bytes)); [Console]::Out.WriteLine() }; break }", - " 'apply-replacements-bulk-stdin' { $expectedCount = [Int32]$targetArg; $records = @([Console]::In.ReadToEnd() -split \"`r?`n\" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }); if ($records.Count -ne $expectedCount) { Fail ('bulk replacement record count mismatch: expected=' + $expectedCount + ' actual=' + $records.Count) 23 }; $utf8 = [System.Text.UTF8Encoding]::new($false); $plans = New-Object System.Collections.Generic.List[object]; $index = 0; foreach ($record in $records) { $index += 1; $fields = $record -split '\\s+'; if ($fields.Count -ne 6) { Fail 'bulk replacement malformed record' 23 }; $targetRelative = Decode-Utf8 $fields[0]; $resolved = Resolve-UnideskPath $targetRelative; $originalBytes = [Int64]$fields[1]; $originalSha256 = $fields[2]; $finalBytes = [Int64]$fields[3]; $finalSha256 = $fields[4]; $replacementsText = $fields[5]; if (-not (Test-Path -LiteralPath $resolved -PathType Leaf)) { Fail ('file not found: ' + $resolved) 1 }; $source = [System.IO.File]::ReadAllBytes($resolved); if ($source.Length -ne $originalBytes -or (Get-Sha256Bytes $source) -ne $originalSha256) { Fail ('bulk replacement original integrity mismatch for ' + $resolved) 23 }; $lines = New-Object System.Collections.Generic.List[string]; $sourceText = [System.Text.Encoding]::UTF8.GetString($source); foreach ($line in ($sourceText -split \"`n\", -1)) { $lines.Add($line) | Out-Null }; if ($lines.Count -gt 0 -and $lines[$lines.Count - 1] -eq '') { $lines.RemoveAt($lines.Count - 1) }; $replacements = New-Object System.Collections.Generic.List[object]; if (-not [string]::IsNullOrEmpty($replacementsText)) { foreach ($item in ($replacementsText -split ';')) { if ([string]::IsNullOrWhiteSpace($item)) { continue }; $parts = $item -split ',', 3; if ($parts.Count -ne 3) { Fail 'bulk replacement malformed replacement' 23 }; $newText = Decode-Utf8 $parts[2]; $newLines = New-Object System.Collections.Generic.List[string]; foreach ($line in ($newText -split \"`n\", -1)) { $newLines.Add($line) | Out-Null }; if ($newLines.Count -gt 0 -and $newLines[$newLines.Count - 1] -eq '') { $newLines.RemoveAt($newLines.Count - 1) }; $replacements.Add([pscustomobject]@{ start = [Int32]$parts[0]; oldLength = [Int32]$parts[1]; newLines = [string[]]$newLines }) | Out-Null } }; foreach ($replacement in @($replacements | Sort-Object -Property start -Descending)) { if ($replacement.start -lt 0 -or $replacement.oldLength -lt 0 -or ($replacement.start + $replacement.oldLength) -gt $lines.Count) { Fail ('bulk replacement out of bounds for ' + $resolved) 23 }; $removeCount = $replacement.oldLength; if ($removeCount -gt 0) { $lines.RemoveRange($replacement.start, $removeCount) }; if ($replacement.newLines.Count -gt 0) { $lines.InsertRange($replacement.start, [string[]]$replacement.newLines) } }; $outputText = if ($lines.Count -eq 0) { '' } else { ([string]::Join(\"`n\", [string[]]$lines) + \"`n\") }; $outputBytes = [System.Text.Encoding]::UTF8.GetBytes($outputText); if ($outputBytes.Length -ne $finalBytes -or (Get-Sha256Bytes $outputBytes) -ne $finalSha256) { Fail ('bulk replacement final integrity mismatch for ' + $resolved) 23 }; $tmp = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($resolved), ('.' + [System.IO.Path]::GetFileName($resolved) + '.unidesk-v2-bulk-' + [guid]::NewGuid().ToString('N') + '.tmp')); [System.IO.File]::WriteAllText($tmp, $outputText, $utf8); $plans.Add([pscustomobject]@{ target = $resolved; tmp = $tmp; sha256 = $finalSha256 }) | Out-Null }; foreach ($plan in $plans) { Ensure-Parent $plan.target; Move-Item -LiteralPath $plan.tmp -Destination $plan.target -Force; if ((Get-Sha256 $plan.target) -ne $plan.sha256) { Fail ('bulk replacement final sha256 mismatch for ' + $plan.target) 25 } }; 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); -} - -async function runSshCaptureRemoteCommand(config: UniDeskConfig, invocation: ParsedSshInvocation, remoteCommand: string, input?: string): Promise { - const startedAtMs = Date.now(); - const size = terminalSize(); - const runtimeTimeoutMs = sshRuntimeTimeoutMs(); - const payload = { - providerId: invocation.providerId, - command: wrapSshRemoteCommand(remoteCommand), - cwd: sshRoutePayloadCwd(invocation.route), - tty: false, - stdinEotOnEnd: false, - openTimeoutMs: Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000)), - runtimeTimeoutMs, - cols: size.cols, - rows: size.rows, - }; - const payloadJson = JSON.stringify(payload); - const encodedBrokerSource = Buffer.from(brokerSource(), "utf8").toString("base64"); - const script = [ - "set -eu", - `payload=${shellQuote(payloadJson)}`, - "if command -v backend-core >/dev/null 2>&1; then", - ' exec backend-core --ssh-broker "$payload"', - "fi", - `export UNIDESK_SSH_BROKER_URL=${shellQuote("ws://127.0.0.1:8080/ws/ssh")}`, - 'broker_js="$(mktemp "${TMPDIR:-/tmp}/unidesk-ssh-broker.XXXXXX")"', - 'trap \'rm -f "$broker_js"\' EXIT', - `printf %s ${shellQuote(encodedBrokerSource)} | base64 -d >"$broker_js"`, - 'bun "$broker_js" "$payload"', - ].join("\n"); - const child = spawn("docker", ["exec", "-i", "unidesk-backend-core", "sh", "-c", script], { - cwd: repoRoot, - stdio: ["pipe", "pipe", "pipe"], - }); - if (input !== undefined) { - writeChunkedStdin(child.stdin, input); - } else { - child.stdin.end(); - } - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (chunk: Buffer) => { - stdout += chunk.toString("utf8"); - }); - child.stderr.on("data", (chunk: Buffer) => { - stderr += chunk.toString("utf8"); - }); - return await new Promise((resolve) => { - let settled = false; - let killTimer: NodeJS.Timeout | null = null; - const finish = (exitCode: number): void => { - if (settled) return; - settled = true; - clearTimeout(runtimeTimer); - if (killTimer !== null) clearTimeout(killTimer); - const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({ - invocation, - transport: "backend-core-broker", - exitCode, - startedAtMs, - })); - if (timingHint) stderr += timingHint; - stderr += formatSshTcpPoolHint(sshTcpPoolHint({ - invocation, - transport: "backend-core-broker", - exitCode, - stderrText: stderr, - })); - resolve({ exitCode, stdout, stderr }); - }; - const runtimeTimer = setTimeout(() => { - const hint = formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({ - invocation, - transport: "backend-core-broker", - timeoutMs: runtimeTimeoutMs, - })); - stderr += hint; - try { - child.kill("SIGTERM"); - } catch { - // Ignore. - } - killTimer = setTimeout(() => { - try { - child.kill("SIGKILL"); - } catch { - // Ignore. - } - }, 2000); - finish(124); - }, runtimeTimeoutMs); - child.on("error", (error) => { - stderr += `unidesk ssh failed to start broker: ${error.message}\n`; - finish(255); - }); - child.on("close", (code) => finish(code ?? 255)); - }); -} - -async function runSshStreamRemoteCommand( - config: UniDeskConfig, - invocation: ParsedSshInvocation, - remoteCommand: string, - handlers: SshRemoteCommandStreamHandlers, - input?: string, - options: { inactivityTimeoutMs?: number } = {}, -): Promise { - const streamInvocation = { - ...invocation, - parsed: { ...invocation.parsed, remoteCommand, requiresStdin: input !== undefined, invocationKind: "helper" as const }, - }; - const startedAtMs = Date.now(); - const size = terminalSize(); - const inactivityTimeoutMs = options.inactivityTimeoutMs ?? sshRuntimeTimeoutMs(); - const runtimeTimeoutMs = options.inactivityTimeoutMs === undefined ? inactivityTimeoutMs : Math.max(inactivityTimeoutMs * 4, 60 * 60_000); - const payload = { - providerId: invocation.providerId, - command: wrapSshRemoteCommand(remoteCommand), - cwd: sshRoutePayloadCwd(invocation.route), - tty: false, - stdinEotOnEnd: false, - openTimeoutMs: Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000)), - runtimeTimeoutMs, - runtimeTimeoutMode: options.inactivityTimeoutMs === undefined ? "wall-clock" : "inactivity", - cols: size.cols, - rows: size.rows, - }; - const payloadJson = JSON.stringify(payload); - const encodedBrokerSource = Buffer.from(brokerSource(), "utf8").toString("base64"); - const script = [ - "set -eu", - `payload=${shellQuote(payloadJson)}`, - "if command -v backend-core >/dev/null 2>&1; then", - ' exec backend-core --ssh-broker "$payload"', - "fi", - `export UNIDESK_SSH_BROKER_URL=${shellQuote("ws://127.0.0.1:8080/ws/ssh")}`, - 'broker_js="$(mktemp "${TMPDIR:-/tmp}/unidesk-ssh-broker.XXXXXX")"', - 'trap \'rm -f "$broker_js"\' EXIT', - `printf %s ${shellQuote(encodedBrokerSource)} | base64 -d >"$broker_js"`, - 'bun "$broker_js" "$payload"', - ].join("\n"); - const child = spawn("docker", ["exec", "-i", "unidesk-backend-core", "sh", "-c", script], { - cwd: repoRoot, - stdio: ["pipe", "pipe", "pipe"], - }); - if (input !== undefined) { - writeChunkedStdin(child.stdin, input); - } else { - child.stdin.end(); - } - let stdout = ""; - let stderr = ""; - let streamError: unknown = null; - let streamWrites = Promise.resolve(); - const queueStreamWrite = (chunk: Buffer, stream: "stdout" | "stderr"): void => { - streamWrites = streamWrites.then(async () => { - if (stream === "stdout") await handlers.onStdout(chunk); - else if (handlers.onStderr !== undefined) await handlers.onStderr(chunk); - }).catch((error) => { - streamError = error; - stderr += `unidesk ssh stream sink failed: ${error instanceof Error ? error.message : String(error)}\n`; - try { - child.kill("SIGTERM"); - } catch { - // Ignore kill failures after a local stream sink error. - } - }); - }; - child.stdout.on("data", (chunk: Buffer) => { - queueStreamWrite(chunk, "stdout"); - }); - child.stderr.on("data", (chunk: Buffer) => { - stderr += chunk.toString("utf8"); - queueStreamWrite(chunk, "stderr"); - }); - return await new Promise((resolve) => { - let settled = false; - let killTimer: NodeJS.Timeout | null = null; - let inactivityTimer: NodeJS.Timeout | null = null; - const refreshActivityTimer = (): void => { - if (settled || options.inactivityTimeoutMs === undefined) return; - if (inactivityTimer !== null) clearTimeout(inactivityTimer); - inactivityTimer = setTimeout(() => { - const hint = formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({ - invocation: streamInvocation, - transport: "backend-core-broker", - timeoutMs: inactivityTimeoutMs, - })); - stderr += hint; - try { - child.kill("SIGTERM"); - } catch { - // Ignore. - } - killTimer = setTimeout(() => { - try { - child.kill("SIGKILL"); - } catch { - // Ignore. - } - }, 2000); - finish(124); - }, inactivityTimeoutMs); - }; - const finish = (exitCode: number): void => { - if (settled) return; - settled = true; - clearTimeout(runtimeTimer); - if (inactivityTimer !== null) clearTimeout(inactivityTimer); - if (killTimer !== null) clearTimeout(killTimer); - void streamWrites.then(() => { - const finalCode = streamError === null ? exitCode : 255; - const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({ - invocation: streamInvocation, - transport: "backend-core-broker", - exitCode: finalCode, - startedAtMs, - })); - if (timingHint) stderr += timingHint; - stderr += formatSshTcpPoolHint(sshTcpPoolHint({ - invocation: streamInvocation, - transport: "backend-core-broker", - exitCode: finalCode, - stderrText: stderr, - })); - resolve({ exitCode: finalCode, stdout, stderr }); - }); - }; - const runtimeTimer = setTimeout(() => { - const hint = formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({ - invocation: streamInvocation, - transport: "backend-core-broker", - timeoutMs: runtimeTimeoutMs, - })); - stderr += hint; - try { - child.kill("SIGTERM"); - } catch { - // Ignore. - } - killTimer = setTimeout(() => { - try { - child.kill("SIGKILL"); - } catch { - // Ignore. - } - }, 2000); - finish(124); - }, runtimeTimeoutMs); - refreshActivityTimer(); - child.on("error", (error) => { - stderr += `unidesk ssh failed to start broker: ${error.message}\n`; - finish(255); - }); - child.on("close", (code) => finish(code ?? 255)); - child.stdout.on("data", refreshActivityTimer); - child.stderr.on("data", refreshActivityTimer); - }); -} - -async function runRemoteSsh(config: UniDeskConfig, host: string, providerId: string, args: string[]): Promise { - const { runRemoteSshCli } = await import("./remote-ssh"); - return await runRemoteSshCli({ - host, - user: "root", - port: 22, - projectRoot: repoRoot, - identityFile: null, - transport: "frontend", - args: ["ssh", providerId, ...args], - }, config); -} - -export async function runSshCommandCapture(config: UniDeskConfig, target: string, args: string[], input?: string): Promise { - const invocation = parseSshInvocation(target, args); - const parsed = invocation.parsed; - if (parsed.remoteCommand === null) throw new Error(`ssh ${target} capture requires a non-interactive operation`); - const stdin = parsed.stdinPrefix !== undefined || parsed.stdinSuffix !== undefined - ? `${parsed.stdinPrefix ?? ""}${input ?? ""}${parsed.stdinSuffix ?? ""}` - : input; - const plan = sshCaptureBackendPlan(config, process.env); - if (plan.backend === "remote-frontend-websocket" && plan.remoteHost !== null) { - return await runRemoteSshCapture(config, plan.remoteHost, target, args, stdin); - } - const local = await runSshCaptureRemoteCommand(config, invocation, parsed.remoteCommand, stdin); - const fallbackHost = sshCaptureRemoteHost(config, process.env); - if (local.exitCode !== 0 && fallbackHost !== null && isLocalSshCaptureBackendUnavailable(local)) { - return await runRemoteSshCapture(config, fallbackHost, target, args, stdin); - } - return local; -} - -async function runRemoteSshCapture(config: UniDeskConfig, host: string, target: string, args: string[], input?: string): Promise { - const { runRemoteSshCommandCapture } = await import("./remote-ssh"); - return await runRemoteSshCommandCapture(config, host, target, args, input); -} - -export function sshCaptureBackendPlan(config: UniDeskConfig, env: NodeJS.ProcessEnv = process.env): SshCaptureBackendPlan { - const configuredBackend = readTransSshBackendConfig(env); - const remoteHost = sshCaptureRemoteHost(config, env); - if (configuredBackend !== null) return configuredSshCaptureBackendPlan(configuredBackend, remoteHost); - const localBackendCore = detectLocalBackendCoreStatus(env); - const runnerEnv = isRunnerEnvironment(env); - if (runnerEnv && remoteHost !== null) return { backend: "remote-frontend-websocket", remoteHost, reason: "runner-environment", localBackendCore }; - if (!localBackendCore.backendCoreContainer && remoteHost !== null) return { backend: "remote-frontend-websocket", remoteHost, reason: "local-backend-core-unavailable", localBackendCore }; - return { backend: "local-backend-core-broker", remoteHost: null, reason: "main-server-local-backend-core", localBackendCore }; -} - -function configuredSshCaptureBackendPlan(configuredBackend: TransSshBackendConfig, remoteHost: string | null): SshCaptureBackendPlan { - if (configuredBackend.backend === "local-backend-core-broker") { - return { - backend: "local-backend-core-broker", - remoteHost: null, - reason: `configured:${configuredBackend.sourceRef}`, - localBackendCore: { - dockerExecutable: true, - backendCoreContainer: true, - error: null, - source: configuredBackend.sourceRef, - }, - }; - } - if (remoteHost === null) { - throw new Error(`${configuredBackend.sourceRef} selects ${configuredBackend.raw}, but no remote host is configured for frontend websocket SSH`); - } - return { - backend: "remote-frontend-websocket", - remoteHost, - reason: `configured:${configuredBackend.sourceRef}`, - localBackendCore: { - dockerExecutable: false, - backendCoreContainer: false, - error: null, - source: configuredBackend.sourceRef, - }, - }; -} - -function sshCaptureRemoteHost(config: UniDeskConfig, env: NodeJS.ProcessEnv): string | null { - return normalizeRemoteHost(env.UNIDESK_MAIN_SERVER_IP) - ?? normalizeRemoteHost(env.UNIDESK_MAIN_SERVER_HOST) - ?? normalizeRemoteHost(env.CODE_QUEUE_DEV_CONTAINER_MASTER_HOST) - ?? normalizeRemoteHost(readHostK8sPublicHost() ?? undefined) - ?? normalizeRemoteHost(config.network.publicHost); -} - -function normalizeRemoteHost(raw: string | undefined): string | null { - const value = raw?.trim() ?? ""; - if (value.length === 0 || value === "localhost" || value === "127.0.0.1" || value === "::1") return null; - return value.replace(/\/+$/u, ""); -} - -function isRunnerEnvironment(env: NodeJS.ProcessEnv): boolean { - return Boolean( - env.AGENTRUN_BOOT_MODE - || env.AGENTRUN_RUN_ID - || env.AGENTRUN_K8S_JOB_NAME - || env.CODE_QUEUE_SERVICE_ROLE - || env.CODE_QUEUE_INSTANCE_ID - || env.KUBERNETES_SERVICE_HOST, - ); -} - -function detectLocalBackendCoreStatus(env: NodeJS.ProcessEnv = process.env): SshCaptureBackendPlan["localBackendCore"] { - const timeout = sshBackendCoreDetectTimeoutMs(env); - const inspect = spawnSync("docker", ["inspect", "unidesk-backend-core", "--format", "{{.State.Running}}"], { encoding: "utf8", timeout }); - if (inspect.error !== undefined) return { dockerExecutable: false, backendCoreContainer: false, error: inspect.error.message, source: "docker-inspect" }; - const inspectOutput = `${inspect.stdout ?? ""}\n${inspect.stderr ?? ""}`.trim(); - if (inspect.status === 0) { - const running = String(inspect.stdout ?? "").trim() === "true"; - return { - dockerExecutable: true, - backendCoreContainer: running, - error: running ? null : inspectOutput || "docker inspect unidesk-backend-core reported not running", - source: "docker-inspect", - }; - } - const result = spawnSync("docker", ["ps", "--format", "{{.Names}}"], { encoding: "utf8", timeout }); - if (result.error !== undefined) return { dockerExecutable: false, backendCoreContainer: false, error: result.error.message, source: "docker-ps" }; - const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); - return { - dockerExecutable: result.status === 0, - backendCoreContainer: result.status === 0 && String(result.stdout ?? "").split(/\r?\n/u).includes("unidesk-backend-core"), - error: result.status === 0 ? null : inspectOutput || output || `docker ps exited ${result.status ?? "unknown"}`, - source: "docker-ps", - }; -} - -function sshBackendCoreDetectTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { - const parsed = Number(env.UNIDESK_SSH_BACKEND_CORE_DETECT_TIMEOUT_MS); - if (!Number.isFinite(parsed) || parsed <= 0) return defaultSshBackendCoreDetectTimeoutMs; - return Math.min(maxSshBackendCoreDetectTimeoutMs, Math.max(2000, Math.trunc(parsed))); -} - -function isLocalSshCaptureBackendUnavailable(result: SshCaptureResult): boolean { - return /No such container: unidesk-backend-core|failed to start broker|Executable not found.*"docker"|docker: not found|Cannot connect to the Docker daemon/iu.test(result.stderr); -} - -function writeChunkedStdin(stdin: NodeJS.WritableStream, input: string): void { - const buffer = Buffer.from(input, "utf8"); - const chunkSize = 32 * 1024; - for (let offset = 0; offset < buffer.length; offset += chunkSize) { - stdin.write(buffer.subarray(offset, Math.min(buffer.length, offset + chunkSize))); - } - stdin.end(); -} - -export async function runSsh(config: UniDeskConfig, providerId: string, args: string[]): Promise { - const normalizedArgs = normalizeSshOperationArgs(args); - process.stderr.write(sshRouteSeparatorCompatibilityHint(args, normalizedArgs)); - const plan = sshCaptureBackendPlan(config, process.env); - if (plan.backend === "remote-frontend-websocket" && plan.remoteHost !== null) { - return await runRemoteSsh(config, plan.remoteHost, providerId, normalizedArgs); - } - const invocation = parseSshInvocation(providerId, normalizedArgs); - const parsed = invocation.parsed; - const operationName = normalizedArgs[0] ?? ""; - if (isSshFileTransferOperation(normalizedArgs)) { - const executor: SshRemoteCommandExecutor = { - runRemoteCommand: (remoteCommand, input) => runSshCaptureRemoteCommand(config, invocation, remoteCommand, input), - streamRemoteCommand: (remoteCommand, handlers, input, options) => runSshStreamRemoteCommand(config, invocation, remoteCommand, handlers, input, options), - }; - return await runSshFileTransferOperation(invocation, normalizedArgs, executor, { - buildRouteCommand: remoteCommandForRoute, - buildWindowsPowerShellCommand: buildWindowsPowerShellInvocation, - }); - } - if (operationName === "apply-patch") { - const applyPatch = effectiveApplyPatchV2Invocation(invocation, normalizedArgs.slice(1)); - const executor: ApplyPatchV2Executor = applyPatch.invocation.route.plane === "win" - ? { fs: createWindowsApplyPatchFileSystem(applyPatch.invocation, (remoteCommand, input) => runSshCaptureRemoteCommand(config, applyPatch.invocation, remoteCommand, input)) } - : applyPatch.invocation.route.plane === "host" - ? { fs: createPosixApplyPatchFileSystem(applyPatch.invocation, (command, input) => runSshCaptureCommand(config, applyPatch.invocation, command, input)) } - : { run: (command, input) => runSshCaptureCommand(config, applyPatch.invocation, command, input) }; - return await runApplyPatchV2({ - executor, - stdin: process.stdin, - stdout: process.stdout, - stderr: process.stderr, - argv: applyPatch.argv, - timing: { - providerId: applyPatch.invocation.providerId, - route: applyPatch.invocation.route.raw, - transport: "backend-core-broker", - }, - }); - } - const startedAtMs = Date.now(); - const size = terminalSize(); - const openTimeoutMs = Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000)); - const runtimeTimeoutMs = sshRuntimeTimeoutMs(); - const payload = { - providerId: invocation.providerId, - command: wrapSshRemoteCommand(parsed.remoteCommand, parsed.requiredHelpers), - cwd: sshRoutePayloadCwd(invocation.route), - tty: parsed.remoteCommand === null, - stdinEotOnEnd: parsed.remoteCommand !== null && parsed.requiresStdin !== true, - openTimeoutMs, - runtimeTimeoutMs, - cols: size.cols, - rows: size.rows, - }; - const payloadJson = JSON.stringify(payload); - const encodedBrokerSource = Buffer.from(brokerSource(), "utf8").toString("base64"); - const script = [ - "set -eu", - `payload=${shellQuote(payloadJson)}`, - "if command -v backend-core >/dev/null 2>&1; then", - ' exec backend-core --ssh-broker "$payload"', - "fi", - `export UNIDESK_SSH_BROKER_URL=${shellQuote("ws://127.0.0.1:8080/ws/ssh")}`, - 'broker_js="$(mktemp "${TMPDIR:-/tmp}/unidesk-ssh-broker.XXXXXX")"', - 'trap \'rm -f "$broker_js"\' EXIT', - `printf %s ${shellQuote(encodedBrokerSource)} | base64 -d >"$broker_js"`, - 'bun "$broker_js" "$payload"', - ].join("\n"); - const child = spawn("docker", [ - "exec", - "-i", - "unidesk-backend-core", - "sh", - "-c", - script, - ], { - cwd: repoRoot, - stdio: ["pipe", "pipe", "pipe"], - }); - - const rawMode = parsed.remoteCommand === null && process.stdin.isTTY; - if (rawMode) process.stdin.setRawMode(true); - process.stdin.resume(); - if (parsed.stdinPrefix !== undefined || parsed.stdinSuffix !== undefined) { - if (parsed.stdinPrefix) child.stdin.write(parsed.stdinPrefix); - process.stdin.pipe(child.stdin, { end: false }); - process.stdin.once("end", () => { - if (parsed.stdinSuffix) child.stdin.write(parsed.stdinSuffix); - child.stdin.end(); - }); - } else { - process.stdin.pipe(child.stdin); - } - let stderrTail = ""; - const appendStderrTail = (chunk: Buffer | string): void => { - const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk; - stderrTail = (stderrTail + text).slice(-16_384); - }; - let stdoutForwarder: SshStreamForwarder | null = null; - let stderrForwarder: SshStreamForwarder | null = null; - if (parsed.remoteCommand === null) { - child.stdout.pipe(process.stdout); - } else { - stdoutForwarder = createSshStdoutForwarder({ - invocation, - transport: "backend-core-broker", - }); - stderrForwarder = createSshStderrForwarder({ - invocation, - transport: "backend-core-broker", - }); - child.stdout.on("data", (chunk: Buffer) => { - const hint = stdoutForwarder.write(chunk); - if (hint !== null) { - appendStderrTail(hint); - process.stderr.write(hint); - } - }); - child.stderr.on("data", (chunk: Buffer) => { - appendStderrTail(chunk); - const hint = stderrForwarder.write(chunk); - if (hint !== null) { - appendStderrTail(hint); - process.stderr.write(hint); - } - }); - } - if (parsed.remoteCommand === null) { - child.stderr.on("data", (chunk: Buffer) => { - appendStderrTail(chunk); - process.stderr.write(chunk); - }); - } - - return await new Promise((resolve) => { - let settled = false; - let timedOut = false; - let killTimer: NodeJS.Timeout | null = null; - const runtimeTimer = setTimeout(() => { - if (settled) return; - timedOut = true; - const hint = sshRuntimeTimeoutHint({ - invocation, - transport: "backend-core-broker", - timeoutMs: runtimeTimeoutMs, - }); - const formatted = formatSshRuntimeTimeoutHint(hint); - appendStderrTail(formatted); - process.stderr.write(formatted); - try { - child.stdin.destroy(); - } catch { - // Ignore stdin teardown failures on the timeout path. - } - try { - child.kill("SIGTERM"); - } catch { - // Ignore kill failures and fall through to finish. - } - killTimer = setTimeout(() => { - try { - child.kill("SIGKILL"); - } catch { - // Process may have already exited. - } - }, 2000); - finish(124); - }, runtimeTimeoutMs); - const restore = (): void => { - clearTimeout(runtimeTimer); - if (killTimer && !timedOut) clearTimeout(killTimer); - process.stdin.unpipe(child.stdin); - if (rawMode) process.stdin.setRawMode(false); - }; - const finish = (exitCode: number): void => { - if (settled) return; - settled = true; - restore(); - const hint = timedOut ? null : sshFailureHint(invocation.providerId, parsed, exitCode, stderrTail); - if (hint !== null) process.stderr.write(formatSshFailureHint(hint)); - if (!timedOut) { - const tcpPoolHint = formatSshTcpPoolHint(sshTcpPoolHint({ - invocation, - transport: "backend-core-broker", - exitCode, - stderrText: stderrTail, - })); - if (tcpPoolHint) process.stderr.write(tcpPoolHint); - } - const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({ - invocation, - transport: "backend-core-broker", - exitCode, - startedAtMs, - })); - if (timingHint) process.stderr.write(timingHint); - const truncationSummary = formatSshTruncationCompletionSummary(sshTruncationCompletionSummary({ - invocation, - transport: "backend-core-broker", - exitCode, - timedOut, - startedAtMs, - stdout: stdoutForwarder?.summary() ?? null, - stderr: stderrForwarder?.summary() ?? null, - })); - if (truncationSummary) process.stderr.write(truncationSummary); - resolve(exitCode); - }; - child.on("error", (error) => { - const message = `unidesk ssh failed to start broker: ${error.message}\n`; - appendStderrTail(message); - process.stderr.write(message); - finish(255); - }); - child.on("close", (code) => { - finish(code ?? 255); - }); - }); -} +export { + classifySshTcpPoolFailure, + createPosixApplyPatchFileSystem, + createSshStderrForwarder, + createSshStdoutForwarder, + createWindowsApplyPatchFileSystem, + formatSshFailureHint, + formatSshRuntimeTimeoutHint, + formatSshRuntimeTimingHint, + formatSshStdoutTruncationHint, + formatSshTcpPoolHint, + formatSshTruncationCompletionSummary, + remoteCommandForRoute, + runSsh, + runSshCommandCapture, + sshCaptureBackendPlan, + sshFailureHint, + sshRuntimeTimeoutHint, + sshRuntimeTimeoutMs, + sshRuntimeTimingHint, + sshSlowWarningThresholdMs, + sshStderrStreamMaxBytes, + sshStdoutStreamMaxBytes, + sshStdoutTruncationHint, + sshTcpPoolHint, + sshTruncationCompletionSummary, + sshWindowsPlaneHint, +} from "./ssh-runtime"; +export type { SshStreamForwarder } from "./ssh-runtime"; +export { windowsFsReadOnlyScript } from "./ssh-windows-fs";