fix: 收敛 trans 远端文本短路径
This commit is contained in:
@@ -36,12 +36,14 @@ Host workspace、k3s、Windows、GitHub issue/PR route 见 [references/routes.md
|
||||
- 顶层与 scoped help 都必须有界且直接返回;出现 `outputTruncated` 或 dump 即视为 CLI 缺陷。
|
||||
- 未知 help scope 和可预测参数错误必须紧凑、无 stack;只有显式 debug/raw 错误模式可以披露 stack,内部异常不得冒充输入错误。
|
||||
- 修复帮助过长时必须收敛信息结构,禁止提高 stdout 阈值。
|
||||
- dump 只允许用于显式 `--full`、`--raw` 或确实无法预先界定的一次性异常证据。
|
||||
- 远端文本修改优先 `trans <route> apply-patch`;不要 download/upload/sed 拼临时 diff 代替 patch。
|
||||
- 默认超限输出不得创建 dump;先返回语义化窄查询。
|
||||
- dump 只允许用于显式 `--full`、`--raw`、`UNIDESK_TRAN_STREAM_DUMP=1` 或确实无法预先界定的一次性异常证据。
|
||||
- 远端文本修改直接使用 `trans <route> apply-patch <<'PATCH'`;不要先建临时 patch 文件,也不要 download/upload/sed 拼临时 diff 代替 patch。
|
||||
- `upload` / `download` 仅用于必要的二进制文件或生成物:
|
||||
- 远端文本读取优先使用 `cat` / `rg`;
|
||||
- 远端文本修改优先使用 `apply-patch`;
|
||||
- 成功传输结果的 `hint` 会提示避免对远端文本反复上传下载。
|
||||
- 远端文本读取优先使用 `cat` / `head` / `tail` / `rg`;
|
||||
- 已知文本路径默认在传输和本地目标写入前返回 `text-transfer-discouraged`;
|
||||
- 确需整体搬运文本生成物时显式加 `--allow-text-transfer`,成功结果记录 override 且继续校验字节数和 SHA-256。
|
||||
- `apply-patch` 检测到交互 TTY 且没有 stdin 时立即返回 `apply-patch-stdin-required` 和 quoted heredoc 示例,不等待 60 秒超时。
|
||||
- Host/WSL 与 Windows route 的 `apply-patch` 优先走 fs adapter bulk update path;不要为了规避旧的 `read-b64-block` / `write-b64-argv` 慢路径改用临时脚本写文件。
|
||||
- `apply-patch v2` 字节与完整性边界:
|
||||
- localized replacement 必须保留纯 CRLF 和 `legacy-single-byte` 文件的原始字节风格。
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
# Remote Apply Patch
|
||||
# 远端文本补丁
|
||||
|
||||
Remote text edits should use `trans <route> apply-patch` with a normal apply-patch envelope.
|
||||
- 远端文本修改固定使用 quoted stdin heredoc:
|
||||
|
||||
- Keep hunks small and scoped.
|
||||
- Read enough context first with `rg`, `sed -n` or file-specific commands.
|
||||
- Avoid download/edit/upload or ad hoc `sed -i` as the final patch path.
|
||||
- For Windows routes, prefer the filesystem adapter path rather than shell-encoded write blocks.
|
||||
```bash
|
||||
trans <route> apply-patch <<'PATCH'
|
||||
*** Begin Patch
|
||||
*** Update File: path/to/file
|
||||
@@
|
||||
-old
|
||||
+new
|
||||
*** End Patch
|
||||
PATCH
|
||||
```
|
||||
|
||||
- 最短路径:
|
||||
- 先用 `cat`、`head`、`tail` 或 `rg` 读取足够的当前上下文;
|
||||
- 直接把标准 apply-patch envelope 送入 stdin;
|
||||
- hunk 保持小且只覆盖当前修改;
|
||||
- 上下文不匹配时重读目标块并缩小 hunk。
|
||||
- 禁止路径:
|
||||
- 不先创建临时 patch 文件再用 `< file` 转交;
|
||||
- 不使用 download/edit/upload、`sed -i`、远端 Python 或整文件覆盖替代文本 patch;
|
||||
- 不因 v2 上下文错误回退 `apply-patch-v1`。
|
||||
- 输入判定:
|
||||
- `apply-patch` 在交互 TTY 且没有 stdin 时立即返回 `apply-patch-stdin-required`;
|
||||
- 该错误直接给出 quoted heredoc 示例,不等待 60 秒传输超时;
|
||||
- Windows route 继续优先使用 filesystem adapter,不使用 shell 编码写入块绕行。
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
# Trans Operations
|
||||
# Trans 操作
|
||||
|
||||
Prefer direct, bounded operations:
|
||||
- 优先使用直接且有界的操作:
|
||||
- `cat` / `head` / `tail` / `rg`:读取远端文本;
|
||||
- `apply-patch <<'PATCH'`:修改远端文本;
|
||||
- `sh` / `bash`:只执行显式 shell 脚本,并保持输出简短;
|
||||
- `kubectl`:只做诊断和应急读取,正式 CI/CD 继续使用 UniDesk CLI;
|
||||
- `logs`:读取有界日志尾部,不输出完整 dump;
|
||||
- `py`:只用于 patch 不适用的一次性支持操作。
|
||||
|
||||
- `sh` / `bash`: explicit shell scripts only, with short output.
|
||||
- `kubectl`: diagnostics and emergency reads; formal CI/CD control remains in the UniDesk CLI.
|
||||
- `logs`: tail bounded logs, not full dumps.
|
||||
- `py`, `upload`, `download`: one-off support operations when patch is not appropriate.
|
||||
- `upload` / `download` 只用于二进制文件或需要整体搬运的生成物:
|
||||
- 已知文本扩展名或文件名默认在远端操作和本地目标写入前返回 `text-transfer-discouraged`;
|
||||
- 错误的第一条建议是 `cat` 或 `apply-patch <<'PATCH'`;
|
||||
- 确需整体搬运文本生成物时显式使用 `--allow-text-transfer`;
|
||||
- override 会记录在成功结果的 `transferPolicy`,且不会绕过字节数和 SHA-256 校验。
|
||||
|
||||
For a Windows workspace, run tools that belong to the Windows installation or PATH through the Windows plane: use `trans <provider>:win/<drive>/<path> cmd ...` for ordinary command-line tools and `ps` for PowerShell logic. For example, use `trans G14-WSL:win/d/Work/CONSTAR_workspace cmd python --version` or invoke a Python script there with `cmd python path\\to\\script.py ...`. A failed `python` lookup on `<provider>:/mnt/<drive>/...` describes only the WSL/host plane and is not evidence that Windows Python is absent.
|
||||
- Windows workspace 工具链:
|
||||
- Windows 安装或 PATH 中的普通命令使用 `trans <provider>:win/<drive>/<path> cmd ...`;
|
||||
- PowerShell 逻辑使用 `ps`;
|
||||
- `<provider>:/mnt/<drive>/...` 上找不到 `python` 只说明 WSL/host plane 缺少该命令,不能据此判断 Windows plane。
|
||||
|
||||
`download` returns verified bytes/SHA-256 evidence. On Windows routes, drive-letter paths are automatically mapped through the same provider's WSL `/mnt/<drive>` host route for binary streaming; for example `trans D518:win download 'D:\tmp\image.png' /tmp/image.png` does not require manually rewriting the source path to `/mnt/d/tmp/image.png`. Relative Windows downloads use the `win/<drive>/<path>` route workspace, such as `trans D518:win/d/tmp download image.png /tmp/image.png`. UNC paths still need to be copied to a drive-letter path first.
|
||||
- Windows `download`:
|
||||
- drive-letter 路径自动映射到同一 provider 的 WSL `/mnt/<drive>` host route 做二进制流传输;
|
||||
- 例如 `trans D518:win download 'D:\tmp\image.png' /tmp/image.png`;
|
||||
- 相对路径使用 `win/<drive>/<path>` route workspace;
|
||||
- UNC 路径仍需先复制到 drive-letter 路径。
|
||||
|
||||
`trans --help download` 返回紧凑的 download 合同、可执行示例和当前 `runtime.repoRoot` / `runtime.cliPath`。PATH wrapper 不根据 cwd 选择源码根;验证独立 worktree 时显式设置 `UNIDESK_TRANS_REPO_ROOT=<worktree>`,并先读取帮助中的实际 root,避免把主 worktree 的旧实现误认为当前 cwd 的实现。
|
||||
- `trans --help download`:
|
||||
- 首屏先展示文本读取短路径,再展示二进制 download;
|
||||
- 同时披露当前 `runtime.repoRoot` / `runtime.cliPath`;
|
||||
- 验证独立 worktree 时显式设置 `UNIDESK_TRANS_REPO_ROOT=<worktree>`。
|
||||
|
||||
Ordinary trans/SSH work must fit the short connection budget. Long build, CI/CD, trace, probe or rollout work must use submit-and-poll through a controlled job/status surface.
|
||||
- 普通 trans/SSH 必须满足短连接预算;长 build、CI/CD、trace、probe 或 rollout 使用受控 job/status submit-and-poll。
|
||||
|
||||
@@ -4,7 +4,7 @@ output:
|
||||
maxStdoutBytes: 10240
|
||||
dumpDir: /tmp/unidesk-cli-output
|
||||
includePreview: false
|
||||
warning: "CLI stdout exceeded YAML-configured limit; full output was dumped to /tmp for one-off drill-down only. This is a CLI usability defect: improve the command itself to print concise tables/summaries and id-specific progressive disclosure instead of repeatedly depending on dump extraction."
|
||||
warning: "CLI stdout 超出 YAML 配置上限;默认不会创建临时 dump,请使用语义化窄查询。只有显式 --full/--raw 或 UNIDESK_TRAN_STREAM_DUMP=1 才允许为一次性完整披露创建受保护 dump;重复依赖 dump 仍属于 CLI 易用性缺陷。"
|
||||
github:
|
||||
auth:
|
||||
tokenSource:
|
||||
|
||||
@@ -8,13 +8,21 @@ UniDesk 的统一 CLI 实现入口是根目录 `scripts/cli.ts`,运行方式
|
||||
- 顶层帮助只展示分组索引和可执行的下钻命令,详细合同通过 `<command> --help <operation>` 或等价 scoped help 获取。
|
||||
- 未知子命令、未知 help scope 和可预测 option 校验失败必须返回紧凑、结构化且无 stack 的输入错误;stack 只允许在显式 debug/raw 错误模式下披露,内部异常不得伪装成输入错误。
|
||||
- 默认输出或帮助输出触发 dump 视为 CLI 缺陷,必须收敛信息结构,禁止用提高 stdout 阈值掩盖。
|
||||
- dump 只允许用于用户显式请求的 `--full`、`--raw`、机器完整输出,或确实无法预先界定的一次性异常证据。
|
||||
- 默认超限只返回语义化窄查询,不创建 `/tmp/unidesk-cli-output` dump;`--json` 只选择机器格式,不等同于完整披露。
|
||||
- dump 只允许用于用户显式请求的 `--full`、`--raw`、`UNIDESK_TRAN_STREAM_DUMP=1`,或确实无法预先界定的一次性异常证据。
|
||||
|
||||
- stdin 文本输入默认直接使用 quoted heredoc:
|
||||
- 单次使用的正文、评论、prompt、patch、脚本或 JSON 必须直接写成 `--*-stdin <<'EOF'` 或等价 stdin heredoc。
|
||||
- 禁止先把同一段文本写入 `/tmp` 或工作区临时文件,再用 `< file` 传给支持 stdin 的命令。
|
||||
- 只有内容需要复用、作为正式工件保留、属于二进制,或目标工具明确只接受文件路径时,才使用 `--*-file`、upload 或文件重定向。
|
||||
- heredoc delimiter 必须引用,避免 `$()`、反引号、变量和反斜杠被本地 shell 提前展开。
|
||||
- `apply-patch` 检测到交互 TTY 且没有 stdin 时必须立即返回 `apply-patch-stdin-required` 和 `trans <route> apply-patch <<'PATCH'`,不得等待短连接超时。
|
||||
|
||||
- 远端文本与文件传输边界:
|
||||
- 文本读取使用 `cat`、`head`、`tail` 或 `rg`;
|
||||
- 文本修改使用 `apply-patch <<'PATCH'`,不得先建临时 patch 文件;
|
||||
- 已知文本路径的 `upload` / `download` 默认在远端操作和本地目标写入前返回 `text-transfer-discouraged`;
|
||||
- 只有确需整体搬运文本生成物时才显式加 `--allow-text-transfer`,成功结果必须记录 override 并继续执行字节数和 SHA-256 校验。
|
||||
|
||||
主 server 必须在 PATH 上提供 `/root/.local/bin/trans` 可执行 wrapper,内容委托 repo 内版本化 `scripts/trans` 并执行 `bun scripts/ssh-cli.ts ssh "$@"`;交互 shell 可额外提供 alias,但非交互 Codex `exec` 和脚本不能依赖 alias 展开。
|
||||
|
||||
|
||||
@@ -34,6 +34,26 @@ function applyReplacementPlan(original: string, plan: ApplyPatchV2BulkReplacemen
|
||||
return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
describe("apply-patch v2 stdin preflight", () => {
|
||||
test("fails immediately on an interactive TTY and points to a quoted heredoc", async () => {
|
||||
const stdin = Readable.from([]) as Readable & { isTTY?: boolean };
|
||||
Object.defineProperty(stdin, "isTTY", { value: true });
|
||||
const stdout = new CaptureWritable();
|
||||
const stderr = new CaptureWritable();
|
||||
const startedAtMs = Date.now();
|
||||
|
||||
const exitCode = await runApplyPatchV2({ executor: {}, stdin, stdout, stderr });
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(Date.now() - startedAtMs).toBeLessThan(1000);
|
||||
expect(stdout.text()).toBe("");
|
||||
expect(stderr.text()).toContain("UNIDESK_APPLY_PATCH_INPUT");
|
||||
expect(stderr.text()).toContain("\"code\":\"apply-patch-stdin-required\"");
|
||||
expect(stderr.text()).toContain("trans <route> apply-patch <<'PATCH'");
|
||||
expect(stderr.text()).toContain("\"temporaryFileRequired\":false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("apply-patch v2 fs bulk update path", () => {
|
||||
test("single fs update uses readFiles and applyReplacementsBulk instead of whole-file write", async () => {
|
||||
const files = new Map<string, string>([["docs/test.md", "alpha\nold\nomega\n"]]);
|
||||
|
||||
@@ -168,11 +168,11 @@ export function isApplyPatchV2HelpArgs(args: string[] = []): boolean {
|
||||
export function applyPatchV2HelpPayload() {
|
||||
return {
|
||||
ok: true,
|
||||
command: "trans <route> apply-patch < patch.diff",
|
||||
command: "trans <route> apply-patch <<'PATCH'",
|
||||
summary: "Apply a standard apply_patch patch to a remote route with the local v2 line-based engine.",
|
||||
usage: [
|
||||
"trans G14:/root/hwlab/.worktree/task apply-patch < patch.diff",
|
||||
"trans D601:/tmp apply-patch < patch.diff"
|
||||
"trans G14:/root/hwlab/.worktree/task apply-patch <<'PATCH'",
|
||||
"trans D601:/tmp apply-patch <<'PATCH'"
|
||||
],
|
||||
input: {
|
||||
required: true,
|
||||
@@ -201,7 +201,7 @@ export function applyPatchV2HelpPayload() {
|
||||
"Add File has no @@ hunk marker: put content immediately after `*** Add File: <path>` and prefix every content line with +.",
|
||||
"A blank line in Add File is a line containing only +.",
|
||||
"Update File uses @@ or @@ context markers, followed by context lines starting with one extra space prefix and changed lines starting with - or +; for a column-0 source line `const x`, write ` const x`, and for a two-space-indented source line write three spaces total. Unified-diff line-range headers are accepted with hints for MiniMax compatibility.",
|
||||
"Prefer `trans <route> apply-patch < /tmp/patch.diff` for long patches, Windows paths, or quoting-sensitive content.",
|
||||
"Send the patch directly through a quoted `<<'PATCH'` heredoc so paths, quotes, `$`, and backticks reach apply-patch unchanged; do not create a temporary patch file.",
|
||||
"If `failed to find expected lines` reports stale or oversized context for a block/function replacement, re-read the exact current block and retry with a smaller Update File hunk or split hunks around unique anchors.",
|
||||
"MiniMax compatibility: stray @@ or unprefixed content inside Add File, unprefixed Update File context lines, and extra hunk/body lines after Delete File, are accepted with stderr hints.",
|
||||
"MiniMax/MXCX concatenated patch compatibility: repeated nested Begin Patch / End Patch markers are accepted with stderr hints, but the CLI still uses only the v2 engine and never auto-falls back to apply-patch-v1."
|
||||
@@ -233,7 +233,7 @@ export function applyPatchV2HelpPayload() {
|
||||
"Do not recover from apply-patch context mismatch by switching to download/upload, remote Python/Perl/sed, or whole-file rewrites; retry with corrected v2 hunks.",
|
||||
"Do not use remote Python/Perl/sed heredocs for text patches when `trans <route> apply-patch` is available."
|
||||
],
|
||||
note: "apply-patch reads patch text from stdin and uses the v2 engine by default. Use `apply-patch-v1` only for the legacy helper."
|
||||
note: "apply-patch reads patch text directly from stdin and uses the v2 engine by default. Use `apply-patch-v1` only for the legacy helper."
|
||||
};
|
||||
}
|
||||
|
||||
@@ -373,9 +373,13 @@ export async function runApplyPatchV2(options: ApplyPatchV2Options): Promise<num
|
||||
stderr.write("ssh apply-patch uses the v2 engine and accepts no helper flags. Use apply-patch-v1 for legacy helper options.\n");
|
||||
return 2;
|
||||
}
|
||||
if ((options.stdin as Readable & { isTTY?: boolean }).isTTY === true) {
|
||||
stderr.write(`${applyPatchV2StdinHint("interactive-tty-without-stdin")}\n`);
|
||||
return 2;
|
||||
}
|
||||
const patchText = await readStreamText(options.stdin);
|
||||
if (!patchText.trim()) {
|
||||
stderr.write("ssh apply-patch requires patch text on stdin.\n");
|
||||
stderr.write(`${applyPatchV2StdinHint("empty-stdin")}\n`);
|
||||
return 2;
|
||||
}
|
||||
const startedAtMs = Date.now();
|
||||
@@ -416,6 +420,17 @@ export async function runApplyPatchV2(options: ApplyPatchV2Options): Promise<num
|
||||
}
|
||||
}
|
||||
|
||||
function applyPatchV2StdinHint(reason: "interactive-tty-without-stdin" | "empty-stdin"): string {
|
||||
return `UNIDESK_APPLY_PATCH_INPUT ${JSON.stringify({
|
||||
code: "apply-patch-stdin-required",
|
||||
reason,
|
||||
message: "apply-patch requires patch text from stdin and will not wait on an interactive TTY.",
|
||||
try: "trans <route> apply-patch <<'PATCH'",
|
||||
envelope: [beginMarker, "*** Update File: <path>", "@@", "-old", "+new", endMarker, "PATCH"],
|
||||
temporaryFileRequired: false,
|
||||
})}`;
|
||||
}
|
||||
|
||||
function createApplyPatchV2RemoteMetrics(): ApplyPatchV2RemoteMetrics {
|
||||
return { remoteOperationCount: 0, remoteOperationCounts: {}, remoteElapsedMs: 0, remoteFailureCount: 0 };
|
||||
}
|
||||
|
||||
+14
-11
@@ -31,8 +31,8 @@ export function rootHelp(): unknown {
|
||||
{ command: "provider attach <providerId> [--master-server URL] [--up] [--force] | provider triage <providerId> [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]", description: "Generate the minimal external provider-gateway env/compose bundle or run the low-noise read-only provider health triage contract." },
|
||||
{ command: "trans <route> [operation args...] (alias of ssh <route> ...)", description: "Open a Host SSH / WSL SSH maintenance session; provider WebSocket carries control and host.ssh.tcp-pool carries stdin/stdout/stderr data." },
|
||||
{ command: "trans gh:/owner/repo[/pr|/issue][/number[/1]] ls|cat|rg|apply-patch", description: "Treat GitHub PRs/issues as virtual text directories; `issue ls --search/--state` covers filtered reads, `cat|rg` reads first-floor body text, and `apply-patch` updates `body.md` through UniDesk gh plus apply-patch v2." },
|
||||
{ command: "trans <route> apply-patch < patch.diff", description: "Default remote text patch entry: apply a standard patch with the local TypeScript v2 engine while the remote route only reads and writes files." },
|
||||
{ command: "trans <route> upload <local-file> <remote-file> | trans <route> download <remote-file> <local-file>", description: "Transfer whole files through SSH passthrough with automatic endpoint byte/SHA-256 verification; downloads stream stdout over host.ssh.tcp-pool." },
|
||||
{ command: "trans <route> apply-patch <<'PATCH'", description: "Default remote text patch entry: send a quoted stdin heredoc directly to the local TypeScript v2 engine while the remote route only reads and writes files." },
|
||||
{ command: "trans <route> upload|download [--allow-text-transfer] ...", description: "Transfer binary files or generated artifacts through SSH passthrough with automatic endpoint byte/SHA-256 verification; known text paths fail before transfer unless explicitly overridden." },
|
||||
{ command: "trans <providerId> apply-patch-v1 [tool args...] < patch.diff", description: "Fallback to the injected legacy remote apply_patch helper directly over SSH passthrough and stream the patch from local stdin." },
|
||||
{ command: "trans <providerId> 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: "trans <providerId> sh|bash [arg...] <<'SH|BASH' ...", description: "Run a remote POSIX sh or Bash body from local stdin; the operation name declares the shell dialect, and removed `script`/`shell` operations fail with a migration hint." },
|
||||
@@ -258,8 +258,8 @@ const SSH_OPERATION_HELP: Record<SshHelpScope, SshOperationHelp> = {
|
||||
"apply-patch": {
|
||||
group: "patch",
|
||||
description: "默认远端文本修改入口;本地 v2 engine 计算变更,route 仅负责读写。",
|
||||
usage: ["trans <route> apply-patch [--cwd /path] < patch.diff"],
|
||||
notes: ["失败后重读当前目标块并缩小 hunk;不得自动回退 apply-patch-v1 或整文件 upload。"],
|
||||
usage: ["trans <route> apply-patch <<'PATCH'"],
|
||||
notes: ["直接在 quoted heredoc 中放置 Begin/End Patch envelope;不要先建临时 patch 文件。", "失败后重读当前目标块并缩小 hunk;不得自动回退 apply-patch-v1 或整文件 upload。"],
|
||||
},
|
||||
"apply-patch-v1": {
|
||||
group: "patch",
|
||||
@@ -269,13 +269,14 @@ const SSH_OPERATION_HELP: Record<SshHelpScope, SshOperationHelp> = {
|
||||
upload: {
|
||||
group: "transfer",
|
||||
description: "上传必要的二进制文件或生成物并自动核对字节数与 SHA-256。",
|
||||
usage: ["trans <route> upload <local-file> <remote-file>"],
|
||||
notes: ["远端文本读取使用 cat/rg,修改使用 apply-patch。"],
|
||||
usage: ["trans <route> apply-patch <<'PATCH'", "trans <route> upload <local-file> <remote-file>"],
|
||||
notes: ["已知文本路径默认在传输前返回 text-transfer-discouraged;仅整体搬运生成物时显式加 --allow-text-transfer。"],
|
||||
},
|
||||
download: {
|
||||
group: "transfer",
|
||||
description: "下载必要的二进制文件或生成物并自动核对字节数与 SHA-256。",
|
||||
usage: ["trans <route> download <remote-file> <local-file>"],
|
||||
usage: ["trans <route> cat <remote-text-file>", "trans <route> download <remote-file> <local-file>"],
|
||||
notes: ["已知文本路径默认在写本地目标前返回 text-transfer-discouraged;仅整体搬运生成物时显式加 --allow-text-transfer。"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -329,7 +330,7 @@ export function sshHelp(scope: SshHelpScope | undefined = undefined): unknown {
|
||||
boundaries: [
|
||||
"route 只定位目标,route 后的 token 全部交给 operation parser。",
|
||||
"单进程优先 argv;shell 逻辑显式使用 sh/bash,Windows 使用 ps/cmd。",
|
||||
"远端文本读取优先 cat/rg,修改优先 apply-patch;upload/download 仅用于必要的二进制或生成物。",
|
||||
"远端文本读取优先 cat/rg,修改优先 apply-patch heredoc;已知文本 upload/download 默认在传输前失败。",
|
||||
"普通 trans 短连接上限保持 60s;长流程使用受控 submit-and-poll 入口。",
|
||||
],
|
||||
cwdSemantics: {
|
||||
@@ -391,7 +392,7 @@ function sshDownloadHelp(): unknown {
|
||||
command: `${entrypoint} --help download`,
|
||||
output: "json",
|
||||
scope: "download",
|
||||
description: "下载一个必要的二进制文件或生成物,并自动核对远端/本地字节数与 SHA-256;远端文本改用 cat/rg/apply-patch。",
|
||||
description: "远端文本先用 cat/head/tail/rg;download 只整体搬运必要的二进制文件或生成物,并自动核对远端/本地字节数与 SHA-256。",
|
||||
runtime: {
|
||||
entrypoint,
|
||||
repoRoot,
|
||||
@@ -405,15 +406,17 @@ function sshDownloadHelp(): unknown {
|
||||
},
|
||||
},
|
||||
usage: [
|
||||
"trans <route> cat <remote-text-file>",
|
||||
"trans <route> download <remote-file> <local-file>",
|
||||
"trans <route> download --inactivity-timeout-ms <milliseconds> <remote-file> <local-file>",
|
||||
"trans <route> download --allow-text-transfer <generated-text-artifact> <local-file>",
|
||||
"trans --help download",
|
||||
],
|
||||
options: {
|
||||
"--inactivity-timeout-ms <milliseconds>": "Allow a controlled progress-emitting download to stay open while data continues flowing; ordinary trans operations retain the short runtime budget.",
|
||||
"--allow-text-transfer": "显式确认该文本路径属于需要整体搬运的生成物;默认文本读取仍使用 cat/head/tail/rg。",
|
||||
},
|
||||
contract: {
|
||||
hint: "避免对远端文本反复 upload/download;读取优先使用 cat/rg,修改优先使用 apply-patch。",
|
||||
hint: "已知文本路径在传输前返回 text-transfer-discouraged;读取优先使用 cat/head/tail/rg,修改优先使用 apply-patch heredoc。",
|
||||
pathOrder: ["remote-file", "local-file"],
|
||||
transport: "host.ssh.tcp-pool",
|
||||
strategy: "tcp-pool-stdout-stream",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { repoRoot } from "./config";
|
||||
|
||||
const dumpDir = "/tmp/unidesk-cli-output";
|
||||
|
||||
function runOversizedIssueOutput(token: string, explicit = false): { status: number | null; stdout: string; stderr: string } {
|
||||
const command = `gh issue view 1890 --repo pikasTech/unidesk --json body ${token}`;
|
||||
const script = [
|
||||
"import { emitJson } from './scripts/src/output.ts';",
|
||||
`emitJson(${JSON.stringify(command)}, {`,
|
||||
" command: 'issue view',",
|
||||
" repo: 'pikasTech/unidesk',",
|
||||
" issue: { number: 1890, title: 'large body', state: 'open', url: 'https://example.invalid/1890', body: 'x'.repeat(30000) },",
|
||||
"});",
|
||||
].join("\n");
|
||||
const args = ["-e", script, "output-test", ...(explicit ? ["--full"] : [])];
|
||||
const result = spawnSync("bun", args, { cwd: repoRoot, encoding: "utf8" });
|
||||
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
|
||||
}
|
||||
|
||||
function matchingDumps(token: string): string[] {
|
||||
if (!existsSync(dumpDir)) return [];
|
||||
return readdirSync(dumpDir).filter((name) => name.includes(token));
|
||||
}
|
||||
|
||||
describe("CLI oversized output disclosure", () => {
|
||||
test("default oversized output creates no dump and gives the semantic gh text route", () => {
|
||||
const token = `nodump-${process.pid}-${Date.now()}`;
|
||||
expect(matchingDumps(token)).toEqual([]);
|
||||
|
||||
const result = runOversizedIssueOutput(token);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
const payload = JSON.parse(result.stdout) as { data: { outputTruncated: boolean; dump?: unknown; next: string[]; disclosurePolicy: Record<string, unknown> } };
|
||||
expect(payload.data.outputTruncated).toBe(true);
|
||||
expect(payload.data.dump).toBeUndefined();
|
||||
expect(payload.data.disclosurePolicy).toMatchObject({ defaultCreatesDump: false, dumpCreated: false });
|
||||
expect(payload.data.next[0]).toBe("trans gh:/pikasTech/unidesk/issue/1890 cat");
|
||||
expect(matchingDumps(token)).toEqual([]);
|
||||
});
|
||||
|
||||
test("explicit full disclosure keeps a protected complete dump", () => {
|
||||
const token = `fulldump-${process.pid}-${Date.now()}`;
|
||||
expect(matchingDumps(token)).toEqual([]);
|
||||
|
||||
const result = runOversizedIssueOutput(token, true);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
const payload = JSON.parse(result.stdout) as { data: { dump: { path: string }; disclosurePolicy: Record<string, unknown> } };
|
||||
expect(payload.data.disclosurePolicy).toMatchObject({ defaultCreatesDump: false, dumpCreated: true });
|
||||
expect(existsSync(payload.data.dump.path)).toBe(true);
|
||||
expect(readFileSync(payload.data.dump.path, "utf8")).toContain(token);
|
||||
rmSync(payload.data.dump.path, { force: true });
|
||||
expect(matchingDumps(token)).toEqual([]);
|
||||
});
|
||||
});
|
||||
+66
-12
@@ -147,11 +147,20 @@ function normalizeErrorPayload(command: string, error: unknown): Record<string,
|
||||
if (error instanceof Error) {
|
||||
const structured = structuredCliErrorPayload(error, message);
|
||||
if (structured !== null) return structured;
|
||||
const fileTransfer = sshFileTransferErrorDetails(error);
|
||||
if (fileTransfer?.code === "text-transfer-discouraged") {
|
||||
return {
|
||||
name: error.name,
|
||||
message,
|
||||
...fileTransfer,
|
||||
debug: false,
|
||||
};
|
||||
}
|
||||
const debug = cliDebugEnabled();
|
||||
return {
|
||||
name: error.name,
|
||||
message,
|
||||
...(sshFileTransferErrorDetails(error) ?? {}),
|
||||
...(fileTransfer ?? {}),
|
||||
stack: error.stack ?? null,
|
||||
...(debug ? { debug: true } : {}),
|
||||
};
|
||||
@@ -208,7 +217,8 @@ function sshFileTransferErrorDetails(error: Error): Record<string, unknown> | nu
|
||||
if (error.name !== "SshFileTransferError") return null;
|
||||
const details = (error as Error & { details?: unknown }).details;
|
||||
if (typeof details !== "object" || details === null || Array.isArray(details)) return null;
|
||||
return { details };
|
||||
const code = (details as Record<string, unknown>).code;
|
||||
return { ...(typeof code === "string" ? { code } : {}), details };
|
||||
}
|
||||
|
||||
function parseStructuredErrorMessage(command: string, message: string): Record<string, unknown> | null {
|
||||
@@ -242,16 +252,20 @@ function renderEnvelope<T>(command: string, envelope: JsonEnvelope<T>): string {
|
||||
const trigger = outputDumpTrigger(fullText, policy, "json");
|
||||
if (trigger === null) return fullText;
|
||||
|
||||
const dump = dumpLargeOutput(command, fullText, "json", policy);
|
||||
const persistDump = explicitOutputDumpRequested();
|
||||
const dump = persistDump ? dumpLargeOutput(command, fullText, "json", policy) : null;
|
||||
const compactPayload = {
|
||||
outputTruncated: true,
|
||||
reason: trigger.reason,
|
||||
warning: policy.warning,
|
||||
message: "Full JSON output was written to a temporary file; stdout contains only file metadata and a compact summary.",
|
||||
disclosurePolicy: disclosurePolicy(policy),
|
||||
message: persistDump
|
||||
? "Explicit full/raw output exceeded the stdout budget and was written to a protected temporary dump."
|
||||
: "Default output exceeded the stdout budget; no temporary dump was created. Use the narrow query below, or rerun with explicit --full/--raw only when complete disclosure is required.",
|
||||
disclosurePolicy: disclosurePolicy(policy, persistDump),
|
||||
trigger,
|
||||
dump,
|
||||
...(dump === null ? {} : { dump }),
|
||||
summary: summarizeEnvelope(envelope),
|
||||
next: oversizedOutputNext(envelope),
|
||||
};
|
||||
const compactEnvelope: JsonEnvelope<Record<string, unknown>> = envelope.ok
|
||||
? { ok: envelope.ok, command: envelope.command, data: compactPayload }
|
||||
@@ -277,7 +291,8 @@ function renderTextOutput(command: string, text: string): string {
|
||||
const policy = cliOutputPolicy();
|
||||
const trigger = outputDumpTrigger(fullText, policy, "text");
|
||||
if (trigger === null) return fullText;
|
||||
const dump = dumpLargeOutput(command, fullText, "txt", policy);
|
||||
const persistDump = explicitOutputDumpRequested();
|
||||
const dump = persistDump ? dumpLargeOutput(command, fullText, "txt", policy) : null;
|
||||
const payload: JsonEnvelope<Record<string, unknown>> = {
|
||||
ok: true,
|
||||
command,
|
||||
@@ -285,15 +300,52 @@ function renderTextOutput(command: string, text: string): string {
|
||||
outputTruncated: true,
|
||||
reason: trigger.reason,
|
||||
warning: policy.warning,
|
||||
message: "Full text output was written to a temporary file; stdout contains only file metadata.",
|
||||
disclosurePolicy: disclosurePolicy(policy),
|
||||
message: persistDump
|
||||
? "Explicit full/raw text exceeded the stdout budget and was written to a protected temporary dump."
|
||||
: "Default text exceeded the stdout budget; no temporary dump was created. Rerun a narrower query, or request explicit --full/--raw disclosure.",
|
||||
disclosurePolicy: disclosurePolicy(policy, persistDump),
|
||||
trigger,
|
||||
dump,
|
||||
...(dump === null ? {} : { dump }),
|
||||
next: [
|
||||
"Use rg --max-count, head, tail, --limit, --tail-bytes, or an id-specific query.",
|
||||
"Use --full or --raw only when the complete one-off payload is intentionally required.",
|
||||
],
|
||||
},
|
||||
};
|
||||
return `${JSON.stringify(payload, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function explicitOutputDumpRequested(argv = process.argv.slice(2)): boolean {
|
||||
return argv.some((arg) => arg === "--full" || arg === "--raw");
|
||||
}
|
||||
|
||||
function oversizedOutputNext(envelope: JsonEnvelope<unknown>): string[] {
|
||||
const source = typeof envelope.data === "object" && envelope.data !== null
|
||||
? envelope.data as Record<string, unknown>
|
||||
: typeof envelope.error === "object" && envelope.error !== null
|
||||
? envelope.error as Record<string, unknown>
|
||||
: {};
|
||||
const repo = typeof source.repo === "string" ? source.repo : null;
|
||||
const issue = recordOrNull(source.issue);
|
||||
const pullRequest = recordOrNull(source.pullRequest);
|
||||
if (repo !== null && typeof issue?.number === "number") {
|
||||
return [
|
||||
`trans gh:/${repo}/issue/${issue.number} cat`,
|
||||
`bun scripts/cli.ts gh issue comments ${issue.number} --repo ${repo} --limit 8`,
|
||||
];
|
||||
}
|
||||
if (repo !== null && typeof pullRequest?.number === "number") {
|
||||
return [
|
||||
`trans gh:/${repo}/pr/${pullRequest.number} cat`,
|
||||
`bun scripts/cli.ts gh pr review-plan ${pullRequest.number} --repo ${repo}`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
"Use --limit, --tail-bytes, an id-specific view, or another semantic narrow query.",
|
||||
"Use --full or --raw only when the complete one-off payload is intentionally required.",
|
||||
];
|
||||
}
|
||||
|
||||
function dumpLargeOutput(command: string, text: string, extension: "json" | "txt", policy: CliOutputPolicy): Record<string, unknown> {
|
||||
mkdirSync(policy.dumpDir, { recursive: true, mode: 0o700 });
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/gu, "-");
|
||||
@@ -395,12 +447,14 @@ function summarizeEnvelope(envelope: JsonEnvelope<unknown>): Record<string, unkn
|
||||
return summary;
|
||||
}
|
||||
|
||||
function disclosurePolicy(policy: CliOutputPolicy): Record<string, unknown> {
|
||||
function disclosurePolicy(policy: CliOutputPolicy, dumpCreated: boolean): Record<string, unknown> {
|
||||
return {
|
||||
configPath: policy.configPath,
|
||||
maxStdoutBytes: policy.maxStdoutBytes,
|
||||
dumpDir: policy.dumpDir,
|
||||
includePreview: policy.includePreview,
|
||||
defaultCreatesDump: false,
|
||||
dumpCreated,
|
||||
recommendation: "Prefer k8s-style concise summaries/tables by default; expose full data through explicit --full/--raw/id-specific drill-down commands instead of large stdout.",
|
||||
...(policy.configError === undefined ? {} : { configWarning: policy.configError }),
|
||||
};
|
||||
@@ -657,7 +711,7 @@ function cliOutputPolicy(): CliOutputPolicy {
|
||||
maxStdoutBytes: EMERGENCY_OUTPUT_DUMP_THRESHOLD_BYTES,
|
||||
dumpDir: EMERGENCY_OUTPUT_DUMP_DIR,
|
||||
includePreview: false,
|
||||
warning: "CLI output policy YAML could not be loaded; emergency dump guard is active for one-off drill-down only. Fix config/unidesk-cli.yaml, then improve the noisy command itself to print concise tables/summaries and id-specific progressive disclosure instead of repeatedly depending on dump extraction.",
|
||||
warning: "CLI output policy YAML could not be loaded; emergency bounded-output guard is active and creates no default dump. Fix config/unidesk-cli.yaml, then improve the noisy command itself to print concise summaries and id-specific queries; use explicit --full/--raw only for one-off complete disclosure.",
|
||||
configPath: CLI_OUTPUT_CONFIG_RELATIVE_PATH,
|
||||
configError: message,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { runSshFileTransferOperation, type SshFileTransferCommandBuilders, type SshRemoteCommandExecutor } from "./ssh-file-transfer";
|
||||
import { parseSshInvocation } from "./ssh";
|
||||
|
||||
function fakeTransfer(content: Buffer): { executor: SshRemoteCommandExecutor; builders: SshFileTransferCommandBuilders; calls: string[] } {
|
||||
const calls: string[] = [];
|
||||
const sha256 = createHash("sha256").update(content).digest("hex");
|
||||
return {
|
||||
calls,
|
||||
builders: {
|
||||
buildRouteCommand(_route, command) {
|
||||
return JSON.stringify(command);
|
||||
},
|
||||
buildWindowsPowerShellCommand(script) {
|
||||
return script;
|
||||
},
|
||||
},
|
||||
executor: {
|
||||
async runRemoteCommand(remoteCommand) {
|
||||
const command = JSON.parse(remoteCommand) as string[];
|
||||
const marker = command.indexOf("unidesk-file-transfer");
|
||||
const operation = command[marker + 1] ?? "unknown";
|
||||
calls.push(operation);
|
||||
return operation === "stat"
|
||||
? { exitCode: 0, stdout: `${content.length} ${sha256}\n`, stderr: "" }
|
||||
: { exitCode: 0, stdout: "", stderr: "" };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ssh text transfer preflight", () => {
|
||||
test("rejects a known text download before remote work or local target creation", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "unidesk-text-transfer-"));
|
||||
const target = join(dir, "result.md");
|
||||
const fake = fakeTransfer(Buffer.from("text\n"));
|
||||
const invocation = parseSshInvocation("D601:/tmp", ["download", "/tmp/result.md", target]);
|
||||
try {
|
||||
await runSshFileTransferOperation(invocation, ["download", "/tmp/result.md", target], fake.executor, fake.builders);
|
||||
throw new Error("expected text download preflight to fail");
|
||||
} catch (error) {
|
||||
const typed = error as Error & { details?: Record<string, unknown> };
|
||||
expect(typed.name).toBe("SshFileTransferError");
|
||||
expect(typed.details?.code).toBe("text-transfer-discouraged");
|
||||
expect(typed.details?.targetCreated).toBe(false);
|
||||
expect(typed.details?.remoteOperationStarted).toBe(false);
|
||||
expect(JSON.stringify(typed.details)).toContain("trans D601:/tmp cat");
|
||||
expect(fake.calls).toEqual([]);
|
||||
expect(existsSync(target)).toBe(false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a known text upload before reading the local source or starting remote work", async () => {
|
||||
const fake = fakeTransfer(Buffer.from("text\n"));
|
||||
const missingSource = "/tmp/unidesk-text-transfer-source-does-not-exist.md";
|
||||
const invocation = parseSshInvocation("D601:/tmp", ["upload", missingSource, "/tmp/result.md"]);
|
||||
try {
|
||||
await runSshFileTransferOperation(invocation, ["upload", missingSource, "/tmp/result.md"], fake.executor, fake.builders);
|
||||
throw new Error("expected text upload preflight to fail");
|
||||
} catch (error) {
|
||||
const typed = error as Error & { details?: Record<string, unknown> };
|
||||
expect(typed.name).toBe("SshFileTransferError");
|
||||
expect(typed.details?.code).toBe("text-transfer-discouraged");
|
||||
expect(JSON.stringify(typed.details)).toContain("apply-patch <<'PATCH'");
|
||||
expect(fake.calls).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps verified binary upload as the default transfer workflow", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "unidesk-binary-transfer-"));
|
||||
const source = join(dir, "firmware.bin");
|
||||
const content = Buffer.from([0, 1, 2, 3, 255]);
|
||||
writeFileSync(source, content);
|
||||
const fake = fakeTransfer(content);
|
||||
const invocation = parseSshInvocation("D601:/tmp", ["upload", source, "/tmp/firmware.bin"]);
|
||||
try {
|
||||
expect(await runSshFileTransferOperation(invocation, ["upload", source, "/tmp/firmware.bin"], fake.executor, fake.builders)).toBe(0);
|
||||
expect(fake.calls).toEqual(["write-b64-argv", "stat"]);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("allows an explicit generated-text override without bypassing verification", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "unidesk-generated-transfer-"));
|
||||
const source = join(dir, "result.json");
|
||||
const content = Buffer.from("{\"ok\":true}\n", "utf8");
|
||||
writeFileSync(source, content);
|
||||
const fake = fakeTransfer(content);
|
||||
const invocation = parseSshInvocation("D601:/tmp", ["upload", "--allow-text-transfer", source, "/tmp/result.json"]);
|
||||
try {
|
||||
expect(await runSshFileTransferOperation(
|
||||
invocation,
|
||||
["upload", "--allow-text-transfer", source, "/tmp/result.json"],
|
||||
fake.executor,
|
||||
fake.builders,
|
||||
)).toBe(0);
|
||||
expect(fake.calls).toEqual(["write-b64-argv", "stat"]);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ interface SshFileTransferCliOptions {
|
||||
localPath: string;
|
||||
remotePath: string;
|
||||
inactivityTimeoutMs?: number;
|
||||
allowTextTransfer: boolean;
|
||||
}
|
||||
|
||||
interface SshFileTransferStat {
|
||||
@@ -99,12 +100,23 @@ const fileTransferWriteRawChunkBytes = 131_072;
|
||||
const fileTransferWriteB64ChunkChars = 1_398_104;
|
||||
const fileTransferProgressEveryChunks = 16;
|
||||
|
||||
const knownTextExtensions = new Set([
|
||||
".bat", ".bib", ".c", ".cc", ".cfg", ".cmd", ".conf", ".cpp", ".css", ".csv", ".cts", ".cxx",
|
||||
".diff", ".env", ".go", ".h", ".hpp", ".htm", ".html", ".ini", ".java", ".js", ".json", ".jsonl",
|
||||
".jsx", ".less", ".log", ".md", ".markdown", ".mts", ".mjs", ".patch", ".ps1", ".py", ".rs", ".scss",
|
||||
".sh", ".sql", ".svg", ".tex", ".text", ".toml", ".ts", ".tsv", ".tsx", ".txt", ".xml", ".yaml", ".yml",
|
||||
]);
|
||||
|
||||
const knownTextBasenames = new Set([
|
||||
".editorconfig", ".gitattributes", ".gitignore", "agents.md", "dockerfile", "license", "makefile", "mdtodo", "readme",
|
||||
]);
|
||||
|
||||
const fileTransferUsageHint = {
|
||||
code: "prefer-remote-text-operations",
|
||||
message: "避免对远端文本反复 upload/download;读取优先使用 cat/rg,修改优先使用 apply-patch。upload/download 仅用于必要的二进制文件或生成物传输。",
|
||||
message: "已知文本路径会在传输前被拦截;读取使用 cat/head/tail/rg,修改直接使用 apply-patch quoted heredoc。upload/download 仅用于必要的二进制文件或生成物。",
|
||||
preferredOperations: {
|
||||
read: ["cat", "rg"],
|
||||
edit: ["apply-patch"],
|
||||
read: ["cat", "head", "tail", "rg"],
|
||||
edit: ["apply-patch <<'PATCH'"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -121,6 +133,7 @@ export async function runSshFileTransferOperation(
|
||||
): Promise<number> {
|
||||
const options = parseSshFileTransferCliOptions(args);
|
||||
const localPath = path.resolve(options.localPath);
|
||||
const transferPolicy = enforceTextTransferPolicy(invocation, options, localPath);
|
||||
if (options.action === "upload") {
|
||||
const content = await readFile(localPath);
|
||||
const expected = { bytes: content.length, sha256: sha256HexBuffer(content) };
|
||||
@@ -141,6 +154,7 @@ export async function runSshFileTransferOperation(
|
||||
bytes: expected.bytes,
|
||||
sha256: expected.sha256,
|
||||
verified: true,
|
||||
transferPolicy,
|
||||
hint: fileTransferUsageHint,
|
||||
verification,
|
||||
transfer: write,
|
||||
@@ -159,6 +173,7 @@ export async function runSshFileTransferOperation(
|
||||
bytes: download.bytes,
|
||||
sha256: download.sha256,
|
||||
verified: true,
|
||||
transferPolicy,
|
||||
hint: fileTransferUsageHint,
|
||||
verification: download.verification,
|
||||
transfer: download.transfer,
|
||||
@@ -204,6 +219,7 @@ function parseSshFileTransferCliOptions(args: string[]): SshFileTransferCliOptio
|
||||
if (action !== "upload" && action !== "download") throw new Error("ssh file transfer requires upload or download");
|
||||
const positionals: string[] = [];
|
||||
let inactivityTimeoutMs: number | undefined;
|
||||
let allowTextTransfer = false;
|
||||
for (let index = 1; index < args.length; index += 1) {
|
||||
const arg = args[index] ?? "";
|
||||
if (arg === "--") {
|
||||
@@ -213,6 +229,10 @@ function parseSshFileTransferCliOptions(args: string[]): SshFileTransferCliOptio
|
||||
if (arg === "--chunk-bytes" || arg === "--block-bytes") {
|
||||
throw new Error(`unsupported ssh ${action} option: ${arg}; downloads use host.ssh.tcp-pool stdout streaming and no longer support the legacy base64 block reader`);
|
||||
}
|
||||
if (arg === "--allow-text-transfer") {
|
||||
allowTextTransfer = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--inactivity-timeout-ms" || arg === "--runtime-timeout-ms") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined) throw new Error(`ssh ${action} ${arg} requires a positive integer value`);
|
||||
@@ -240,8 +260,55 @@ function parseSshFileTransferCliOptions(args: string[]): SshFileTransferCliOptio
|
||||
const [first, second] = positionals;
|
||||
if (!first || !second) throw new Error(`ssh ${action} paths must be non-empty`);
|
||||
return action === "upload"
|
||||
? { action, localPath: first, remotePath: second, inactivityTimeoutMs }
|
||||
: { action, remotePath: first, localPath: second, inactivityTimeoutMs };
|
||||
? { action, localPath: first, remotePath: second, inactivityTimeoutMs, allowTextTransfer }
|
||||
: { action, remotePath: first, localPath: second, inactivityTimeoutMs, allowTextTransfer };
|
||||
}
|
||||
|
||||
function enforceTextTransferPolicy(
|
||||
invocation: ParsedSshInvocation,
|
||||
options: SshFileTransferCliOptions,
|
||||
resolvedLocalPath: string,
|
||||
): Record<string, unknown> {
|
||||
const knownTextPaths = [
|
||||
knownTextPath(options.remotePath),
|
||||
knownTextPath(resolvedLocalPath),
|
||||
].filter((item): item is { path: string; reason: string } => item !== null);
|
||||
if (knownTextPaths.length > 0 && !options.allowTextTransfer) {
|
||||
const readCommand = `trans ${invocation.route.raw} cat ${JSON.stringify(options.remotePath)}`;
|
||||
const editCommand = `trans ${invocation.route.raw} apply-patch <<'PATCH'`;
|
||||
throw new SshFileTransferError("remote text transfer is discouraged; use the native text operation", {
|
||||
code: "text-transfer-discouraged",
|
||||
action: options.action,
|
||||
route: invocation.route.raw,
|
||||
knownTextPaths,
|
||||
preferred: options.action === "download"
|
||||
? { operation: "cat", command: readCommand }
|
||||
: { operation: "apply-patch", command: editCommand },
|
||||
alternatives: options.action === "download"
|
||||
? [readCommand, `trans ${invocation.route.raw} head -n 80 ${JSON.stringify(options.remotePath)}`, `trans ${invocation.route.raw} rg <pattern> ${JSON.stringify(options.remotePath)}`]
|
||||
: [editCommand],
|
||||
override: {
|
||||
option: "--allow-text-transfer",
|
||||
purpose: "仅用于确需整体搬运的生成物;成功结果会记录 overrideRequested=true。",
|
||||
},
|
||||
targetCreated: false,
|
||||
remoteOperationStarted: false,
|
||||
});
|
||||
}
|
||||
return {
|
||||
code: "binary-or-generated-artifact-transfer",
|
||||
knownTextPaths,
|
||||
overrideRequested: options.allowTextTransfer,
|
||||
overrideUsed: knownTextPaths.length > 0 && options.allowTextTransfer,
|
||||
};
|
||||
}
|
||||
|
||||
function knownTextPath(rawPath: string): { path: string; reason: string } | null {
|
||||
const normalized = rawPath.trim().replace(/\\/gu, "/").replace(/\/+$/gu, "");
|
||||
const basename = normalized.slice(normalized.lastIndexOf("/") + 1).toLowerCase();
|
||||
if (knownTextBasenames.has(basename)) return { path: rawPath, reason: `basename:${basename}` };
|
||||
const extension = path.posix.extname(basename);
|
||||
return knownTextExtensions.has(extension) ? { path: rawPath, reason: `extension:${extension}` } : null;
|
||||
}
|
||||
|
||||
function parsePositiveRuntimeTimeoutMs(value: string, label: string): number {
|
||||
|
||||
@@ -76,6 +76,9 @@ describe("ssh bounded progressive help", () => {
|
||||
expect(scoped.contract.pathOrder).toEqual(["remote-file", "local-file"]);
|
||||
expect(scoped.contract.transport).toBe("host.ssh.tcp-pool");
|
||||
expect(scoped.contract.verification.automatic).toBe(true);
|
||||
expect(scoped.usage[0]).toBe("trans <route> cat <remote-text-file>");
|
||||
expect(serialized).toContain("text-transfer-discouraged");
|
||||
expect(serialized).toContain("--allow-text-transfer");
|
||||
expect(scoped.examples.host).toContain("download /tmp/trace.json ./trace.json");
|
||||
expect(scoped.examples.k3sWorkload).toBe(
|
||||
"trans NC01:k3s:hwlab-v03:hwlab-cloud-api:hwlab-cloud-api download /tmp/trace.json ./trace.json",
|
||||
@@ -100,7 +103,8 @@ describe("ssh bounded progressive help", () => {
|
||||
const applyPatch = runTransHelp("--help", "apply-patch");
|
||||
expect(applyPatch.trim().split("\n").length).toBeLessThanOrEqual(8);
|
||||
expect(applyPatch).toContain("TRANS HELP apply-patch");
|
||||
expect(applyPatch).toContain("usage: trans <route> apply-patch");
|
||||
expect(applyPatch).toContain("usage: trans <route> apply-patch <<'PATCH'");
|
||||
expect(applyPatch).not.toContain("patch.diff");
|
||||
expect(applyPatch).not.toContain("outputTruncated");
|
||||
|
||||
const exec = runTransHelp("--help", "exec");
|
||||
@@ -110,8 +114,14 @@ describe("ssh bounded progressive help", () => {
|
||||
const download = runTransHelp("--help", "download");
|
||||
expect(download.trim().split("\n").length).toBeLessThanOrEqual(8);
|
||||
expect(download).toContain("TRANS HELP download");
|
||||
expect(download.indexOf("cat <remote-text-file>")).toBeLessThan(download.indexOf("download <remote-file> <local-file>"));
|
||||
expect(download).toContain("download <remote-file> <local-file>");
|
||||
expect(download).not.toContain("outputTruncated");
|
||||
|
||||
const upload = runTransHelp("--help", "upload");
|
||||
expect(upload.trim().split("\n").length).toBeLessThanOrEqual(8);
|
||||
expect(upload.indexOf("apply-patch <<'PATCH'")).toBeLessThan(upload.indexOf("upload <local-file> <remote-file>"));
|
||||
expect(upload).toContain("text-transfer-discouraged");
|
||||
});
|
||||
|
||||
test("returns the existing bounded JSON model only for explicit machine output", () => {
|
||||
|
||||
@@ -234,6 +234,7 @@ export function sshStdoutTruncationHint(options: {
|
||||
}): SshStdoutTruncationHint {
|
||||
const stream = options.stream ?? "stdout";
|
||||
const policy = readCliOutputPolicy();
|
||||
const dumpCreated = options.dumpPath !== null;
|
||||
return {
|
||||
code: stream === "stdout" ? "ssh-stdout-truncated" : "ssh-stderr-truncated",
|
||||
level: "warning",
|
||||
@@ -248,18 +249,26 @@ export function sshStdoutTruncationHint(options: {
|
||||
dumpPath: options.dumpPath,
|
||||
dumpError: options.dumpError ?? null,
|
||||
disclosurePolicy: {
|
||||
name: "unified-cli-dump-preview",
|
||||
name: "bounded-stream-explicit-dump",
|
||||
configPath: policy.configPath,
|
||||
maxPreviewBytes: policy.maxStdoutBytes,
|
||||
dumpDir: policy.dumpDir,
|
||||
defaultCreatesDump: false,
|
||||
dumpCreated,
|
||||
},
|
||||
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.",
|
||||
...(dumpCreated
|
||||
? ["Read dumpPath only because explicit stream dump capture was requested."]
|
||||
: ["Default truncation creates no dump; set UNIDESK_TRAN_STREAM_DUMP=1 only for an explicit one-off complete capture."]),
|
||||
],
|
||||
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.",
|
||||
message: dumpCreated
|
||||
? `ssh ${stream} exceeded the YAML-configured preview budget; explicit capture wrote the complete stream to a protected local dump.`
|
||||
: `ssh ${stream} exceeded the YAML-configured preview budget; output is bounded and no temporary dump was created by default.`,
|
||||
action: dumpCreated
|
||||
? "Inspect dumpPath for this explicit one-off capture, then prefer a narrower remote command next time."
|
||||
: "Rerun with cat/head/tail/rg, --limit, --tail-bytes, or an id-specific query instead of recovering through a temporary dump.",
|
||||
note: "This hint is written to stderr and intentionally does not echo the original remote command.",
|
||||
};
|
||||
}
|
||||
@@ -294,8 +303,10 @@ export function sshTruncationCompletionSummary(options: {
|
||||
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.",
|
||||
message: "SSH output was truncated, but the command has finished; use exitCode/timedOut for closeout and rerun a semantic narrow query when more evidence is needed.",
|
||||
action: options.stdout?.dumpPath != null || options.stderr?.dumpPath != null
|
||||
? "An explicit stream dump was requested; inspect its dumpPath only when the complete one-off payload is required."
|
||||
: "No dump was created by default; use cat/head/tail/rg, --limit, --tail-bytes, or an id-specific query.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -378,6 +389,7 @@ export function createSshStdoutForwarder(options: {
|
||||
transport: SshStdoutTruncationHint["transport"];
|
||||
maxBytes?: number;
|
||||
stdout?: NodeJS.WritableStream;
|
||||
persistDump?: boolean;
|
||||
}): SshStreamForwarder {
|
||||
return createSshStreamForwarder({
|
||||
invocation: options.invocation,
|
||||
@@ -385,6 +397,7 @@ export function createSshStdoutForwarder(options: {
|
||||
stream: "stdout",
|
||||
maxBytes: options.maxBytes ?? sshStdoutStreamMaxBytes(),
|
||||
target: options.stdout ?? process.stdout,
|
||||
persistDump: options.persistDump ?? sshStreamDumpExplicitlyRequested(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -393,6 +406,7 @@ export function createSshStderrForwarder(options: {
|
||||
transport: SshStdoutTruncationHint["transport"];
|
||||
maxBytes?: number;
|
||||
stderr?: NodeJS.WritableStream;
|
||||
persistDump?: boolean;
|
||||
}): SshStreamForwarder {
|
||||
return createSshStreamForwarder({
|
||||
invocation: options.invocation,
|
||||
@@ -400,6 +414,7 @@ export function createSshStderrForwarder(options: {
|
||||
stream: "stderr",
|
||||
maxBytes: options.maxBytes ?? sshStderrStreamMaxBytes(),
|
||||
target: options.stderr ?? process.stderr,
|
||||
persistDump: options.persistDump ?? sshStreamDumpExplicitlyRequested(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -409,6 +424,7 @@ function createSshStreamForwarder(options: {
|
||||
stream: SshStdoutTruncationHint["stream"];
|
||||
maxBytes: number;
|
||||
target: NodeJS.WritableStream;
|
||||
persistDump: boolean;
|
||||
}): SshStreamForwarder {
|
||||
let observedBytes = 0;
|
||||
let forwardedBytes = 0;
|
||||
@@ -419,6 +435,7 @@ function createSshStreamForwarder(options: {
|
||||
const bufferedChunks: Buffer[] = [];
|
||||
|
||||
const appendDump = (chunk: Buffer): void => {
|
||||
if (!options.persistDump) return;
|
||||
if (dumpError !== null) return;
|
||||
try {
|
||||
if (dumpPath === null) {
|
||||
@@ -438,7 +455,7 @@ function createSshStreamForwarder(options: {
|
||||
write(chunk: Buffer): string | null {
|
||||
observedBytes += chunk.length;
|
||||
if (!truncated && observedBytes <= options.maxBytes) {
|
||||
bufferedChunks.push(Buffer.from(chunk));
|
||||
if (options.persistDump) bufferedChunks.push(Buffer.from(chunk));
|
||||
options.target.write(chunk);
|
||||
forwardedBytes += chunk.length;
|
||||
return null;
|
||||
@@ -483,6 +500,10 @@ function createSshStreamForwarder(options: {
|
||||
};
|
||||
}
|
||||
|
||||
export function sshStreamDumpExplicitlyRequested(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return env.UNIDESK_TRAN_STREAM_DUMP === "1";
|
||||
}
|
||||
|
||||
function brokerSource(): string {
|
||||
return String.raw`
|
||||
const open = JSON.parse(process.argv[2] || process.argv[1] || "{}");
|
||||
|
||||
+26
-5
@@ -15,6 +15,7 @@ import {
|
||||
sshTruncationCompletionSummary,
|
||||
sshCaptureBackendPlan,
|
||||
sshStderrStreamMaxBytes,
|
||||
sshStreamDumpExplicitlyRequested,
|
||||
sshStdoutStreamMaxBytes,
|
||||
sshStdoutTruncationHint,
|
||||
sshWindowsPlaneHint,
|
||||
@@ -408,13 +409,13 @@ describe("ssh stdout bounded streaming", () => {
|
||||
expect(payload.route).toBe("D601:win/c/test");
|
||||
expect(payload.thresholdBytes).toBe(4096);
|
||||
expect(payload.disclosurePolicy).toMatchObject({
|
||||
name: "unified-cli-dump-preview",
|
||||
name: "bounded-stream-explicit-dump",
|
||||
configPath: "config/unidesk-cli.yaml",
|
||||
});
|
||||
expect(formatted).not.toContain("Get-Content");
|
||||
});
|
||||
|
||||
test("forwarder bounds stdout and emits one truncation hint", () => {
|
||||
test("forwarder bounds stdout without creating a default dump", () => {
|
||||
const invocation = parseSshInvocation("D601:win/c/test", ["ps"]);
|
||||
const forwarded: Buffer[] = [];
|
||||
const stdout = {
|
||||
@@ -438,8 +439,9 @@ describe("ssh stdout bounded streaming", () => {
|
||||
expect(hint).toContain("UNIDESK_SSH_STDOUT_TRUNCATED");
|
||||
expect(hint).toContain("\"transport\":\"frontend-websocket\"");
|
||||
expect(hint).toContain("\"forwardedBytes\":5");
|
||||
const payload = JSON.parse(hint!.slice("UNIDESK_SSH_STDOUT_TRUNCATED ".length)) as { dumpPath: string };
|
||||
expect(readFileSync(payload.dumpPath, "utf8")).toBe("abcdefghijkl");
|
||||
const payload = JSON.parse(hint!.slice("UNIDESK_SSH_STDOUT_TRUNCATED ".length)) as { dumpPath: string | null; action: string };
|
||||
expect(payload.dumpPath).toBeNull();
|
||||
expect(payload.action).toContain("cat/head/tail/rg");
|
||||
const summary = sshTruncationCompletionSummary({
|
||||
invocation,
|
||||
transport: "frontend-websocket",
|
||||
@@ -453,7 +455,26 @@ describe("ssh stdout bounded streaming", () => {
|
||||
expect(formattedSummary).toContain("UNIDESK_SSH_TRUNCATION_SUMMARY");
|
||||
expect(formattedSummary).toContain("\"exitCode\":0");
|
||||
expect(formattedSummary).toContain("\"commandOmitted\":true");
|
||||
expect(formattedSummary).toContain("\"dumpPath\"");
|
||||
expect(formattedSummary).toContain("\"dumpPath\":null");
|
||||
});
|
||||
|
||||
test("keeps complete stream capture behind an explicit audited switch", () => {
|
||||
expect(sshStreamDumpExplicitlyRequested({} as NodeJS.ProcessEnv)).toBe(false);
|
||||
expect(sshStreamDumpExplicitlyRequested({ UNIDESK_TRAN_STREAM_DUMP: "1" } as NodeJS.ProcessEnv)).toBe(true);
|
||||
const invocation = parseSshInvocation("D601:win/c/test", ["ps"]);
|
||||
const stdout = { write(): boolean { return true; } } as NodeJS.WritableStream;
|
||||
const forwarder = createSshStdoutForwarder({
|
||||
invocation,
|
||||
transport: "frontend-websocket",
|
||||
maxBytes: 5,
|
||||
stdout,
|
||||
persistDump: true,
|
||||
});
|
||||
expect(forwarder.write(Buffer.from("abc"))).toBeNull();
|
||||
const hint = forwarder.write(Buffer.from("defgh"));
|
||||
forwarder.write(Buffer.from("ijkl"));
|
||||
const payload = JSON.parse(hint!.slice("UNIDESK_SSH_STDOUT_TRUNCATED ".length)) as { dumpPath: string };
|
||||
expect(readFileSync(payload.dumpPath, "utf8")).toBe("abcdefghijkl");
|
||||
rmSync(payload.dumpPath, { force: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -2117,6 +2117,7 @@ export {
|
||||
sshRuntimeTimingHint,
|
||||
sshSlowWarningThresholdMs,
|
||||
sshStderrStreamMaxBytes,
|
||||
sshStreamDumpExplicitlyRequested,
|
||||
sshStdoutStreamMaxBytes,
|
||||
sshStdoutTruncationHint,
|
||||
sshTcpPoolHint,
|
||||
|
||||
Reference in New Issue
Block a user