diff --git a/AGENTS.md b/AGENTS.md index fc879621..92b823e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts server status`:查询固定端口、容器状态、健康检查和访问 URL,判定标准见 `docs/reference/deployment.md`。 - `bun scripts/cli.ts server logs`:分页返回文件日志与 Docker 日志尾部,日志规则见 `docs/reference/observability.md`。 - `bun scripts/cli.ts server rebuild `:以 build-first、label-scoped replace 的异步 job 重建单个服务,避免 Docker Compose v1 recreate 问题,规则见 `docs/reference/deployment.md`。 -- `bun scripts/cli.ts ssh [ssh-like args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥打开近似原生 ssh 的交互会话或远端命令,并在远端 PATH 注入 `apply_patch`;`bun scripts/cli.ts ssh apply-patch < patch.diff` 可直接调用该远端补丁工具,使用规则见 `docs/reference/cli.md` 和 `docs/reference/provider-gateway.md`。 +- `bun scripts/cli.ts ssh [ssh-like args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥打开近似原生 ssh 的交互会话或远端命令,并在远端 PATH 注入 `apply_patch` 与 `glob`;`apply-patch`、`py`、结构化 `find`、`glob` 和 `argv` 子命令用于避免远端补丁、Python stdin 执行与常用只读命令的嵌套转义问题,使用规则见 `docs/reference/cli.md` 和 `docs/reference/provider-gateway.md`。 - `bun scripts/cli.ts microservice list/status/health/proxy`:管理和验证挂载在主 server 或计算节点 Docker 中的业务 microservice,Todo Note on main-server 与 FindJob/Pipeline/MET Nonlinear on D601 的规则见 `docs/reference/microservices.md`。 - `bun scripts/cli.ts server stop`:以异步 job 停止固定 Compose 项目中的全部 UniDesk 服务,停止后用 `server status` 复核。 - `bun scripts/cli.ts job list` / `bun scripts/cli.ts job status latest`:查询 `.state/jobs/` 中的异步任务状态,job 机制见 `docs/reference/cli.md`。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 4b2d5efb..a25af4a7 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -15,6 +15,7 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定 - `server rebuild ` 创建异步 job,先构建目标服务镜像,构建成功后只按 Compose project/service label 移除该服务旧容器,再用 `--no-deps` 启动目标服务;该命令用于替代手工删除容器的兜底流程,其中 `todo-note` 只重建主 server 承载的 Todo Note 后端,不会重建或删除 database 命名卷。 - `ssh [ssh-like args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;无后续参数时进入远端登录 shell,有后续参数时按 ssh 远端命令体验执行并返回远端 exit code。 - `ssh apply-patch [tool args...] < patch.diff` 直接调用远端注入的 `apply_patch` 工具,并把本地 stdin 中的标准 `*** Begin Patch` / `*** End Patch` patch 流透传给目标节点。 +- `ssh py [script-args...] < script.py` 把本地 stdin 落到远端临时 `.py` 文件后再以 `python3 -u` 执行并自动清理,避免再手写 `'python3 -'`、heredoc 或多层引号;`script-args` 会按 argv 安全透传给远端脚本。 - `microservice list/status/health/proxy` 通过 backend-core 内网 API 管理挂载在计算节点 Docker 中的 microservice;`health` 和 `proxy` 会走真实 backend-core -> provider-gateway -> 节点本机后端链路,`proxy` 对超大 body 默认输出有界预览,规则见 `docs/reference/microservices.md`。 - `job list` 与 `job status` 查询 `.state/jobs/` 文件系统状态,是异步命令的可观测入口。 - `debug health`、`debug dispatch` 与 `debug task` 走真实内部 core、WebSocket、数据库、provider、系统指标、Docker 状态和 Host SSH 维护桥流程,只用于开发调试,不写入 `TEST.md` 的正式验收步骤。 @@ -46,7 +47,7 @@ core 只允许声明了 `host.ssh` capability 的 provider 使用 `ssh` 透传 本地 broker 默认等待 provider SSH 会话打开 60000ms,以便在目标节点同时有较多 microservice.http 任务时仍能建立维护会话;需要诊断慢连接时可用 `UNIDESK_SSH_OPEN_TIMEOUT_MS=` 临时调大,但最小有效值固定为 15000ms,避免把真实离线误判为长时间阻塞。 -`ssh ` 会在远端会话启动时注入 `/tmp/unidesk-ssh-tools/apply_patch`,并把该目录加入远端 `PATH`。该工具接受标准 `*** Begin Patch` / `*** End Patch` patch 格式,便于通过 SSH 透传编辑远端仓库文件;目标节点需要具备 `python3` 和 `base64`。注入工具只写 `/tmp/unidesk-ssh-tools`,不修改目标仓库,交互式 shell 和远端命令都可以直接调用 `apply_patch`。 +`ssh ` 会在远端会话启动时注入 `/tmp/unidesk-ssh-tools/apply_patch` 和 `/tmp/unidesk-ssh-tools/glob`,并把该目录加入远端 `PATH`。`apply_patch` 接受标准 `*** Begin Patch` / `*** End Patch` patch 格式,便于通过 SSH 透传编辑远端仓库文件;`glob` 在远端用 Python 执行路径匹配,避免依赖 shell glob 展开。目标节点需要具备 `python3` 和 `base64`。注入工具只写 `/tmp/unidesk-ssh-tools`,不修改目标仓库,交互式 shell 和远端命令都可以直接调用这些工具。 如果只是远端打小补丁,不需要再手写 `ssh D601 'apply_patch' < patch.diff` 这种命令拼接;正式入口是 `bun scripts/cli.ts ssh D601 apply-patch < patch.diff`。`apply-patch` 与 `patch` 等价,附加参数会原样透传给远端 `apply_patch`,例如 `bun scripts/cli.ts ssh D601 apply-patch --help`。标准单命令用法如下,不需要先创建本地 patch 临时文件: @@ -61,13 +62,35 @@ bun scripts/cli.ts ssh D601 apply-patch <<'PATCH' PATCH ``` -通过 `ssh ` 执行多行脚本时,脚本内容必须从本地 stdin 直接喂给远端解释器,例如 `bun scripts/cli.ts ssh D601 'python3 -' < script.py` 或 `printf ... | (bun scripts/cli.ts ssh D601 'bash -s')` 这种单层 stdin 传输。不要在远端命令字符串里再嵌套 heredoc、复杂引号或 `ssh 'python3 - < py` 的附加参数是脚本参数,不是 Python 解释器参数;如需 `-m`、`-X` 或多条 shell 命令,仍使用原始远端命令入口。为了保证 CLI 输出及时可见且不被 `ssh -tt` 误触发交互 REPL,helper 固定采用“临时文件 + `python3 -u`”模式。 + +`ssh find` 是常用远端搜索的结构化入口,避免在 Host SSH / WSL SSH 透传里手写 `find \( ... \)`、`*`、管道和多层引号。它会把路径、谓词和 pattern 作为 argv 安全拼接,并支持重复 `--name`、`--iname`、`--path` 或 `--ipath`,重复 pattern 默认按 OR 组合。稳定参数包括 `--max-depth`/`-maxdepth`、`--min-depth`/`-mindepth`、`--type`/`-type`、`--contains`、`--icontains`、`--name`/`-name`、`--iname`/`-iname`、`--path`/`-path`、`--ipath`/`-ipath`、`--mtime`/`-mtime`、`--mmin`/`-mmin`、`--size`/`-size`、`--sort` 和 `--limit N`。典型用法: + +```bash +bun scripts/cli.ts ssh D601 find /home/ubuntu --max-depth 4 --type d --icontains pika --limit 50 --sort +``` + +`ssh glob` 是远端 glob 匹配入口,支持 `--root DIR`、`--pattern PATTERN`、`--contains TEXT`、`--icontains TEXT`、`--type any|f|d`、`--limit N`、`--sort` 和 `--absolute`。`--contains` 与 `--icontains` 可避免在本地 shell 中输入 `*`;若显式使用 `--pattern '**/*.ts'` 这类 pattern,仍应按本地 shell 规则加引号,防止参数到达 CLI 前已被本地 shell 展开。典型用法: + +```bash +bun scripts/cli.ts ssh D601 glob --root /home/ubuntu/pikapython --pattern '**/*-test.cpp' --limit 20 --sort +``` + +`ssh argv [args...]` 是通用 argv 安全拼接入口;`exec` 是同义入口。它适合不需要 shell 管道的常用命令。`find`、`glob` 和 `apply-patch` 有专用入口;`rg`、`grep`、`sed`、`nl`、`stat`、`du`、`ls`、`cat`、`head`、`tail`、`wc` 和 `pwd` 可以直接作为 `ssh` 子命令使用,CLI 会对每个 argv token 做 shell quoting。需要管道、重定向、变量展开或多条命令时仍使用旧的自由远端命令入口,并把整段远端 shell 脚本作为一个本地参数传入。 + +通过 `ssh ` 执行多行脚本时,优先使用结构化 helper,例如 `bun scripts/cli.ts ssh D601 py < script.py` 或 `printf ... | (bun scripts/cli.ts ssh D601 'bash -s')` 这种单层 stdin 传输。不要在远端命令字符串里再嵌套 heredoc、复杂引号或 `ssh 'python3 - <:/` 获取 HttpOnly session cookie,然后通过 frontend 的 `/api/*` 同源代理访问 backend-core 内网 API;因此计算节点只需要能访问公网 frontend,不需要主 server SSH key,也不需要打开 backend-core REST API 或 PostgreSQL 端口。 -默认 frontend 传输支持 `debug health`、`debug dispatch`、`debug task`、`microservice list/status/health/proxy` 和 `ssh `。其中 `ssh` 的 remote frontend 传输使用 `host.ssh` dispatch 执行有界远端命令,适合 `ssh D601 hostname` 这类自测;交互式登录 shell 仍应在主 server 本机 CLI 使用,或显式切换到旧 SSH 传输后在主 server 上执行。若确实需要旧行为,可使用 `--main-server-key ` 或 `--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts `。 +默认 frontend 传输支持 `debug health`、`debug dispatch`、`debug task`、`microservice list/status/health/proxy` 和 `ssh `。其中 `ssh` 的 remote frontend 传输使用 `host.ssh` dispatch 执行有界远端命令,适合 `ssh D601 hostname` 这类自测;交互式登录 shell 仍应在主 server 本机 CLI 使用,或显式切换到旧 SSH 传输后在主 server 上执行。frontend 远程透传不会流式转发本地 stdin,因此 `ssh py < script.py`、`ssh apply-patch < patch.diff` 这类 stdin-backed helper 必须在主 server 本机运行,或显式切换到 `--main-server-transport ssh`。若确实需要旧行为,可使用 `--main-server-key ` 或 `--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts `。 计算节点可以用该入口测试自身的远程升级闭环,而不需要在计算节点公开 core REST API 或 database。标准顺序是:先运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health` 确认主 server 看到当前 Provider 在线,且该 Provider labels 中 `unideskCapabilities` 包含 `host.ssh`、`hostSshConfigured=true`、`hostSshKeyPresent=true`;再运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch provider.upgrade --mode schedule --wait-ms 15000` 触发真实 `provider.upgrade`;随后再次运行 `debug health` 确认节点重新上线;最后运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch host.ssh --wait-ms 15000` 和 `bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh hostname` 验证 SSH 透传能力。provider-gateway 新部署或升级后没有完成这组 remote CLI 自测,不能视为交付完成。 diff --git a/scripts/cli.ts b/scripts/cli.ts index 21a71211..06bd368b 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -27,8 +27,12 @@ function help(): unknown { { command: "server status", description: "Show fixed ports, containers, service health, and public URLs." }, { command: "server logs [--tail-bytes N]", description: "Return bounded tails from file logs and docker logs." }, { command: "server rebuild ", description: "Build first, then label-replace one service without Docker Compose v1 recreate fallback." }, - { command: "ssh [ssh-like args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge with built-in apply_patch in PATH." }, + { command: "ssh [ssh-like args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge with built-in remote helper tools in PATH." }, { command: "ssh apply-patch [tool args...] < patch.diff", description: "Invoke the injected remote apply_patch helper directly over SSH passthrough and stream the patch from local stdin." }, + { command: "ssh py [script-args...] < script.py", description: "Run remote Python from local stdin through SSH passthrough without nested shell quoting; extra args become script argv." }, + { command: "ssh find [--max-depth N] [--type d|f|l] [--contains TEXT] [--iname PATTERN] [--limit N] [--sort]", description: "Run a structured remote find command without nested shell quoting or parentheses." }, + { command: "ssh glob [--root DIR] [--pattern PATTERN] [--contains TEXT] [--type any|f|d] [--limit N] [--sort]", description: "Run remote glob matching through the injected helper without shell glob expansion." }, + { command: "ssh argv [args...]", description: "Run a remote command with each argv token shell-quoted by UniDesk before SSH passthrough." }, { command: "microservice list", description: "List UniDesk-managed microservices and their provider/runtime mapping." }, { command: "microservice status ", description: "Show one microservice config, repository reference, backend mapping, and runtime status." }, { command: "microservice health ", description: "Probe one microservice through backend-core -> provider-gateway HTTP proxy." }, diff --git a/scripts/src/remote.ts b/scripts/src/remote.ts index 18542d13..a7ce9cb5 100644 --- a/scripts/src/remote.ts +++ b/scripts/src/remote.ts @@ -381,6 +381,10 @@ async function remoteMicroservice(session: FrontendSession, args: string[]): Pro async function runRemoteSshOverFrontend(session: FrontendSession, providerId: string | undefined, args: string[]): Promise { if (!providerId) throw new Error("remote ssh requires provider id, for example: bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh D601 hostname"); const parsed = parseSshArgs(args); + if (parsed.requiresStdin) { + process.stderr.write("remote frontend transport does not stream stdin for ssh helper subcommands such as apply-patch or py; run the command on the main server or use --main-server-transport ssh\n"); + return 255; + } if (parsed.remoteCommand === null) { process.stderr.write("remote frontend transport supports ssh remote commands only; pass a command such as: ssh D601 hostname\n"); return 255; diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 6eb8567d..e4836570 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -3,8 +3,11 @@ import { type UniDeskConfig, repoRoot } from "./config"; export interface ParsedSshArgs { remoteCommand: string | null; + requiresStdin: boolean; } +const argvQuotedSshSubcommands = new Set(["rg", "grep", "sed", "nl", "stat", "du", "ls", "cat", "head", "tail", "wc", "pwd"]); + const remoteApplyPatchSource = String.raw`#!/usr/bin/env python3 import sys from pathlib import Path @@ -148,6 +151,71 @@ if __name__ == "__main__": 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 sshOptionsWithValue = new Set([ "-B", "-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J", "-L", "-l", "-m", "-O", "-o", "-p", "-Q", "-R", "-S", "-W", "-w", ]); @@ -156,7 +224,24 @@ export function parseSshArgs(args: string[]): ParsedSshArgs { const subcommand = args[0] ?? ""; if (subcommand === "apply-patch" || subcommand === "patch") { const toolArgs = ["apply_patch", ...args.slice(1)]; - return { remoteCommand: toolArgs.map(shellQuote).join(" ") }; + return { remoteCommand: shellArgv(toolArgs), requiresStdin: true }; + } + if (subcommand === "py") { + return { remoteCommand: buildPythonStdinCommand(args.slice(1)), requiresStdin: true }; + } + if (subcommand === "argv" || subcommand === "exec") { + const toolArgs = args.slice(1); + if (toolArgs.length === 0) throw new Error(`ssh ${subcommand} requires a command`); + return { remoteCommand: shellArgv(toolArgs), requiresStdin: false }; + } + if (subcommand === "find") { + return { remoteCommand: buildFindCommand(args.slice(1)), requiresStdin: false }; + } + if (subcommand === "glob") { + return { remoteCommand: shellArgv(["glob", ...args.slice(1)]), requiresStdin: false }; + } + if (argvQuotedSshSubcommands.has(subcommand)) { + return { remoteCommand: shellArgv(args), requiresStdin: false }; } const remote: string[] = []; let remoteStarted = false; @@ -177,20 +262,130 @@ export function parseSshArgs(args: string[]): ParsedSshArgs { remoteStarted = true; remote.push(arg); } - return { remoteCommand: remote.length === 0 ? null : remote.join(" ") }; + return { remoteCommand: remote.length === 0 ? null : remote.join(" "), requiresStdin: false }; +} + +function shellArgv(args: string[]): string { + return args.map(shellQuote).join(" "); } function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } +function positiveInt(value: string, option: string): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${option} must be a positive integer`); + return parsed; +} + +function findOptionValue(args: string[], index: number, option: string): string { + const value = args[index + 1]; + if (value === undefined || value.length === 0) throw new Error(`ssh find ${option} requires a value`); + return value; +} + +function buildFindCommand(args: string[]): string { + const paths: string[] = []; + const predicates: string[] = []; + const patternPredicates: Array<[string, string]> = []; + let limit: number | null = null; + let sortOutput = false; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] ?? ""; + if (arg === "--limit") { + const value = findOptionValue(args, index, arg); + limit = positiveInt(value, "ssh find --limit"); + index += 1; + continue; + } + if (arg === "--sort") { + sortOutput = true; + continue; + } + if (arg === "--max-depth" || arg === "-maxdepth" || arg === "--min-depth" || arg === "-mindepth") { + const value = findOptionValue(args, index, arg); + const findArg = arg === "--max-depth" ? "-maxdepth" : arg === "--min-depth" ? "-mindepth" : arg; + predicates.push(findArg, String(positiveInt(value, `ssh find ${arg}`))); + index += 1; + continue; + } + if (arg === "--type" || arg === "-type") { + const value = findOptionValue(args, index, arg); + if (!/^[bcdpfls]$/u.test(value)) throw new Error("ssh find --type must be one of: b c d p f l s"); + predicates.push("-type", value); + index += 1; + continue; + } + if (arg === "--name" || arg === "-name" || arg === "--iname" || arg === "-iname" || arg === "--path" || arg === "-path" || arg === "--ipath" || arg === "-ipath") { + const value = findOptionValue(args, index, arg); + const findArg = arg.startsWith("--") ? `-${arg.slice(2)}` : arg; + patternPredicates.push([findArg, value]); + index += 1; + continue; + } + if (arg === "--contains" || arg === "--icontains") { + const value = findOptionValue(args, index, arg); + const findArg = arg === "--contains" ? "-name" : "-iname"; + patternPredicates.push([findArg, `*${value}*`]); + index += 1; + continue; + } + if (arg === "--mtime" || arg === "-mtime" || arg === "--mmin" || arg === "-mmin" || arg === "--size" || arg === "-size") { + const value = findOptionValue(args, index, arg); + const findArg = arg.startsWith("--") ? `-${arg.slice(2)}` : arg; + predicates.push(findArg, value); + index += 1; + continue; + } + if (arg.startsWith("-")) { + throw new Error(`unsupported ssh find option: ${arg}`); + } + paths.push(arg); + } + + const findArgs = ["find", ...(paths.length > 0 ? paths : ["."]), ...predicates]; + if (patternPredicates.length === 1) { + const [kind, pattern] = patternPredicates[0]!; + findArgs.push(kind, pattern); + } else if (patternPredicates.length > 1) { + findArgs.push("("); + patternPredicates.forEach(([kind, pattern], index) => { + if (index > 0) findArgs.push("-o"); + findArgs.push(kind, pattern); + }); + findArgs.push(")"); + } + findArgs.push("-print"); + + let command = shellArgv(findArgs); + if (sortOutput) command = `${command} | sort`; + if (limit !== null) command = `${command} | head -n ${limit}`; + return command; +} + +function buildPythonStdinCommand(args: string[]): string { + const pythonArgs = args.map(shellQuote).join(" "); + const execArgs = pythonArgs.length > 0 ? ` "$UNIDESK_SSH_PY_FILE" ${pythonArgs}` : ' "$UNIDESK_SSH_PY_FILE"'; + return [ + 'UNIDESK_SSH_PY_FILE="$(mktemp /tmp/unidesk-ssh-py.XXXXXX.py)" || exit 1', + `trap 'rm -f "$UNIDESK_SSH_PY_FILE"' EXIT`, + 'cat > "$UNIDESK_SSH_PY_FILE"', + `python3 -u${execArgs}`, + ].join("; "); +} + function remoteToolBootstrapCommand(): string { - const encoded = Buffer.from(remoteApplyPatchSource, "utf8").toString("base64"); + const encodedApplyPatch = Buffer.from(remoteApplyPatchSource, "utf8").toString("base64"); + const encodedGlob = Buffer.from(remoteGlobSource, "utf8").toString("base64"); return [ "UNIDESK_SSH_TOOL_DIR=/tmp/unidesk-ssh-tools", 'mkdir -p "$UNIDESK_SSH_TOOL_DIR"', - `printf %s ${shellQuote(encoded)} | base64 -d > "$UNIDESK_SSH_TOOL_DIR/apply_patch"`, + `printf %s ${shellQuote(encodedApplyPatch)} | base64 -d > "$UNIDESK_SSH_TOOL_DIR/apply_patch"`, + `printf %s ${shellQuote(encodedGlob)} | base64 -d > "$UNIDESK_SSH_TOOL_DIR/glob"`, 'chmod 700 "$UNIDESK_SSH_TOOL_DIR/apply_patch"', + 'chmod 700 "$UNIDESK_SSH_TOOL_DIR/glob"', 'export PATH="$UNIDESK_SSH_TOOL_DIR:$PATH"', ].join("; "); }