From c04983455e5becd4ca05ccb917a19c0c2aeb7135 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 05:20:27 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9B=20trans=20=E8=BF=9C?= =?UTF-8?q?=E7=AB=AF=E6=96=87=E6=9C=AC=E7=9F=AD=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/unidesk-trans/SKILL.md | 12 +- .../unidesk-trans/references/apply-patch.md | 32 ++++- .../unidesk-trans/references/operations.md | 37 ++++-- config/unidesk-cli.yaml | 2 +- docs/reference/cli.md | 10 +- scripts/src/apply-patch-v2.test.ts | 20 ++++ scripts/src/apply-patch-v2.ts | 27 ++++- scripts/src/help.ts | 25 ++-- scripts/src/output.test.ts | 60 ++++++++++ scripts/src/output.ts | 78 +++++++++++-- scripts/src/ssh-file-transfer.test.ts | 109 ++++++++++++++++++ scripts/src/ssh-file-transfer.ts | 77 ++++++++++++- scripts/src/ssh-help.test.ts | 12 +- scripts/src/ssh-runtime.ts | 35 ++++-- scripts/src/ssh.test.ts | 31 ++++- scripts/src/ssh.ts | 1 + 16 files changed, 498 insertions(+), 70 deletions(-) create mode 100644 scripts/src/output.test.ts create mode 100644 scripts/src/ssh-file-transfer.test.ts diff --git a/.agents/skills/unidesk-trans/SKILL.md b/.agents/skills/unidesk-trans/SKILL.md index 2e5b156b..eb8c4350 100644 --- a/.agents/skills/unidesk-trans/SKILL.md +++ b/.agents/skills/unidesk-trans/SKILL.md @@ -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 apply-patch`;不要 download/upload/sed 拼临时 diff 代替 patch。 + - 默认超限输出不得创建 dump;先返回语义化窄查询。 + - dump 只允许用于显式 `--full`、`--raw`、`UNIDESK_TRAN_STREAM_DUMP=1` 或确实无法预先界定的一次性异常证据。 +- 远端文本修改直接使用 `trans 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` 文件的原始字节风格。 diff --git a/.agents/skills/unidesk-trans/references/apply-patch.md b/.agents/skills/unidesk-trans/references/apply-patch.md index e30de9e2..7caaecb6 100644 --- a/.agents/skills/unidesk-trans/references/apply-patch.md +++ b/.agents/skills/unidesk-trans/references/apply-patch.md @@ -1,8 +1,28 @@ -# Remote Apply Patch +# 远端文本补丁 -Remote text edits should use `trans 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 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 编码写入块绕行。 diff --git a/.agents/skills/unidesk-trans/references/operations.md b/.agents/skills/unidesk-trans/references/operations.md index 0cb41b86..6d66823b 100644 --- a/.agents/skills/unidesk-trans/references/operations.md +++ b/.agents/skills/unidesk-trans/references/operations.md @@ -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 :win// 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 `:/mnt//...` describes only the WSL/host plane and is not evidence that Windows Python is absent. +- Windows workspace 工具链: + - Windows 安装或 PATH 中的普通命令使用 `trans :win// cmd ...`; + - PowerShell 逻辑使用 `ps`; + - `:/mnt//...` 上找不到 `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/` 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//` 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/` host route 做二进制流传输; + - 例如 `trans D518:win download 'D:\tmp\image.png' /tmp/image.png`; + - 相对路径使用 `win//` route workspace; + - UNC 路径仍需先复制到 drive-letter 路径。 -`trans --help download` 返回紧凑的 download 合同、可执行示例和当前 `runtime.repoRoot` / `runtime.cliPath`。PATH wrapper 不根据 cwd 选择源码根;验证独立 worktree 时显式设置 `UNIDESK_TRANS_REPO_ROOT=`,并先读取帮助中的实际 root,避免把主 worktree 的旧实现误认为当前 cwd 的实现。 +- `trans --help download`: + - 首屏先展示文本读取短路径,再展示二进制 download; + - 同时披露当前 `runtime.repoRoot` / `runtime.cliPath`; + - 验证独立 worktree 时显式设置 `UNIDESK_TRANS_REPO_ROOT=`。 -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。 diff --git a/config/unidesk-cli.yaml b/config/unidesk-cli.yaml index d8049f65..936e3217 100644 --- a/config/unidesk-cli.yaml +++ b/config/unidesk-cli.yaml @@ -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: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 93a27642..a680f5f6 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -8,13 +8,21 @@ UniDesk 的统一 CLI 实现入口是根目录 `scripts/cli.ts`,运行方式 - 顶层帮助只展示分组索引和可执行的下钻命令,详细合同通过 ` --help ` 或等价 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 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 展开。 diff --git a/scripts/src/apply-patch-v2.test.ts b/scripts/src/apply-patch-v2.test.ts index 68bc3cab..e9999517 100644 --- a/scripts/src/apply-patch-v2.test.ts +++ b/scripts/src/apply-patch-v2.test.ts @@ -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 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([["docs/test.md", "alpha\nold\nomega\n"]]); diff --git a/scripts/src/apply-patch-v2.ts b/scripts/src/apply-patch-v2.ts index 26b03a41..2f72c870 100644 --- a/scripts/src/apply-patch-v2.ts +++ b/scripts/src/apply-patch-v2.ts @@ -168,11 +168,11 @@ export function isApplyPatchV2HelpArgs(args: string[] = []): boolean { export function applyPatchV2HelpPayload() { return { ok: true, - command: "trans apply-patch < patch.diff", + command: "trans 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: ` 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 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 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 apply-patch <<'PATCH'", + envelope: [beginMarker, "*** Update File: ", "@@", "-old", "+new", endMarker, "PATCH"], + temporaryFileRequired: false, + })}`; +} + function createApplyPatchV2RemoteMetrics(): ApplyPatchV2RemoteMetrics { return { remoteOperationCount: 0, remoteOperationCounts: {}, remoteElapsedMs: 0, remoteFailureCount: 0 }; } diff --git a/scripts/src/help.ts b/scripts/src/help.ts index eab6a7c9..30e677be 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -31,8 +31,8 @@ export function rootHelp(): unknown { { command: "provider attach [--master-server URL] [--up] [--force] | provider triage [--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 [operation args...] (alias of ssh ...)", 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 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 upload | trans download ", description: "Transfer whole files through SSH passthrough with automatic endpoint byte/SHA-256 verification; downloads stream stdout over host.ssh.tcp-pool." }, + { command: "trans 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 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 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 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 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 = { "apply-patch": { group: "patch", description: "默认远端文本修改入口;本地 v2 engine 计算变更,route 仅负责读写。", - usage: ["trans apply-patch [--cwd /path] < patch.diff"], - notes: ["失败后重读当前目标块并缩小 hunk;不得自动回退 apply-patch-v1 或整文件 upload。"], + usage: ["trans 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 = { upload: { group: "transfer", description: "上传必要的二进制文件或生成物并自动核对字节数与 SHA-256。", - usage: ["trans upload "], - notes: ["远端文本读取使用 cat/rg,修改使用 apply-patch。"], + usage: ["trans apply-patch <<'PATCH'", "trans upload "], + notes: ["已知文本路径默认在传输前返回 text-transfer-discouraged;仅整体搬运生成物时显式加 --allow-text-transfer。"], }, download: { group: "transfer", description: "下载必要的二进制文件或生成物并自动核对字节数与 SHA-256。", - usage: ["trans download "], + usage: ["trans cat ", "trans download "], + 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 cat ", "trans download ", - "trans download --inactivity-timeout-ms ", + "trans download --allow-text-transfer ", "trans --help download", ], options: { "--inactivity-timeout-ms ": "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", diff --git a/scripts/src/output.test.ts b/scripts/src/output.test.ts new file mode 100644 index 00000000..d5236247 --- /dev/null +++ b/scripts/src/output.test.ts @@ -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 } }; + 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 } }; + 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([]); + }); +}); diff --git a/scripts/src/output.ts b/scripts/src/output.ts index 430f9911..4c217e03 100644 --- a/scripts/src/output.ts +++ b/scripts/src/output.ts @@ -147,11 +147,20 @@ function normalizeErrorPayload(command: string, error: unknown): Record | 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).code; + return { ...(typeof code === "string" ? { code } : {}), details }; } function parseStructuredErrorMessage(command: string, message: string): Record | null { @@ -242,16 +252,20 @@ function renderEnvelope(command: string, envelope: JsonEnvelope): 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> = 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> = { 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): string[] { + const source = typeof envelope.data === "object" && envelope.data !== null + ? envelope.data as Record + : typeof envelope.error === "object" && envelope.error !== null + ? envelope.error as Record + : {}; + 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 { mkdirSync(policy.dumpDir, { recursive: true, mode: 0o700 }); const timestamp = new Date().toISOString().replace(/[:.]/gu, "-"); @@ -395,12 +447,14 @@ function summarizeEnvelope(envelope: JsonEnvelope): Record { +function disclosurePolicy(policy: CliOutputPolicy, dumpCreated: boolean): Record { 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, }; diff --git a/scripts/src/ssh-file-transfer.test.ts b/scripts/src/ssh-file-transfer.test.ts new file mode 100644 index 00000000..101783c5 --- /dev/null +++ b/scripts/src/ssh-file-transfer.test.ts @@ -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 }; + 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 }; + 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 }); + } + }); +}); diff --git a/scripts/src/ssh-file-transfer.ts b/scripts/src/ssh-file-transfer.ts index b8e0d8a0..209868c8 100644 --- a/scripts/src/ssh-file-transfer.ts +++ b/scripts/src/ssh-file-transfer.ts @@ -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 { 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 { + 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 ${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 { diff --git a/scripts/src/ssh-help.test.ts b/scripts/src/ssh-help.test.ts index 22d5fbb4..ef19c85e 100644 --- a/scripts/src/ssh-help.test.ts +++ b/scripts/src/ssh-help.test.ts @@ -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 cat "); + 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 apply-patch"); + expect(applyPatch).toContain("usage: trans 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 ")).toBeLessThan(download.indexOf("download ")); expect(download).toContain("download "); 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 ")); + expect(upload).toContain("text-transfer-discouraged"); }); test("returns the existing bounded JSON model only for explicit machine output", () => { diff --git a/scripts/src/ssh-runtime.ts b/scripts/src/ssh-runtime.ts index 4c556d8b..1fc1be14 100644 --- a/scripts/src/ssh-runtime.ts +++ b/scripts/src/ssh-runtime.ts @@ -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] || "{}"); diff --git a/scripts/src/ssh.test.ts b/scripts/src/ssh.test.ts index a7226a4b..908d9e6d 100644 --- a/scripts/src/ssh.test.ts +++ b/scripts/src/ssh.test.ts @@ -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 }); }); diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 0738af7e..b3652488 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -2117,6 +2117,7 @@ export { sshRuntimeTimingHint, sshSlowWarningThresholdMs, sshStderrStreamMaxBytes, + sshStreamDumpExplicitlyRequested, sshStdoutStreamMaxBytes, sshStdoutTruncationHint, sshTcpPoolHint,