fix: 收敛 trans 远端文本短路径

This commit is contained in:
Codex
2026-07-13 05:20:27 +02:00
parent 18937da9db
commit c04983455e
16 changed files with 498 additions and 70 deletions
+7 -5
View File
@@ -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 必须满足短连接预算;长 buildCI/CDtraceprobe rollout 使用受控 job/status submit-and-poll
+1 -1
View File
@@ -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:
+9 -1
View File
@@ -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 展开。
+20
View File
@@ -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"]]);
+21 -6
View File
@@ -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
View File
@@ -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。",
"单进程优先 argvshell 逻辑显式使用 sh/bashWindows 使用 ps/cmd。",
"远端文本读取优先 cat/rg,修改优先 apply-patchupload/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/rgdownload 只整体搬运必要的二进制文件或生成物,并自动核对远端/本地字节数与 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",
+60
View File
@@ -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
View File
@@ -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,
};
+109
View File
@@ -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 });
}
});
});
+72 -5
View File
@@ -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 {
+11 -1
View File
@@ -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", () => {
+28 -7
View File
@@ -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
View File
@@ -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 });
});
+1
View File
@@ -2117,6 +2117,7 @@ export {
sshRuntimeTimingHint,
sshSlowWarningThresholdMs,
sshStderrStreamMaxBytes,
sshStreamDumpExplicitlyRequested,
sshStdoutStreamMaxBytes,
sshStdoutTruncationHint,
sshTcpPoolHint,