fix: harden Windows trans helpers
Resolve #1691 by preserving argv boundaries, adding bounded native rg and wc/skill query support, surfacing WSL-to-Windows hints, and splitting the oversized SSH module and embedded remote scripts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,8 @@ description: UniDesk SSH 透传与 apply-patch 语法 — `trans ROUTE OPERATION
|
||||
|
||||
分布式 SSH 透传入口。`trans <route> <operation>` 中 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/<drive>` 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、解释器或工具链的命令优先走 `<provider>:win/<drive>/<path> cmd|ps`;不得因同一 WSL provider 的 host/WSL plane 缺少 `python`、编译器或其他命令,就判断 Windows plane 也未安装。Windows Python 例如 `trans G14-WSL:win/d/Work/CONSTAR_workspace cmd python --version`。
|
||||
- `/mnt/<drive>` workspace 的命令返回 `command not found` 时,CLI 会输出对应 `:win/<drive>/... cmd` 的 `UNIDESK_SSH_HINT`;该提示只建议核对 Windows plane,不改变原命令退出码。
|
||||
- Windows `git` helper 拒绝包含 `%` format 等需 shell review 的复杂参数时,按错误提示改用 `trans <route>:win/... ps '<PowerShell command>'`,不要反复改写 direct `git` argv 绕过校验。
|
||||
- 从本地 Bash 调用一行 Windows `ps` 时,PowerShell 表达式外层使用单引号,保护 `$`、`$_` 和 `$_.Property` 不被本地 shell 提前展开;复杂或多行 PowerShell 改用 `ps <<'PS'` heredoc。
|
||||
- 扩展 Windows helper 时保持 operation-scoped PowerShell payload;不要把多操作大脚本塞进 single `EncodedCommand`。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 使用 `<provider>:/absolute/workspace`,例如 `D601:/home/ubuntu/workspace/hwlab-dev`,CLI 会把该路径作为远端 cwd 传给 Host SSH 维护桥,后续 `pwd`、`git`、`sh`、`bash`、`apply-patch` 和旧 `apply-patch-v1` fallback 等操作仍按同一套 operation parser 执行。`<provider>: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 别名:`<provider>:win ps` 在 WSL provider 上启动 Windows PowerShell,stdin heredoc 会被写入临时 `.ps1` 后执行;`<provider>:win cmd` 启动 Windows host 的 `cmd.exe`,stdin heredoc 会被写入临时 `.cmd` 后执行;`<provider>:win skills` 发现 Windows skill 目录。需要 Windows 当前目录时使用 slash 路由 `<provider>:win/<drive>/<path>`,例如 `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 别名:`<provider>:win ps` 在 WSL provider 上启动 Windows PowerShell,stdin heredoc 会被写入临时 `.ps1` 后执行;`<provider>:win cmd` 启动 Windows host 的 `cmd.exe`,stdin heredoc 会被写入临时 `.cmd` 后执行;`<provider>:win skills` 发现 Windows skill 目录。需要 Windows 当前目录时使用 slash 路由 `<provider>:win/<drive>/<path>`,例如 `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/<drive>` 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
|
||||
```
|
||||
|
||||
`<provider>: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` 或宽泛扫描整个用户目录。
|
||||
`<provider>: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`。
|
||||
|
||||
`<provider>:win/<drive>/<path> 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 控制面;`<provider>:k3s:<namespace>:<workload>[:container]` 定位到 namespace 下的一个默认 deployment workload;若目标是具体 Pod,标准 workload 段写成 `pod:<podid>`,例如 `D601:k3s:hwlab-v03:pod:hwlab-cloud-api-abc:hwlab-cloud-api`。`pod/<podid>` 不是合法 route 写法,因为 `:` 是分布式路由分隔符,`/` 只表示目标容器里的文件系统 cwd;如果调用者写成 `pod/<podid>/<container>`,CLI 必须在连接运行面前报错并提示改用 `pod:<podid>:<container>` 或 `--container <container>`。若目标是 Deployment,也可以显式写 `deployment:<name>` 或简写 `<name>`;容器选择写 `:<container>` 或 operation 参数 `--container <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 -- <command> ...` 是 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 <providerId> argv <command> [args...]` 是通用 argv 安全拼接入口;`exec` 是同义入口。它是非交互远端单进程命令的默认成功路径,不需要 shell 管道时直接传命令和参数,例如 `trans D601 argv true`。需要管道、重定向、变量展开或多条命令时,优先改用 `trans <providerId> 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 <providerId> argv <command> [args...]` 是通用 argv 安全拼接入口;`exec` 是同义入口。它是非交互远端单进程命令的默认成功路径,不需要 shell 管道时直接传命令和参数,例如 `trans D601 argv true`。直接写未列入专用 helper 的单进程命令时也会逐 token shell quote,不再把已形成的多词参数重新按空格拆分。需要管道、重定向、变量展开或多条命令时,优先改用 `trans <providerId> 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/<drive>` workspace 上的命令若以 127 和 `command not found` 失败,stderr 会给出同 provider 对应 `:win/<drive>/... cmd` 的 `UNIDESK_SSH_HINT`,用于核对 Windows PATH;原退出码保持不变。
|
||||
|
||||
通过 `trans <providerId>` 执行多行脚本时,优先使用结构化 helper,例如 `trans G14 py < script.py`、`trans G14 sh <<'SH'` 或 `trans G14:k3s sh <<'SH'`。不要在远端命令字符串里再嵌套 heredoc、复杂引号或 `ssh 'python3 - <<EOF ...'` 形态;多层 shell 解析容易把 stdin 绑定到错误进程,结果会打开远端交互解释器并留下悬挂的 broker/SSH 会话。长脚本需要复用时,优先提交到 repo 或通过 stdin 传输到目标节点执行。
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
describe("gh Markdown stdin", () => {
|
||||
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: "-" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
+11
-2
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
@@ -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 "$@"
|
||||
@@ -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())
|
||||
@@ -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/<drive>/...")
|
||||
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())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<string>(["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<WindowsFsReadOnlyOperation, string> = {
|
||||
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<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requiredRecord(value: Record<string, unknown>, key: string, path: string): Record<string, unknown> {
|
||||
return asRecord(value[key], `${path}.${key}`);
|
||||
}
|
||||
|
||||
function positiveInt(value: Record<string, unknown>, 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, "''")}'`;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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") } }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+46
-1
@@ -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"]);
|
||||
|
||||
+58
-2241
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user