fix: 收敛 trans 远端文本短路径
This commit is contained in:
@@ -34,6 +34,26 @@ function applyReplacementPlan(original: string, plan: ApplyPatchV2BulkReplacemen
|
||||
return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
describe("apply-patch v2 stdin preflight", () => {
|
||||
test("fails immediately on an interactive TTY and points to a quoted heredoc", async () => {
|
||||
const stdin = Readable.from([]) as Readable & { isTTY?: boolean };
|
||||
Object.defineProperty(stdin, "isTTY", { value: true });
|
||||
const stdout = new CaptureWritable();
|
||||
const stderr = new CaptureWritable();
|
||||
const startedAtMs = Date.now();
|
||||
|
||||
const exitCode = await runApplyPatchV2({ executor: {}, stdin, stdout, stderr });
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(Date.now() - startedAtMs).toBeLessThan(1000);
|
||||
expect(stdout.text()).toBe("");
|
||||
expect(stderr.text()).toContain("UNIDESK_APPLY_PATCH_INPUT");
|
||||
expect(stderr.text()).toContain("\"code\":\"apply-patch-stdin-required\"");
|
||||
expect(stderr.text()).toContain("trans <route> apply-patch <<'PATCH'");
|
||||
expect(stderr.text()).toContain("\"temporaryFileRequired\":false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("apply-patch v2 fs bulk update path", () => {
|
||||
test("single fs update uses readFiles and applyReplacementsBulk instead of whole-file write", async () => {
|
||||
const files = new Map<string, string>([["docs/test.md", "alpha\nold\nomega\n"]]);
|
||||
|
||||
@@ -168,11 +168,11 @@ export function isApplyPatchV2HelpArgs(args: string[] = []): boolean {
|
||||
export function applyPatchV2HelpPayload() {
|
||||
return {
|
||||
ok: true,
|
||||
command: "trans <route> apply-patch < patch.diff",
|
||||
command: "trans <route> apply-patch <<'PATCH'",
|
||||
summary: "Apply a standard apply_patch patch to a remote route with the local v2 line-based engine.",
|
||||
usage: [
|
||||
"trans G14:/root/hwlab/.worktree/task apply-patch < patch.diff",
|
||||
"trans D601:/tmp apply-patch < patch.diff"
|
||||
"trans G14:/root/hwlab/.worktree/task apply-patch <<'PATCH'",
|
||||
"trans D601:/tmp apply-patch <<'PATCH'"
|
||||
],
|
||||
input: {
|
||||
required: true,
|
||||
@@ -201,7 +201,7 @@ export function applyPatchV2HelpPayload() {
|
||||
"Add File has no @@ hunk marker: put content immediately after `*** Add File: <path>` and prefix every content line with +.",
|
||||
"A blank line in Add File is a line containing only +.",
|
||||
"Update File uses @@ or @@ context markers, followed by context lines starting with one extra space prefix and changed lines starting with - or +; for a column-0 source line `const x`, write ` const x`, and for a two-space-indented source line write three spaces total. Unified-diff line-range headers are accepted with hints for MiniMax compatibility.",
|
||||
"Prefer `trans <route> apply-patch < /tmp/patch.diff` for long patches, Windows paths, or quoting-sensitive content.",
|
||||
"Send the patch directly through a quoted `<<'PATCH'` heredoc so paths, quotes, `$`, and backticks reach apply-patch unchanged; do not create a temporary patch file.",
|
||||
"If `failed to find expected lines` reports stale or oversized context for a block/function replacement, re-read the exact current block and retry with a smaller Update File hunk or split hunks around unique anchors.",
|
||||
"MiniMax compatibility: stray @@ or unprefixed content inside Add File, unprefixed Update File context lines, and extra hunk/body lines after Delete File, are accepted with stderr hints.",
|
||||
"MiniMax/MXCX concatenated patch compatibility: repeated nested Begin Patch / End Patch markers are accepted with stderr hints, but the CLI still uses only the v2 engine and never auto-falls back to apply-patch-v1."
|
||||
@@ -233,7 +233,7 @@ export function applyPatchV2HelpPayload() {
|
||||
"Do not recover from apply-patch context mismatch by switching to download/upload, remote Python/Perl/sed, or whole-file rewrites; retry with corrected v2 hunks.",
|
||||
"Do not use remote Python/Perl/sed heredocs for text patches when `trans <route> apply-patch` is available."
|
||||
],
|
||||
note: "apply-patch reads patch text from stdin and uses the v2 engine by default. Use `apply-patch-v1` only for the legacy helper."
|
||||
note: "apply-patch reads patch text directly from stdin and uses the v2 engine by default. Use `apply-patch-v1` only for the legacy helper."
|
||||
};
|
||||
}
|
||||
|
||||
@@ -373,9 +373,13 @@ export async function runApplyPatchV2(options: ApplyPatchV2Options): Promise<num
|
||||
stderr.write("ssh apply-patch uses the v2 engine and accepts no helper flags. Use apply-patch-v1 for legacy helper options.\n");
|
||||
return 2;
|
||||
}
|
||||
if ((options.stdin as Readable & { isTTY?: boolean }).isTTY === true) {
|
||||
stderr.write(`${applyPatchV2StdinHint("interactive-tty-without-stdin")}\n`);
|
||||
return 2;
|
||||
}
|
||||
const patchText = await readStreamText(options.stdin);
|
||||
if (!patchText.trim()) {
|
||||
stderr.write("ssh apply-patch requires patch text on stdin.\n");
|
||||
stderr.write(`${applyPatchV2StdinHint("empty-stdin")}\n`);
|
||||
return 2;
|
||||
}
|
||||
const startedAtMs = Date.now();
|
||||
@@ -416,6 +420,17 @@ export async function runApplyPatchV2(options: ApplyPatchV2Options): Promise<num
|
||||
}
|
||||
}
|
||||
|
||||
function applyPatchV2StdinHint(reason: "interactive-tty-without-stdin" | "empty-stdin"): string {
|
||||
return `UNIDESK_APPLY_PATCH_INPUT ${JSON.stringify({
|
||||
code: "apply-patch-stdin-required",
|
||||
reason,
|
||||
message: "apply-patch requires patch text from stdin and will not wait on an interactive TTY.",
|
||||
try: "trans <route> apply-patch <<'PATCH'",
|
||||
envelope: [beginMarker, "*** Update File: <path>", "@@", "-old", "+new", endMarker, "PATCH"],
|
||||
temporaryFileRequired: false,
|
||||
})}`;
|
||||
}
|
||||
|
||||
function createApplyPatchV2RemoteMetrics(): ApplyPatchV2RemoteMetrics {
|
||||
return { remoteOperationCount: 0, remoteOperationCounts: {}, remoteElapsedMs: 0, remoteFailureCount: 0 };
|
||||
}
|
||||
|
||||
+14
-11
@@ -31,8 +31,8 @@ export function rootHelp(): unknown {
|
||||
{ command: "provider attach <providerId> [--master-server URL] [--up] [--force] | provider triage <providerId> [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]", description: "Generate the minimal external provider-gateway env/compose bundle or run the low-noise read-only provider health triage contract." },
|
||||
{ command: "trans <route> [operation args...] (alias of ssh <route> ...)", description: "Open a Host SSH / WSL SSH maintenance session; provider WebSocket carries control and host.ssh.tcp-pool carries stdin/stdout/stderr data." },
|
||||
{ command: "trans gh:/owner/repo[/pr|/issue][/number[/1]] ls|cat|rg|apply-patch", description: "Treat GitHub PRs/issues as virtual text directories; `issue ls --search/--state` covers filtered reads, `cat|rg` reads first-floor body text, and `apply-patch` updates `body.md` through UniDesk gh plus apply-patch v2." },
|
||||
{ command: "trans <route> apply-patch < patch.diff", description: "Default remote text patch entry: apply a standard patch with the local TypeScript v2 engine while the remote route only reads and writes files." },
|
||||
{ command: "trans <route> upload <local-file> <remote-file> | trans <route> download <remote-file> <local-file>", description: "Transfer whole files through SSH passthrough with automatic endpoint byte/SHA-256 verification; downloads stream stdout over host.ssh.tcp-pool." },
|
||||
{ command: "trans <route> apply-patch <<'PATCH'", description: "Default remote text patch entry: send a quoted stdin heredoc directly to the local TypeScript v2 engine while the remote route only reads and writes files." },
|
||||
{ command: "trans <route> upload|download [--allow-text-transfer] ...", description: "Transfer binary files or generated artifacts through SSH passthrough with automatic endpoint byte/SHA-256 verification; known text paths fail before transfer unless explicitly overridden." },
|
||||
{ command: "trans <providerId> apply-patch-v1 [tool args...] < patch.diff", description: "Fallback to the injected legacy remote apply_patch helper directly over SSH passthrough and stream the patch from local stdin." },
|
||||
{ command: "trans <providerId> py [script-args...] < script.py", description: "Run remote Python from local stdin through SSH passthrough without nested shell quoting; extra args become script argv." },
|
||||
{ command: "trans <providerId> sh|bash [arg...] <<'SH|BASH' ...", description: "Run a remote POSIX sh or Bash body from local stdin; the operation name declares the shell dialect, and removed `script`/`shell` operations fail with a migration hint." },
|
||||
@@ -258,8 +258,8 @@ const SSH_OPERATION_HELP: Record<SshHelpScope, SshOperationHelp> = {
|
||||
"apply-patch": {
|
||||
group: "patch",
|
||||
description: "默认远端文本修改入口;本地 v2 engine 计算变更,route 仅负责读写。",
|
||||
usage: ["trans <route> apply-patch [--cwd /path] < patch.diff"],
|
||||
notes: ["失败后重读当前目标块并缩小 hunk;不得自动回退 apply-patch-v1 或整文件 upload。"],
|
||||
usage: ["trans <route> apply-patch <<'PATCH'"],
|
||||
notes: ["直接在 quoted heredoc 中放置 Begin/End Patch envelope;不要先建临时 patch 文件。", "失败后重读当前目标块并缩小 hunk;不得自动回退 apply-patch-v1 或整文件 upload。"],
|
||||
},
|
||||
"apply-patch-v1": {
|
||||
group: "patch",
|
||||
@@ -269,13 +269,14 @@ const SSH_OPERATION_HELP: Record<SshHelpScope, SshOperationHelp> = {
|
||||
upload: {
|
||||
group: "transfer",
|
||||
description: "上传必要的二进制文件或生成物并自动核对字节数与 SHA-256。",
|
||||
usage: ["trans <route> upload <local-file> <remote-file>"],
|
||||
notes: ["远端文本读取使用 cat/rg,修改使用 apply-patch。"],
|
||||
usage: ["trans <route> apply-patch <<'PATCH'", "trans <route> upload <local-file> <remote-file>"],
|
||||
notes: ["已知文本路径默认在传输前返回 text-transfer-discouraged;仅整体搬运生成物时显式加 --allow-text-transfer。"],
|
||||
},
|
||||
download: {
|
||||
group: "transfer",
|
||||
description: "下载必要的二进制文件或生成物并自动核对字节数与 SHA-256。",
|
||||
usage: ["trans <route> download <remote-file> <local-file>"],
|
||||
usage: ["trans <route> cat <remote-text-file>", "trans <route> download <remote-file> <local-file>"],
|
||||
notes: ["已知文本路径默认在写本地目标前返回 text-transfer-discouraged;仅整体搬运生成物时显式加 --allow-text-transfer。"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -329,7 +330,7 @@ export function sshHelp(scope: SshHelpScope | undefined = undefined): unknown {
|
||||
boundaries: [
|
||||
"route 只定位目标,route 后的 token 全部交给 operation parser。",
|
||||
"单进程优先 argv;shell 逻辑显式使用 sh/bash,Windows 使用 ps/cmd。",
|
||||
"远端文本读取优先 cat/rg,修改优先 apply-patch;upload/download 仅用于必要的二进制或生成物。",
|
||||
"远端文本读取优先 cat/rg,修改优先 apply-patch heredoc;已知文本 upload/download 默认在传输前失败。",
|
||||
"普通 trans 短连接上限保持 60s;长流程使用受控 submit-and-poll 入口。",
|
||||
],
|
||||
cwdSemantics: {
|
||||
@@ -391,7 +392,7 @@ function sshDownloadHelp(): unknown {
|
||||
command: `${entrypoint} --help download`,
|
||||
output: "json",
|
||||
scope: "download",
|
||||
description: "下载一个必要的二进制文件或生成物,并自动核对远端/本地字节数与 SHA-256;远端文本改用 cat/rg/apply-patch。",
|
||||
description: "远端文本先用 cat/head/tail/rg;download 只整体搬运必要的二进制文件或生成物,并自动核对远端/本地字节数与 SHA-256。",
|
||||
runtime: {
|
||||
entrypoint,
|
||||
repoRoot,
|
||||
@@ -405,15 +406,17 @@ function sshDownloadHelp(): unknown {
|
||||
},
|
||||
},
|
||||
usage: [
|
||||
"trans <route> cat <remote-text-file>",
|
||||
"trans <route> download <remote-file> <local-file>",
|
||||
"trans <route> download --inactivity-timeout-ms <milliseconds> <remote-file> <local-file>",
|
||||
"trans <route> download --allow-text-transfer <generated-text-artifact> <local-file>",
|
||||
"trans --help download",
|
||||
],
|
||||
options: {
|
||||
"--inactivity-timeout-ms <milliseconds>": "Allow a controlled progress-emitting download to stay open while data continues flowing; ordinary trans operations retain the short runtime budget.",
|
||||
"--allow-text-transfer": "显式确认该文本路径属于需要整体搬运的生成物;默认文本读取仍使用 cat/head/tail/rg。",
|
||||
},
|
||||
contract: {
|
||||
hint: "避免对远端文本反复 upload/download;读取优先使用 cat/rg,修改优先使用 apply-patch。",
|
||||
hint: "已知文本路径在传输前返回 text-transfer-discouraged;读取优先使用 cat/head/tail/rg,修改优先使用 apply-patch heredoc。",
|
||||
pathOrder: ["remote-file", "local-file"],
|
||||
transport: "host.ssh.tcp-pool",
|
||||
strategy: "tcp-pool-stdout-stream",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { repoRoot } from "./config";
|
||||
|
||||
const dumpDir = "/tmp/unidesk-cli-output";
|
||||
|
||||
function runOversizedIssueOutput(token: string, explicit = false): { status: number | null; stdout: string; stderr: string } {
|
||||
const command = `gh issue view 1890 --repo pikasTech/unidesk --json body ${token}`;
|
||||
const script = [
|
||||
"import { emitJson } from './scripts/src/output.ts';",
|
||||
`emitJson(${JSON.stringify(command)}, {`,
|
||||
" command: 'issue view',",
|
||||
" repo: 'pikasTech/unidesk',",
|
||||
" issue: { number: 1890, title: 'large body', state: 'open', url: 'https://example.invalid/1890', body: 'x'.repeat(30000) },",
|
||||
"});",
|
||||
].join("\n");
|
||||
const args = ["-e", script, "output-test", ...(explicit ? ["--full"] : [])];
|
||||
const result = spawnSync("bun", args, { cwd: repoRoot, encoding: "utf8" });
|
||||
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
|
||||
}
|
||||
|
||||
function matchingDumps(token: string): string[] {
|
||||
if (!existsSync(dumpDir)) return [];
|
||||
return readdirSync(dumpDir).filter((name) => name.includes(token));
|
||||
}
|
||||
|
||||
describe("CLI oversized output disclosure", () => {
|
||||
test("default oversized output creates no dump and gives the semantic gh text route", () => {
|
||||
const token = `nodump-${process.pid}-${Date.now()}`;
|
||||
expect(matchingDumps(token)).toEqual([]);
|
||||
|
||||
const result = runOversizedIssueOutput(token);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
const payload = JSON.parse(result.stdout) as { data: { outputTruncated: boolean; dump?: unknown; next: string[]; disclosurePolicy: Record<string, unknown> } };
|
||||
expect(payload.data.outputTruncated).toBe(true);
|
||||
expect(payload.data.dump).toBeUndefined();
|
||||
expect(payload.data.disclosurePolicy).toMatchObject({ defaultCreatesDump: false, dumpCreated: false });
|
||||
expect(payload.data.next[0]).toBe("trans gh:/pikasTech/unidesk/issue/1890 cat");
|
||||
expect(matchingDumps(token)).toEqual([]);
|
||||
});
|
||||
|
||||
test("explicit full disclosure keeps a protected complete dump", () => {
|
||||
const token = `fulldump-${process.pid}-${Date.now()}`;
|
||||
expect(matchingDumps(token)).toEqual([]);
|
||||
|
||||
const result = runOversizedIssueOutput(token, true);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
const payload = JSON.parse(result.stdout) as { data: { dump: { path: string }; disclosurePolicy: Record<string, unknown> } };
|
||||
expect(payload.data.disclosurePolicy).toMatchObject({ defaultCreatesDump: false, dumpCreated: true });
|
||||
expect(existsSync(payload.data.dump.path)).toBe(true);
|
||||
expect(readFileSync(payload.data.dump.path, "utf8")).toContain(token);
|
||||
rmSync(payload.data.dump.path, { force: true });
|
||||
expect(matchingDumps(token)).toEqual([]);
|
||||
});
|
||||
});
|
||||
+66
-12
@@ -147,11 +147,20 @@ function normalizeErrorPayload(command: string, error: unknown): Record<string,
|
||||
if (error instanceof Error) {
|
||||
const structured = structuredCliErrorPayload(error, message);
|
||||
if (structured !== null) return structured;
|
||||
const fileTransfer = sshFileTransferErrorDetails(error);
|
||||
if (fileTransfer?.code === "text-transfer-discouraged") {
|
||||
return {
|
||||
name: error.name,
|
||||
message,
|
||||
...fileTransfer,
|
||||
debug: false,
|
||||
};
|
||||
}
|
||||
const debug = cliDebugEnabled();
|
||||
return {
|
||||
name: error.name,
|
||||
message,
|
||||
...(sshFileTransferErrorDetails(error) ?? {}),
|
||||
...(fileTransfer ?? {}),
|
||||
stack: error.stack ?? null,
|
||||
...(debug ? { debug: true } : {}),
|
||||
};
|
||||
@@ -208,7 +217,8 @@ function sshFileTransferErrorDetails(error: Error): Record<string, unknown> | nu
|
||||
if (error.name !== "SshFileTransferError") return null;
|
||||
const details = (error as Error & { details?: unknown }).details;
|
||||
if (typeof details !== "object" || details === null || Array.isArray(details)) return null;
|
||||
return { details };
|
||||
const code = (details as Record<string, unknown>).code;
|
||||
return { ...(typeof code === "string" ? { code } : {}), details };
|
||||
}
|
||||
|
||||
function parseStructuredErrorMessage(command: string, message: string): Record<string, unknown> | null {
|
||||
@@ -242,16 +252,20 @@ function renderEnvelope<T>(command: string, envelope: JsonEnvelope<T>): string {
|
||||
const trigger = outputDumpTrigger(fullText, policy, "json");
|
||||
if (trigger === null) return fullText;
|
||||
|
||||
const dump = dumpLargeOutput(command, fullText, "json", policy);
|
||||
const persistDump = explicitOutputDumpRequested();
|
||||
const dump = persistDump ? dumpLargeOutput(command, fullText, "json", policy) : null;
|
||||
const compactPayload = {
|
||||
outputTruncated: true,
|
||||
reason: trigger.reason,
|
||||
warning: policy.warning,
|
||||
message: "Full JSON output was written to a temporary file; stdout contains only file metadata and a compact summary.",
|
||||
disclosurePolicy: disclosurePolicy(policy),
|
||||
message: persistDump
|
||||
? "Explicit full/raw output exceeded the stdout budget and was written to a protected temporary dump."
|
||||
: "Default output exceeded the stdout budget; no temporary dump was created. Use the narrow query below, or rerun with explicit --full/--raw only when complete disclosure is required.",
|
||||
disclosurePolicy: disclosurePolicy(policy, persistDump),
|
||||
trigger,
|
||||
dump,
|
||||
...(dump === null ? {} : { dump }),
|
||||
summary: summarizeEnvelope(envelope),
|
||||
next: oversizedOutputNext(envelope),
|
||||
};
|
||||
const compactEnvelope: JsonEnvelope<Record<string, unknown>> = envelope.ok
|
||||
? { ok: envelope.ok, command: envelope.command, data: compactPayload }
|
||||
@@ -277,7 +291,8 @@ function renderTextOutput(command: string, text: string): string {
|
||||
const policy = cliOutputPolicy();
|
||||
const trigger = outputDumpTrigger(fullText, policy, "text");
|
||||
if (trigger === null) return fullText;
|
||||
const dump = dumpLargeOutput(command, fullText, "txt", policy);
|
||||
const persistDump = explicitOutputDumpRequested();
|
||||
const dump = persistDump ? dumpLargeOutput(command, fullText, "txt", policy) : null;
|
||||
const payload: JsonEnvelope<Record<string, unknown>> = {
|
||||
ok: true,
|
||||
command,
|
||||
@@ -285,15 +300,52 @@ function renderTextOutput(command: string, text: string): string {
|
||||
outputTruncated: true,
|
||||
reason: trigger.reason,
|
||||
warning: policy.warning,
|
||||
message: "Full text output was written to a temporary file; stdout contains only file metadata.",
|
||||
disclosurePolicy: disclosurePolicy(policy),
|
||||
message: persistDump
|
||||
? "Explicit full/raw text exceeded the stdout budget and was written to a protected temporary dump."
|
||||
: "Default text exceeded the stdout budget; no temporary dump was created. Rerun a narrower query, or request explicit --full/--raw disclosure.",
|
||||
disclosurePolicy: disclosurePolicy(policy, persistDump),
|
||||
trigger,
|
||||
dump,
|
||||
...(dump === null ? {} : { dump }),
|
||||
next: [
|
||||
"Use rg --max-count, head, tail, --limit, --tail-bytes, or an id-specific query.",
|
||||
"Use --full or --raw only when the complete one-off payload is intentionally required.",
|
||||
],
|
||||
},
|
||||
};
|
||||
return `${JSON.stringify(payload, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function explicitOutputDumpRequested(argv = process.argv.slice(2)): boolean {
|
||||
return argv.some((arg) => arg === "--full" || arg === "--raw");
|
||||
}
|
||||
|
||||
function oversizedOutputNext(envelope: JsonEnvelope<unknown>): string[] {
|
||||
const source = typeof envelope.data === "object" && envelope.data !== null
|
||||
? envelope.data as Record<string, unknown>
|
||||
: typeof envelope.error === "object" && envelope.error !== null
|
||||
? envelope.error as Record<string, unknown>
|
||||
: {};
|
||||
const repo = typeof source.repo === "string" ? source.repo : null;
|
||||
const issue = recordOrNull(source.issue);
|
||||
const pullRequest = recordOrNull(source.pullRequest);
|
||||
if (repo !== null && typeof issue?.number === "number") {
|
||||
return [
|
||||
`trans gh:/${repo}/issue/${issue.number} cat`,
|
||||
`bun scripts/cli.ts gh issue comments ${issue.number} --repo ${repo} --limit 8`,
|
||||
];
|
||||
}
|
||||
if (repo !== null && typeof pullRequest?.number === "number") {
|
||||
return [
|
||||
`trans gh:/${repo}/pr/${pullRequest.number} cat`,
|
||||
`bun scripts/cli.ts gh pr review-plan ${pullRequest.number} --repo ${repo}`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
"Use --limit, --tail-bytes, an id-specific view, or another semantic narrow query.",
|
||||
"Use --full or --raw only when the complete one-off payload is intentionally required.",
|
||||
];
|
||||
}
|
||||
|
||||
function dumpLargeOutput(command: string, text: string, extension: "json" | "txt", policy: CliOutputPolicy): Record<string, unknown> {
|
||||
mkdirSync(policy.dumpDir, { recursive: true, mode: 0o700 });
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/gu, "-");
|
||||
@@ -395,12 +447,14 @@ function summarizeEnvelope(envelope: JsonEnvelope<unknown>): Record<string, unkn
|
||||
return summary;
|
||||
}
|
||||
|
||||
function disclosurePolicy(policy: CliOutputPolicy): Record<string, unknown> {
|
||||
function disclosurePolicy(policy: CliOutputPolicy, dumpCreated: boolean): Record<string, unknown> {
|
||||
return {
|
||||
configPath: policy.configPath,
|
||||
maxStdoutBytes: policy.maxStdoutBytes,
|
||||
dumpDir: policy.dumpDir,
|
||||
includePreview: policy.includePreview,
|
||||
defaultCreatesDump: false,
|
||||
dumpCreated,
|
||||
recommendation: "Prefer k8s-style concise summaries/tables by default; expose full data through explicit --full/--raw/id-specific drill-down commands instead of large stdout.",
|
||||
...(policy.configError === undefined ? {} : { configWarning: policy.configError }),
|
||||
};
|
||||
@@ -657,7 +711,7 @@ function cliOutputPolicy(): CliOutputPolicy {
|
||||
maxStdoutBytes: EMERGENCY_OUTPUT_DUMP_THRESHOLD_BYTES,
|
||||
dumpDir: EMERGENCY_OUTPUT_DUMP_DIR,
|
||||
includePreview: false,
|
||||
warning: "CLI output policy YAML could not be loaded; emergency dump guard is active for one-off drill-down only. Fix config/unidesk-cli.yaml, then improve the noisy command itself to print concise tables/summaries and id-specific progressive disclosure instead of repeatedly depending on dump extraction.",
|
||||
warning: "CLI output policy YAML could not be loaded; emergency bounded-output guard is active and creates no default dump. Fix config/unidesk-cli.yaml, then improve the noisy command itself to print concise summaries and id-specific queries; use explicit --full/--raw only for one-off complete disclosure.",
|
||||
configPath: CLI_OUTPUT_CONFIG_RELATIVE_PATH,
|
||||
configError: message,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { runSshFileTransferOperation, type SshFileTransferCommandBuilders, type SshRemoteCommandExecutor } from "./ssh-file-transfer";
|
||||
import { parseSshInvocation } from "./ssh";
|
||||
|
||||
function fakeTransfer(content: Buffer): { executor: SshRemoteCommandExecutor; builders: SshFileTransferCommandBuilders; calls: string[] } {
|
||||
const calls: string[] = [];
|
||||
const sha256 = createHash("sha256").update(content).digest("hex");
|
||||
return {
|
||||
calls,
|
||||
builders: {
|
||||
buildRouteCommand(_route, command) {
|
||||
return JSON.stringify(command);
|
||||
},
|
||||
buildWindowsPowerShellCommand(script) {
|
||||
return script;
|
||||
},
|
||||
},
|
||||
executor: {
|
||||
async runRemoteCommand(remoteCommand) {
|
||||
const command = JSON.parse(remoteCommand) as string[];
|
||||
const marker = command.indexOf("unidesk-file-transfer");
|
||||
const operation = command[marker + 1] ?? "unknown";
|
||||
calls.push(operation);
|
||||
return operation === "stat"
|
||||
? { exitCode: 0, stdout: `${content.length} ${sha256}\n`, stderr: "" }
|
||||
: { exitCode: 0, stdout: "", stderr: "" };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ssh text transfer preflight", () => {
|
||||
test("rejects a known text download before remote work or local target creation", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "unidesk-text-transfer-"));
|
||||
const target = join(dir, "result.md");
|
||||
const fake = fakeTransfer(Buffer.from("text\n"));
|
||||
const invocation = parseSshInvocation("D601:/tmp", ["download", "/tmp/result.md", target]);
|
||||
try {
|
||||
await runSshFileTransferOperation(invocation, ["download", "/tmp/result.md", target], fake.executor, fake.builders);
|
||||
throw new Error("expected text download preflight to fail");
|
||||
} catch (error) {
|
||||
const typed = error as Error & { details?: Record<string, unknown> };
|
||||
expect(typed.name).toBe("SshFileTransferError");
|
||||
expect(typed.details?.code).toBe("text-transfer-discouraged");
|
||||
expect(typed.details?.targetCreated).toBe(false);
|
||||
expect(typed.details?.remoteOperationStarted).toBe(false);
|
||||
expect(JSON.stringify(typed.details)).toContain("trans D601:/tmp cat");
|
||||
expect(fake.calls).toEqual([]);
|
||||
expect(existsSync(target)).toBe(false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a known text upload before reading the local source or starting remote work", async () => {
|
||||
const fake = fakeTransfer(Buffer.from("text\n"));
|
||||
const missingSource = "/tmp/unidesk-text-transfer-source-does-not-exist.md";
|
||||
const invocation = parseSshInvocation("D601:/tmp", ["upload", missingSource, "/tmp/result.md"]);
|
||||
try {
|
||||
await runSshFileTransferOperation(invocation, ["upload", missingSource, "/tmp/result.md"], fake.executor, fake.builders);
|
||||
throw new Error("expected text upload preflight to fail");
|
||||
} catch (error) {
|
||||
const typed = error as Error & { details?: Record<string, unknown> };
|
||||
expect(typed.name).toBe("SshFileTransferError");
|
||||
expect(typed.details?.code).toBe("text-transfer-discouraged");
|
||||
expect(JSON.stringify(typed.details)).toContain("apply-patch <<'PATCH'");
|
||||
expect(fake.calls).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps verified binary upload as the default transfer workflow", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "unidesk-binary-transfer-"));
|
||||
const source = join(dir, "firmware.bin");
|
||||
const content = Buffer.from([0, 1, 2, 3, 255]);
|
||||
writeFileSync(source, content);
|
||||
const fake = fakeTransfer(content);
|
||||
const invocation = parseSshInvocation("D601:/tmp", ["upload", source, "/tmp/firmware.bin"]);
|
||||
try {
|
||||
expect(await runSshFileTransferOperation(invocation, ["upload", source, "/tmp/firmware.bin"], fake.executor, fake.builders)).toBe(0);
|
||||
expect(fake.calls).toEqual(["write-b64-argv", "stat"]);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("allows an explicit generated-text override without bypassing verification", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "unidesk-generated-transfer-"));
|
||||
const source = join(dir, "result.json");
|
||||
const content = Buffer.from("{\"ok\":true}\n", "utf8");
|
||||
writeFileSync(source, content);
|
||||
const fake = fakeTransfer(content);
|
||||
const invocation = parseSshInvocation("D601:/tmp", ["upload", "--allow-text-transfer", source, "/tmp/result.json"]);
|
||||
try {
|
||||
expect(await runSshFileTransferOperation(
|
||||
invocation,
|
||||
["upload", "--allow-text-transfer", source, "/tmp/result.json"],
|
||||
fake.executor,
|
||||
fake.builders,
|
||||
)).toBe(0);
|
||||
expect(fake.calls).toEqual(["write-b64-argv", "stat"]);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ interface SshFileTransferCliOptions {
|
||||
localPath: string;
|
||||
remotePath: string;
|
||||
inactivityTimeoutMs?: number;
|
||||
allowTextTransfer: boolean;
|
||||
}
|
||||
|
||||
interface SshFileTransferStat {
|
||||
@@ -99,12 +100,23 @@ const fileTransferWriteRawChunkBytes = 131_072;
|
||||
const fileTransferWriteB64ChunkChars = 1_398_104;
|
||||
const fileTransferProgressEveryChunks = 16;
|
||||
|
||||
const knownTextExtensions = new Set([
|
||||
".bat", ".bib", ".c", ".cc", ".cfg", ".cmd", ".conf", ".cpp", ".css", ".csv", ".cts", ".cxx",
|
||||
".diff", ".env", ".go", ".h", ".hpp", ".htm", ".html", ".ini", ".java", ".js", ".json", ".jsonl",
|
||||
".jsx", ".less", ".log", ".md", ".markdown", ".mts", ".mjs", ".patch", ".ps1", ".py", ".rs", ".scss",
|
||||
".sh", ".sql", ".svg", ".tex", ".text", ".toml", ".ts", ".tsv", ".tsx", ".txt", ".xml", ".yaml", ".yml",
|
||||
]);
|
||||
|
||||
const knownTextBasenames = new Set([
|
||||
".editorconfig", ".gitattributes", ".gitignore", "agents.md", "dockerfile", "license", "makefile", "mdtodo", "readme",
|
||||
]);
|
||||
|
||||
const fileTransferUsageHint = {
|
||||
code: "prefer-remote-text-operations",
|
||||
message: "避免对远端文本反复 upload/download;读取优先使用 cat/rg,修改优先使用 apply-patch。upload/download 仅用于必要的二进制文件或生成物传输。",
|
||||
message: "已知文本路径会在传输前被拦截;读取使用 cat/head/tail/rg,修改直接使用 apply-patch quoted heredoc。upload/download 仅用于必要的二进制文件或生成物。",
|
||||
preferredOperations: {
|
||||
read: ["cat", "rg"],
|
||||
edit: ["apply-patch"],
|
||||
read: ["cat", "head", "tail", "rg"],
|
||||
edit: ["apply-patch <<'PATCH'"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -121,6 +133,7 @@ export async function runSshFileTransferOperation(
|
||||
): Promise<number> {
|
||||
const options = parseSshFileTransferCliOptions(args);
|
||||
const localPath = path.resolve(options.localPath);
|
||||
const transferPolicy = enforceTextTransferPolicy(invocation, options, localPath);
|
||||
if (options.action === "upload") {
|
||||
const content = await readFile(localPath);
|
||||
const expected = { bytes: content.length, sha256: sha256HexBuffer(content) };
|
||||
@@ -141,6 +154,7 @@ export async function runSshFileTransferOperation(
|
||||
bytes: expected.bytes,
|
||||
sha256: expected.sha256,
|
||||
verified: true,
|
||||
transferPolicy,
|
||||
hint: fileTransferUsageHint,
|
||||
verification,
|
||||
transfer: write,
|
||||
@@ -159,6 +173,7 @@ export async function runSshFileTransferOperation(
|
||||
bytes: download.bytes,
|
||||
sha256: download.sha256,
|
||||
verified: true,
|
||||
transferPolicy,
|
||||
hint: fileTransferUsageHint,
|
||||
verification: download.verification,
|
||||
transfer: download.transfer,
|
||||
@@ -204,6 +219,7 @@ function parseSshFileTransferCliOptions(args: string[]): SshFileTransferCliOptio
|
||||
if (action !== "upload" && action !== "download") throw new Error("ssh file transfer requires upload or download");
|
||||
const positionals: string[] = [];
|
||||
let inactivityTimeoutMs: number | undefined;
|
||||
let allowTextTransfer = false;
|
||||
for (let index = 1; index < args.length; index += 1) {
|
||||
const arg = args[index] ?? "";
|
||||
if (arg === "--") {
|
||||
@@ -213,6 +229,10 @@ function parseSshFileTransferCliOptions(args: string[]): SshFileTransferCliOptio
|
||||
if (arg === "--chunk-bytes" || arg === "--block-bytes") {
|
||||
throw new Error(`unsupported ssh ${action} option: ${arg}; downloads use host.ssh.tcp-pool stdout streaming and no longer support the legacy base64 block reader`);
|
||||
}
|
||||
if (arg === "--allow-text-transfer") {
|
||||
allowTextTransfer = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--inactivity-timeout-ms" || arg === "--runtime-timeout-ms") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined) throw new Error(`ssh ${action} ${arg} requires a positive integer value`);
|
||||
@@ -240,8 +260,55 @@ function parseSshFileTransferCliOptions(args: string[]): SshFileTransferCliOptio
|
||||
const [first, second] = positionals;
|
||||
if (!first || !second) throw new Error(`ssh ${action} paths must be non-empty`);
|
||||
return action === "upload"
|
||||
? { action, localPath: first, remotePath: second, inactivityTimeoutMs }
|
||||
: { action, remotePath: first, localPath: second, inactivityTimeoutMs };
|
||||
? { action, localPath: first, remotePath: second, inactivityTimeoutMs, allowTextTransfer }
|
||||
: { action, remotePath: first, localPath: second, inactivityTimeoutMs, allowTextTransfer };
|
||||
}
|
||||
|
||||
function enforceTextTransferPolicy(
|
||||
invocation: ParsedSshInvocation,
|
||||
options: SshFileTransferCliOptions,
|
||||
resolvedLocalPath: string,
|
||||
): Record<string, unknown> {
|
||||
const knownTextPaths = [
|
||||
knownTextPath(options.remotePath),
|
||||
knownTextPath(resolvedLocalPath),
|
||||
].filter((item): item is { path: string; reason: string } => item !== null);
|
||||
if (knownTextPaths.length > 0 && !options.allowTextTransfer) {
|
||||
const readCommand = `trans ${invocation.route.raw} cat ${JSON.stringify(options.remotePath)}`;
|
||||
const editCommand = `trans ${invocation.route.raw} apply-patch <<'PATCH'`;
|
||||
throw new SshFileTransferError("remote text transfer is discouraged; use the native text operation", {
|
||||
code: "text-transfer-discouraged",
|
||||
action: options.action,
|
||||
route: invocation.route.raw,
|
||||
knownTextPaths,
|
||||
preferred: options.action === "download"
|
||||
? { operation: "cat", command: readCommand }
|
||||
: { operation: "apply-patch", command: editCommand },
|
||||
alternatives: options.action === "download"
|
||||
? [readCommand, `trans ${invocation.route.raw} head -n 80 ${JSON.stringify(options.remotePath)}`, `trans ${invocation.route.raw} rg <pattern> ${JSON.stringify(options.remotePath)}`]
|
||||
: [editCommand],
|
||||
override: {
|
||||
option: "--allow-text-transfer",
|
||||
purpose: "仅用于确需整体搬运的生成物;成功结果会记录 overrideRequested=true。",
|
||||
},
|
||||
targetCreated: false,
|
||||
remoteOperationStarted: false,
|
||||
});
|
||||
}
|
||||
return {
|
||||
code: "binary-or-generated-artifact-transfer",
|
||||
knownTextPaths,
|
||||
overrideRequested: options.allowTextTransfer,
|
||||
overrideUsed: knownTextPaths.length > 0 && options.allowTextTransfer,
|
||||
};
|
||||
}
|
||||
|
||||
function knownTextPath(rawPath: string): { path: string; reason: string } | null {
|
||||
const normalized = rawPath.trim().replace(/\\/gu, "/").replace(/\/+$/gu, "");
|
||||
const basename = normalized.slice(normalized.lastIndexOf("/") + 1).toLowerCase();
|
||||
if (knownTextBasenames.has(basename)) return { path: rawPath, reason: `basename:${basename}` };
|
||||
const extension = path.posix.extname(basename);
|
||||
return knownTextExtensions.has(extension) ? { path: rawPath, reason: `extension:${extension}` } : null;
|
||||
}
|
||||
|
||||
function parsePositiveRuntimeTimeoutMs(value: string, label: string): number {
|
||||
|
||||
@@ -76,6 +76,9 @@ describe("ssh bounded progressive help", () => {
|
||||
expect(scoped.contract.pathOrder).toEqual(["remote-file", "local-file"]);
|
||||
expect(scoped.contract.transport).toBe("host.ssh.tcp-pool");
|
||||
expect(scoped.contract.verification.automatic).toBe(true);
|
||||
expect(scoped.usage[0]).toBe("trans <route> cat <remote-text-file>");
|
||||
expect(serialized).toContain("text-transfer-discouraged");
|
||||
expect(serialized).toContain("--allow-text-transfer");
|
||||
expect(scoped.examples.host).toContain("download /tmp/trace.json ./trace.json");
|
||||
expect(scoped.examples.k3sWorkload).toBe(
|
||||
"trans NC01:k3s:hwlab-v03:hwlab-cloud-api:hwlab-cloud-api download /tmp/trace.json ./trace.json",
|
||||
@@ -100,7 +103,8 @@ describe("ssh bounded progressive help", () => {
|
||||
const applyPatch = runTransHelp("--help", "apply-patch");
|
||||
expect(applyPatch.trim().split("\n").length).toBeLessThanOrEqual(8);
|
||||
expect(applyPatch).toContain("TRANS HELP apply-patch");
|
||||
expect(applyPatch).toContain("usage: trans <route> apply-patch");
|
||||
expect(applyPatch).toContain("usage: trans <route> apply-patch <<'PATCH'");
|
||||
expect(applyPatch).not.toContain("patch.diff");
|
||||
expect(applyPatch).not.toContain("outputTruncated");
|
||||
|
||||
const exec = runTransHelp("--help", "exec");
|
||||
@@ -110,8 +114,14 @@ describe("ssh bounded progressive help", () => {
|
||||
const download = runTransHelp("--help", "download");
|
||||
expect(download.trim().split("\n").length).toBeLessThanOrEqual(8);
|
||||
expect(download).toContain("TRANS HELP download");
|
||||
expect(download.indexOf("cat <remote-text-file>")).toBeLessThan(download.indexOf("download <remote-file> <local-file>"));
|
||||
expect(download).toContain("download <remote-file> <local-file>");
|
||||
expect(download).not.toContain("outputTruncated");
|
||||
|
||||
const upload = runTransHelp("--help", "upload");
|
||||
expect(upload.trim().split("\n").length).toBeLessThanOrEqual(8);
|
||||
expect(upload.indexOf("apply-patch <<'PATCH'")).toBeLessThan(upload.indexOf("upload <local-file> <remote-file>"));
|
||||
expect(upload).toContain("text-transfer-discouraged");
|
||||
});
|
||||
|
||||
test("returns the existing bounded JSON model only for explicit machine output", () => {
|
||||
|
||||
@@ -234,6 +234,7 @@ export function sshStdoutTruncationHint(options: {
|
||||
}): SshStdoutTruncationHint {
|
||||
const stream = options.stream ?? "stdout";
|
||||
const policy = readCliOutputPolicy();
|
||||
const dumpCreated = options.dumpPath !== null;
|
||||
return {
|
||||
code: stream === "stdout" ? "ssh-stdout-truncated" : "ssh-stderr-truncated",
|
||||
level: "warning",
|
||||
@@ -248,18 +249,26 @@ export function sshStdoutTruncationHint(options: {
|
||||
dumpPath: options.dumpPath,
|
||||
dumpError: options.dumpError ?? null,
|
||||
disclosurePolicy: {
|
||||
name: "unified-cli-dump-preview",
|
||||
name: "bounded-stream-explicit-dump",
|
||||
configPath: policy.configPath,
|
||||
maxPreviewBytes: policy.maxStdoutBytes,
|
||||
dumpDir: policy.dumpDir,
|
||||
defaultCreatesDump: false,
|
||||
dumpCreated,
|
||||
},
|
||||
recommendedRerun: [
|
||||
"Use rg -m/--max-count, sed -n, head, tail, --limit, --tail-bytes, or an id-specific query.",
|
||||
"Use --full, --raw, --tail-bytes, --limit, or UNIDESK_*_STREAM_MAX_BYTES only when you intentionally need a wider one-off disclosure.",
|
||||
"Read the dumpPath for the complete captured stream.",
|
||||
...(dumpCreated
|
||||
? ["Read dumpPath only because explicit stream dump capture was requested."]
|
||||
: ["Default truncation creates no dump; set UNIDESK_TRAN_STREAM_DUMP=1 only for an explicit one-off complete capture."]),
|
||||
],
|
||||
message: `ssh ${stream} exceeded the YAML-configured preview budget; ${stream} is bounded and the complete stream is written to a local dump when possible.`,
|
||||
action: "Inspect dumpPath for the complete stream, or rerun a narrower remote command instead of emitting full logs, huge JSON, or broad search output.",
|
||||
message: dumpCreated
|
||||
? `ssh ${stream} exceeded the YAML-configured preview budget; explicit capture wrote the complete stream to a protected local dump.`
|
||||
: `ssh ${stream} exceeded the YAML-configured preview budget; output is bounded and no temporary dump was created by default.`,
|
||||
action: dumpCreated
|
||||
? "Inspect dumpPath for this explicit one-off capture, then prefer a narrower remote command next time."
|
||||
: "Rerun with cat/head/tail/rg, --limit, --tail-bytes, or an id-specific query instead of recovering through a temporary dump.",
|
||||
note: "This hint is written to stderr and intentionally does not echo the original remote command.",
|
||||
};
|
||||
}
|
||||
@@ -294,8 +303,10 @@ export function sshTruncationCompletionSummary(options: {
|
||||
commandOmitted: true,
|
||||
stdout: options.stdout,
|
||||
stderr: options.stderr,
|
||||
message: "SSH output was truncated, but the command has finished; use this completion summary for closeout status and inspect dumpPath only when full output is needed.",
|
||||
action: "Use exitCode/timedOut plus dumpPath from this summary instead of manually inferring success from a long truncated stream.",
|
||||
message: "SSH output was truncated, but the command has finished; use exitCode/timedOut for closeout and rerun a semantic narrow query when more evidence is needed.",
|
||||
action: options.stdout?.dumpPath != null || options.stderr?.dumpPath != null
|
||||
? "An explicit stream dump was requested; inspect its dumpPath only when the complete one-off payload is required."
|
||||
: "No dump was created by default; use cat/head/tail/rg, --limit, --tail-bytes, or an id-specific query.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -378,6 +389,7 @@ export function createSshStdoutForwarder(options: {
|
||||
transport: SshStdoutTruncationHint["transport"];
|
||||
maxBytes?: number;
|
||||
stdout?: NodeJS.WritableStream;
|
||||
persistDump?: boolean;
|
||||
}): SshStreamForwarder {
|
||||
return createSshStreamForwarder({
|
||||
invocation: options.invocation,
|
||||
@@ -385,6 +397,7 @@ export function createSshStdoutForwarder(options: {
|
||||
stream: "stdout",
|
||||
maxBytes: options.maxBytes ?? sshStdoutStreamMaxBytes(),
|
||||
target: options.stdout ?? process.stdout,
|
||||
persistDump: options.persistDump ?? sshStreamDumpExplicitlyRequested(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -393,6 +406,7 @@ export function createSshStderrForwarder(options: {
|
||||
transport: SshStdoutTruncationHint["transport"];
|
||||
maxBytes?: number;
|
||||
stderr?: NodeJS.WritableStream;
|
||||
persistDump?: boolean;
|
||||
}): SshStreamForwarder {
|
||||
return createSshStreamForwarder({
|
||||
invocation: options.invocation,
|
||||
@@ -400,6 +414,7 @@ export function createSshStderrForwarder(options: {
|
||||
stream: "stderr",
|
||||
maxBytes: options.maxBytes ?? sshStderrStreamMaxBytes(),
|
||||
target: options.stderr ?? process.stderr,
|
||||
persistDump: options.persistDump ?? sshStreamDumpExplicitlyRequested(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -409,6 +424,7 @@ function createSshStreamForwarder(options: {
|
||||
stream: SshStdoutTruncationHint["stream"];
|
||||
maxBytes: number;
|
||||
target: NodeJS.WritableStream;
|
||||
persistDump: boolean;
|
||||
}): SshStreamForwarder {
|
||||
let observedBytes = 0;
|
||||
let forwardedBytes = 0;
|
||||
@@ -419,6 +435,7 @@ function createSshStreamForwarder(options: {
|
||||
const bufferedChunks: Buffer[] = [];
|
||||
|
||||
const appendDump = (chunk: Buffer): void => {
|
||||
if (!options.persistDump) return;
|
||||
if (dumpError !== null) return;
|
||||
try {
|
||||
if (dumpPath === null) {
|
||||
@@ -438,7 +455,7 @@ function createSshStreamForwarder(options: {
|
||||
write(chunk: Buffer): string | null {
|
||||
observedBytes += chunk.length;
|
||||
if (!truncated && observedBytes <= options.maxBytes) {
|
||||
bufferedChunks.push(Buffer.from(chunk));
|
||||
if (options.persistDump) bufferedChunks.push(Buffer.from(chunk));
|
||||
options.target.write(chunk);
|
||||
forwardedBytes += chunk.length;
|
||||
return null;
|
||||
@@ -483,6 +500,10 @@ function createSshStreamForwarder(options: {
|
||||
};
|
||||
}
|
||||
|
||||
export function sshStreamDumpExplicitlyRequested(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return env.UNIDESK_TRAN_STREAM_DUMP === "1";
|
||||
}
|
||||
|
||||
function brokerSource(): string {
|
||||
return String.raw`
|
||||
const open = JSON.parse(process.argv[2] || process.argv[1] || "{}");
|
||||
|
||||
+26
-5
@@ -15,6 +15,7 @@ import {
|
||||
sshTruncationCompletionSummary,
|
||||
sshCaptureBackendPlan,
|
||||
sshStderrStreamMaxBytes,
|
||||
sshStreamDumpExplicitlyRequested,
|
||||
sshStdoutStreamMaxBytes,
|
||||
sshStdoutTruncationHint,
|
||||
sshWindowsPlaneHint,
|
||||
@@ -408,13 +409,13 @@ describe("ssh stdout bounded streaming", () => {
|
||||
expect(payload.route).toBe("D601:win/c/test");
|
||||
expect(payload.thresholdBytes).toBe(4096);
|
||||
expect(payload.disclosurePolicy).toMatchObject({
|
||||
name: "unified-cli-dump-preview",
|
||||
name: "bounded-stream-explicit-dump",
|
||||
configPath: "config/unidesk-cli.yaml",
|
||||
});
|
||||
expect(formatted).not.toContain("Get-Content");
|
||||
});
|
||||
|
||||
test("forwarder bounds stdout and emits one truncation hint", () => {
|
||||
test("forwarder bounds stdout without creating a default dump", () => {
|
||||
const invocation = parseSshInvocation("D601:win/c/test", ["ps"]);
|
||||
const forwarded: Buffer[] = [];
|
||||
const stdout = {
|
||||
@@ -438,8 +439,9 @@ describe("ssh stdout bounded streaming", () => {
|
||||
expect(hint).toContain("UNIDESK_SSH_STDOUT_TRUNCATED");
|
||||
expect(hint).toContain("\"transport\":\"frontend-websocket\"");
|
||||
expect(hint).toContain("\"forwardedBytes\":5");
|
||||
const payload = JSON.parse(hint!.slice("UNIDESK_SSH_STDOUT_TRUNCATED ".length)) as { dumpPath: string };
|
||||
expect(readFileSync(payload.dumpPath, "utf8")).toBe("abcdefghijkl");
|
||||
const payload = JSON.parse(hint!.slice("UNIDESK_SSH_STDOUT_TRUNCATED ".length)) as { dumpPath: string | null; action: string };
|
||||
expect(payload.dumpPath).toBeNull();
|
||||
expect(payload.action).toContain("cat/head/tail/rg");
|
||||
const summary = sshTruncationCompletionSummary({
|
||||
invocation,
|
||||
transport: "frontend-websocket",
|
||||
@@ -453,7 +455,26 @@ describe("ssh stdout bounded streaming", () => {
|
||||
expect(formattedSummary).toContain("UNIDESK_SSH_TRUNCATION_SUMMARY");
|
||||
expect(formattedSummary).toContain("\"exitCode\":0");
|
||||
expect(formattedSummary).toContain("\"commandOmitted\":true");
|
||||
expect(formattedSummary).toContain("\"dumpPath\"");
|
||||
expect(formattedSummary).toContain("\"dumpPath\":null");
|
||||
});
|
||||
|
||||
test("keeps complete stream capture behind an explicit audited switch", () => {
|
||||
expect(sshStreamDumpExplicitlyRequested({} as NodeJS.ProcessEnv)).toBe(false);
|
||||
expect(sshStreamDumpExplicitlyRequested({ UNIDESK_TRAN_STREAM_DUMP: "1" } as NodeJS.ProcessEnv)).toBe(true);
|
||||
const invocation = parseSshInvocation("D601:win/c/test", ["ps"]);
|
||||
const stdout = { write(): boolean { return true; } } as NodeJS.WritableStream;
|
||||
const forwarder = createSshStdoutForwarder({
|
||||
invocation,
|
||||
transport: "frontend-websocket",
|
||||
maxBytes: 5,
|
||||
stdout,
|
||||
persistDump: true,
|
||||
});
|
||||
expect(forwarder.write(Buffer.from("abc"))).toBeNull();
|
||||
const hint = forwarder.write(Buffer.from("defgh"));
|
||||
forwarder.write(Buffer.from("ijkl"));
|
||||
const payload = JSON.parse(hint!.slice("UNIDESK_SSH_STDOUT_TRUNCATED ".length)) as { dumpPath: string };
|
||||
expect(readFileSync(payload.dumpPath, "utf8")).toBe("abcdefghijkl");
|
||||
rmSync(payload.dumpPath, { force: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -2117,6 +2117,7 @@ export {
|
||||
sshRuntimeTimingHint,
|
||||
sshSlowWarningThresholdMs,
|
||||
sshStderrStreamMaxBytes,
|
||||
sshStreamDumpExplicitlyRequested,
|
||||
sshStdoutStreamMaxBytes,
|
||||
sshStdoutTruncationHint,
|
||||
sshTcpPoolHint,
|
||||
|
||||
Reference in New Issue
Block a user