fix: bound cli dump previews from yaml

This commit is contained in:
Codex
2026-07-01 03:02:16 +00:00
parent a2ed11575d
commit 2210b66eee
7 changed files with 327 additions and 45 deletions
+190 -24
View File
@@ -1,7 +1,6 @@
import { spawn, spawnSync } from "node:child_process";
import { createHash, randomBytes } from "node:crypto";
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { type UniDeskConfig, repoRoot } from "./config";
import {
@@ -24,6 +23,7 @@ import {
} from "./ssh-file-transfer";
import { readTransHostProxyEnvRule, type TransHostProxyEnvRule } from "./trans-host-proxy";
import { readTransSshBackendConfig, type TransSshBackendConfig } from "./trans-config";
import { readCliOutputPolicy } from "./output";
export interface ParsedSshArgs {
remoteCommand: string | null;
@@ -151,16 +151,30 @@ export class SshRemovedShellAliasError extends Error {
}
export interface SshStdoutTruncationHint {
code: "ssh-stdout-truncated";
code: "ssh-stdout-truncated" | "ssh-stderr-truncated";
level: "warning";
stream: "stdout" | "stderr";
providerId: string;
route: string;
transport: "backend-core-broker" | "frontend-websocket";
invocationKind: SshInvocationKind;
thresholdBytes: number;
thresholdLines: number;
trigger: "bytes" | "lines" | "bytes-and-lines";
observedBytesAtTruncation: number;
forwardedBytes: number;
observedLinesAtTruncation: number;
forwardedLines: number;
dumpPath: string | null;
dumpError: string | null;
disclosurePolicy: {
name: "unified-cli-dump-preview";
configPath: string;
maxPreviewBytes: number;
maxPreviewLines: number;
dumpDir: string;
};
recommendedRerun: string[];
message: string;
action: string;
note: string;
@@ -200,10 +214,8 @@ const defaultSshRuntimeTimeoutMs = 60_000;
const maxSshRuntimeTimeoutMs = 60_000;
const defaultSshBackendCoreDetectTimeoutMs = 15_000;
const maxSshBackendCoreDetectTimeoutMs = 60_000;
const defaultSshStdoutStreamMaxBytes = 256 * 1024;
const minSshStdoutStreamMaxBytes = 4 * 1024;
const maxSshStdoutStreamMaxBytes = 16 * 1024 * 1024;
const sshStdoutDumpDir = join(tmpdir(), "unidesk-cli-output");
export const sshShellCompatibilityPrelude = 'printf(){ if [ "${1+x}" = x ] && [ "$1" = "-v" ] && [ -n "${BASH_VERSION:-}" ]; then command printf "$@"; return $?; fi; if [ "${1+x}" = x ] && [ "$1" = "--" ]; then shift; fi; command printf -- "$@"; }';
export const sshUserToolPathPrelude = [
'for unidesk_path_dir in "$HOME/.bun/bin" "$HOME/.local/bin" "$HOME/bin" "/root/.bun/bin"; do',
@@ -2653,37 +2665,71 @@ export function formatSshRuntimeTimeoutHint(hint: SshRuntimeTimeoutHint): string
export function sshStdoutStreamMaxBytes(env: NodeJS.ProcessEnv = process.env): number {
const raw = env.UNIDESK_SSH_STDOUT_STREAM_MAX_BYTES ?? env.UNIDESK_TRAN_STDOUT_STREAM_MAX_BYTES;
const parsed = raw === undefined ? NaN : Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) return defaultSshStdoutStreamMaxBytes;
if (!Number.isFinite(parsed) || parsed <= 0) return readCliOutputPolicy().maxStdoutBytes;
return Math.min(maxSshStdoutStreamMaxBytes, Math.max(minSshStdoutStreamMaxBytes, Math.trunc(parsed)));
}
export function sshStderrStreamMaxBytes(env: NodeJS.ProcessEnv = process.env): number {
const raw = env.UNIDESK_SSH_STDERR_STREAM_MAX_BYTES ?? env.UNIDESK_TRAN_STDERR_STREAM_MAX_BYTES;
const parsed = raw === undefined ? NaN : Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) return readCliOutputPolicy().maxStdoutBytes;
return Math.min(maxSshStdoutStreamMaxBytes, Math.max(minSshStdoutStreamMaxBytes, Math.trunc(parsed)));
}
export function sshStdoutTruncationHint(options: {
invocation: ParsedSshInvocation;
transport: SshStdoutTruncationHint["transport"];
stream?: SshStdoutTruncationHint["stream"];
thresholdBytes: number;
thresholdLines?: number;
trigger?: SshStdoutTruncationHint["trigger"];
observedBytesAtTruncation: number;
forwardedBytes?: number;
observedLinesAtTruncation?: number;
forwardedLines?: number;
dumpPath: string | null;
dumpError?: string | null;
}): SshStdoutTruncationHint {
const stream = options.stream ?? "stdout";
const policy = readCliOutputPolicy();
return {
code: "ssh-stdout-truncated",
code: stream === "stdout" ? "ssh-stdout-truncated" : "ssh-stderr-truncated",
level: "warning",
stream,
providerId: safeProviderId(options.invocation.providerId),
route: options.invocation.route.raw,
transport: options.transport,
invocationKind: options.invocation.parsed.invocationKind,
thresholdBytes: options.thresholdBytes,
thresholdLines: options.thresholdLines ?? policy.maxPreviewLines,
trigger: options.trigger ?? "bytes",
observedBytesAtTruncation: options.observedBytesAtTruncation,
forwardedBytes: options.forwardedBytes ?? Math.min(options.thresholdBytes, options.observedBytesAtTruncation),
observedLinesAtTruncation: options.observedLinesAtTruncation ?? 0,
forwardedLines: options.forwardedLines ?? 0,
dumpPath: options.dumpPath,
dumpError: options.dumpError ?? null,
message: `ssh stdout exceeded ${options.thresholdBytes} bytes; stdout is bounded and the complete stream is written to a local dump when possible.`,
action: "Inspect the dump path, or rerun a narrower remote command with tail/paging instead of emitting full logs or huge JSON.",
disclosurePolicy: {
name: "unified-cli-dump-preview",
configPath: policy.configPath,
maxPreviewBytes: policy.maxStdoutBytes,
maxPreviewLines: policy.maxPreviewLines,
dumpDir: policy.dumpDir,
},
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.",
],
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.",
note: "This hint is written to stderr and intentionally does not echo the original remote command.",
};
}
export function formatSshStdoutTruncationHint(hint: SshStdoutTruncationHint): string {
return `UNIDESK_SSH_STDOUT_TRUNCATED ${JSON.stringify(hint)}\n`;
const marker = hint.stream === "stderr" ? "UNIDESK_SSH_STDERR_TRUNCATED" : "UNIDESK_SSH_STDOUT_TRUNCATED";
return `${marker} ${JSON.stringify(hint)}\n`;
}
export function classifySshTcpPoolFailure(text: string): SshTcpPoolFailureKind | null {
@@ -2739,27 +2785,66 @@ export function formatSshTcpPoolHint(hint: SshTcpPoolHint | null): string {
return hint === null ? "" : `UNIDESK_SSH_TCP_POOL_HINT ${JSON.stringify(hint)}\n`;
}
function sshStdoutDumpPath(invocation: ParsedSshInvocation): string {
mkdirSync(sshStdoutDumpDir, { recursive: true, mode: 0o700 });
function sshStreamDumpPath(invocation: ParsedSshInvocation, stream: SshStdoutTruncationHint["stream"]): string {
const policy = readCliOutputPolicy();
mkdirSync(policy.dumpDir, { recursive: true, mode: 0o700 });
const timestamp = new Date().toISOString().replace(/[:.]/gu, "-");
const suffix = randomBytes(4).toString("hex");
const slug = `${invocation.providerId}-${invocation.route.raw}-${invocation.route.plane}`
.replace(/[^A-Za-z0-9._-]+/gu, "-")
.replace(/^-+|-+$/gu, "")
.slice(0, 80) || "ssh";
return join(sshStdoutDumpDir, `${timestamp}-${process.pid}-${suffix}-${slug}.stdout.bin`);
return join(policy.dumpDir, `${timestamp}-${process.pid}-${suffix}-${slug}.${stream}.bin`);
}
export function createSshStdoutForwarder(options: {
invocation: ParsedSshInvocation;
transport: SshStdoutTruncationHint["transport"];
maxBytes?: number;
maxLines?: number;
stdout?: NodeJS.WritableStream;
}): { write: (chunk: Buffer) => string | null } {
const stdout = options.stdout ?? process.stdout;
const maxBytes = options.maxBytes ?? sshStdoutStreamMaxBytes();
return createSshStreamForwarder({
invocation: options.invocation,
transport: options.transport,
stream: "stdout",
maxBytes: options.maxBytes ?? sshStdoutStreamMaxBytes(),
maxLines: options.maxLines ?? readCliOutputPolicy().maxPreviewLines,
target: options.stdout ?? process.stdout,
});
}
export function createSshStderrForwarder(options: {
invocation: ParsedSshInvocation;
transport: SshStdoutTruncationHint["transport"];
maxBytes?: number;
maxLines?: number;
stderr?: NodeJS.WritableStream;
}): { write: (chunk: Buffer) => string | null } {
return createSshStreamForwarder({
invocation: options.invocation,
transport: options.transport,
stream: "stderr",
maxBytes: options.maxBytes ?? sshStderrStreamMaxBytes(),
maxLines: options.maxLines ?? readCliOutputPolicy().maxPreviewLines,
target: options.stderr ?? process.stderr,
});
}
function createSshStreamForwarder(options: {
invocation: ParsedSshInvocation;
transport: SshStdoutTruncationHint["transport"];
stream: SshStdoutTruncationHint["stream"];
maxBytes: number;
maxLines: number;
target: NodeJS.WritableStream;
}): { write: (chunk: Buffer) => string | null } {
let observedBytes = 0;
let forwardedBytes = 0;
let observedLineBreaks = 0;
let forwardedLineBreaks = 0;
let observedEndsWithLineBreak = false;
let forwardedEndsWithLineBreak = false;
let truncated = false;
let dumpPath: string | null = null;
let dumpError: string | null = null;
@@ -2769,7 +2854,7 @@ export function createSshStdoutForwarder(options: {
if (dumpError !== null) return;
try {
if (dumpPath === null) {
dumpPath = sshStdoutDumpPath(options.invocation);
dumpPath = sshStreamDumpPath(options.invocation, options.stream);
writeFileSync(dumpPath, Buffer.alloc(0), { mode: 0o600 });
for (const buffered of bufferedChunks) appendFileSync(dumpPath, buffered);
bufferedChunks.length = 0;
@@ -2784,26 +2869,48 @@ export function createSshStdoutForwarder(options: {
return {
write(chunk: Buffer): string | null {
observedBytes += chunk.length;
if (!truncated && observedBytes <= maxBytes) {
observedLineBreaks += countBufferLineBreaks(chunk);
if (chunk.length > 0) observedEndsWithLineBreak = chunk[chunk.length - 1] === 10;
const observedLines = lineCountFromBreaks(observedBytes, observedLineBreaks, observedEndsWithLineBreak);
if (!truncated && observedBytes <= options.maxBytes && observedLines <= options.maxLines) {
bufferedChunks.push(Buffer.from(chunk));
stdout.write(chunk);
options.target.write(chunk);
forwardedBytes += chunk.length;
forwardedLineBreaks += countBufferLineBreaks(chunk);
if (chunk.length > 0) forwardedEndsWithLineBreak = chunk[chunk.length - 1] === 10;
return null;
}
if (!truncated) {
truncated = true;
const remaining = Math.max(0, maxBytes - forwardedBytes);
const remainingByBytes = Math.max(0, options.maxBytes - forwardedBytes);
const remainingByLines = prefixLengthWithinLineBudget(
chunk,
forwardedBytes,
forwardedLineBreaks,
forwardedEndsWithLineBreak,
options.maxLines,
);
const remaining = Math.min(remainingByBytes, remainingByLines);
if (remaining > 0) {
stdout.write(chunk.subarray(0, remaining));
const forwarded = chunk.subarray(0, remaining);
options.target.write(forwarded);
forwardedBytes += remaining;
forwardedLineBreaks += countBufferLineBreaks(forwarded);
if (forwarded.length > 0) forwardedEndsWithLineBreak = forwarded[forwarded.length - 1] === 10;
}
appendDump(chunk);
return formatSshStdoutTruncationHint(sshStdoutTruncationHint({
invocation: options.invocation,
transport: options.transport,
thresholdBytes: maxBytes,
stream: options.stream,
thresholdBytes: options.maxBytes,
thresholdLines: options.maxLines,
trigger: sshStreamTruncationTrigger(observedBytes, observedLines, options.maxBytes, options.maxLines),
observedBytesAtTruncation: observedBytes,
forwardedBytes,
observedLinesAtTruncation: lineCountFromBreaks(observedBytes, observedLineBreaks, observedEndsWithLineBreak),
forwardedLines: lineCountFromBreaks(forwardedBytes, forwardedLineBreaks, forwardedEndsWithLineBreak),
dumpPath,
dumpError,
}));
@@ -2815,6 +2922,51 @@ export function createSshStdoutForwarder(options: {
};
}
function countBufferLineBreaks(buffer: Buffer): number {
let count = 0;
for (const byte of buffer) {
if (byte === 10) count += 1;
}
return count;
}
function lineCountFromBreaks(bytes: number, lineBreaks: number, endsWithLineBreak: boolean): number {
return bytes === 0 ? 0 : lineBreaks + (endsWithLineBreak ? 0 : 1);
}
function sshStreamTruncationTrigger(
observedBytes: number,
observedLines: number,
maxBytes: number,
maxLines: number,
): SshStdoutTruncationHint["trigger"] {
const bytes = observedBytes > maxBytes;
const lines = observedLines > maxLines;
if (bytes && lines) return "bytes-and-lines";
return bytes ? "bytes" : "lines";
}
function prefixLengthWithinLineBudget(
chunk: Buffer,
currentBytes: number,
currentLineBreaks: number,
currentEndsWithLineBreak: boolean,
maxLines: number,
): number {
if (lineCountFromBreaks(currentBytes, currentLineBreaks, currentEndsWithLineBreak) >= maxLines) return 0;
let bytes = currentBytes;
let lineBreaks = currentLineBreaks;
let endsWithLineBreak = currentEndsWithLineBreak;
for (let index = 0; index < chunk.length; index += 1) {
const byte = chunk[index] ?? 0;
bytes += 1;
if (byte === 10) lineBreaks += 1;
endsWithLineBreak = byte === 10;
if (lineCountFromBreaks(bytes, lineBreaks, endsWithLineBreak) > maxLines) return index;
}
return chunk.length;
}
function brokerSource(): string {
return String.raw`
const open = JSON.parse(process.argv[2] || process.argv[1] || "{}");
@@ -3769,6 +3921,10 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
invocation,
transport: "backend-core-broker",
});
const stderrForwarder = createSshStderrForwarder({
invocation,
transport: "backend-core-broker",
});
child.stdout.on("data", (chunk: Buffer) => {
const hint = stdoutForwarder.write(chunk);
if (hint !== null) {
@@ -3776,11 +3932,21 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
process.stderr.write(hint);
}
});
child.stderr.on("data", (chunk: Buffer) => {
appendStderrTail(chunk);
const hint = stderrForwarder.write(chunk);
if (hint !== null) {
appendStderrTail(hint);
process.stderr.write(hint);
}
});
}
if (parsed.remoteCommand === null) {
child.stderr.on("data", (chunk: Buffer) => {
appendStderrTail(chunk);
process.stderr.write(chunk);
});
}
child.stderr.on("data", (chunk: Buffer) => {
appendStderrTail(chunk);
process.stderr.write(chunk);
});
return await new Promise<number>((resolve) => {
let settled = false;