fix: bound cli dump previews from yaml
This commit is contained in:
+190
-24
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user