1598 lines
75 KiB
TypeScript
1598 lines
75 KiB
TypeScript
import { spawn, spawnSync } from "node:child_process";
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { type UniDeskConfig, repoRoot } from "./config";
|
|
import {
|
|
ApplyPatchV2Error,
|
|
applyPatchV2RemoteCommand,
|
|
decodeApplyPatchV2BulkRead,
|
|
formatApplyPatchV2BulkReplacementPayload,
|
|
runApplyPatchV2,
|
|
type ApplyPatchV2BulkReplacementWritePlan,
|
|
type ApplyPatchV2Executor,
|
|
type ApplyPatchV2FileSystem,
|
|
type ApplyPatchV2RemoteOperation,
|
|
} from "./apply-patch-v2";
|
|
import {
|
|
isSshFileTransferOperation,
|
|
runSshFileTransferOperation,
|
|
type SshRemoteCommandExecutor,
|
|
type SshRemoteCommandStreamHandlers,
|
|
} from "./ssh-file-transfer";
|
|
import { readTransSshBackendConfig, type TransSshBackendConfig } from "./trans-config";
|
|
import { readCliOutputPolicy } from "./output";
|
|
import { readHostK8sPublicHost } from "./host-k8s-config";
|
|
import {
|
|
buildK3sTargetCommand,
|
|
buildWindowsPowerShellInvocation,
|
|
effectiveApplyPatchV2Invocation,
|
|
normalizeSshOperationArgs,
|
|
parseSshInvocation,
|
|
powerShellSingleQuote,
|
|
safeProviderId,
|
|
shellArgv,
|
|
shellQuote,
|
|
sshRoutePayloadCwd,
|
|
sshRouteSeparatorCompatibilityHint,
|
|
wrapSshRemoteCommand,
|
|
type ParsedSshArgs,
|
|
type ParsedSshInvocation,
|
|
type ParsedSshRoute,
|
|
type SshCaptureBackend,
|
|
type SshCaptureBackendPlan,
|
|
type SshCaptureResult,
|
|
type SshFailureHint,
|
|
type SshInvocationKind,
|
|
type SshRuntimeTimeoutHint,
|
|
type SshRuntimeTimingHint,
|
|
type SshStdoutTruncationHint,
|
|
type SshStreamTruncationSummary,
|
|
type SshTcpPoolFailureKind,
|
|
type SshTcpPoolHint,
|
|
type SshTruncationCompletionSummary,
|
|
} from "./ssh";
|
|
|
|
const defaultSshSlowWarningMs = 10_000;
|
|
const defaultSshRuntimeTimeoutMs = 60_000;
|
|
const maxSshRuntimeTimeoutMs = 60_000;
|
|
const defaultSshBackendCoreDetectTimeoutMs = 15_000;
|
|
const maxSshBackendCoreDetectTimeoutMs = 60_000;
|
|
const minSshStdoutStreamMaxBytes = 4 * 1024;
|
|
const maxSshStdoutStreamMaxBytes = 16 * 1024 * 1024;
|
|
function classifySshLikeFailure(exitCode: number, stderrText: string): SshFailureHint["trigger"] | null {
|
|
const normalized = stderrText.toLowerCase();
|
|
if (
|
|
normalized.includes("kex_exchange_identification")
|
|
|| normalized.includes("ssh_exchange_identification")
|
|
|| normalized.includes("connection closed by remote host")
|
|
|| normalized.includes("connection reset by peer")
|
|
|| normalized.includes("connection timed out")
|
|
|| normalized.includes("operation timed out")
|
|
|| normalized.includes("timed out waiting for provider session")
|
|
|| normalized.includes("the operation was aborted")
|
|
) {
|
|
return "timeout-or-kex";
|
|
}
|
|
return exitCode === 255 ? "exit-255" : null;
|
|
}
|
|
|
|
export function sshFailureHint(providerId: string, parsed: ParsedSshArgs, exitCode: number, stderrText: string): SshFailureHint | null {
|
|
if (parsed.invocationKind !== "ssh-like") return null;
|
|
const trigger = classifySshLikeFailure(exitCode, stderrText);
|
|
if (trigger === null) return null;
|
|
const shownProviderId = safeProviderId(providerId);
|
|
return {
|
|
code: "ssh-like-command-friction",
|
|
providerId: shownProviderId,
|
|
trigger,
|
|
exitCode,
|
|
message: "ssh-like remote command failed before proving Host SSH is globally unavailable; prefer structured argv or explicit sh/bash stdin passthrough for non-interactive commands.",
|
|
try: `trans ${shownProviderId} sh <<'SH'`,
|
|
triage: `bun scripts/cli.ts provider triage ${shownProviderId} --observed-scope ssh --observed-error '<ssh-like timeout or kex failure>'`,
|
|
note: "This hint intentionally does not echo the original remote command.",
|
|
};
|
|
}
|
|
|
|
export function formatSshFailureHint(hint: SshFailureHint): string {
|
|
return `UNIDESK_SSH_HINT ${JSON.stringify(hint)}\n`;
|
|
}
|
|
|
|
export function sshWindowsPlaneHint(invocation: ParsedSshInvocation, exitCode: number, stderrText: string): string {
|
|
if (invocation.route.plane !== "host" || exitCode !== 127) return "";
|
|
const workspace = invocation.route.workspace ?? "";
|
|
const match = workspace.match(/^\/mnt\/([a-z])(?:\/(.*))?$/iu);
|
|
if (match === null || !/command not found|not found/iu.test(stderrText)) return "";
|
|
const drive = (match[1] ?? "c").toLowerCase();
|
|
const suffix = (match[2] ?? "").replace(/^\/+|\/+$/gu, "");
|
|
const windowsRoute = `${invocation.providerId}:win/${drive}${suffix.length > 0 ? `/${suffix}` : ""}`;
|
|
return `UNIDESK_SSH_HINT ${JSON.stringify({
|
|
code: "wsl-command-not-found-check-windows-plane",
|
|
providerId: invocation.providerId,
|
|
route: invocation.route.raw,
|
|
exitCode,
|
|
message: "The command was not found on the WSL/host plane; this does not prove it is absent from the same provider's Windows PATH.",
|
|
try: `trans ${windowsRoute} cmd <command> ...`,
|
|
windowsRoute,
|
|
})}\n`;
|
|
}
|
|
|
|
export function sshWindowsPowerShellPipelineHint(invocation: ParsedSshInvocation, exitCode: number, stderrText: string): string {
|
|
if (invocation.route.plane !== "win" || exitCode === 0 || !/An empty pipe element is not allowed/iu.test(stderrText)) return "";
|
|
return `UNIDESK_SSH_HINT ${JSON.stringify({
|
|
code: "windows-powershell-statement-pipeline-boundary",
|
|
providerId: safeProviderId(invocation.providerId),
|
|
route: invocation.route.raw,
|
|
exitCode,
|
|
message: "PowerShell cannot pipe a statement such as foreach directly at this syntax boundary.",
|
|
action: "Wrap the statement in @(...), then pipe the resulting values, or use a multiline quoted PowerShell heredoc.",
|
|
examples: ["@(foreach (...) { ... }) | Format-List", "trans <route> ps <<'PS'"],
|
|
note: "This hint intentionally does not echo the submitted PowerShell source.",
|
|
})}\n`;
|
|
}
|
|
|
|
export function sshSlowWarningThresholdMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
const raw = env.UNIDESK_SSH_SLOW_WARNING_MS;
|
|
const parsed = raw === undefined ? NaN : Number(raw);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) return defaultSshSlowWarningMs;
|
|
return Math.max(1000, Math.trunc(parsed));
|
|
}
|
|
|
|
export function sshRuntimeTimingHint(options: {
|
|
invocation: ParsedSshInvocation;
|
|
transport: SshRuntimeTimingHint["transport"];
|
|
exitCode: number;
|
|
startedAtMs: number;
|
|
finishedAtMs?: number;
|
|
thresholdMs?: number;
|
|
}): SshRuntimeTimingHint {
|
|
const finishedAtMs = options.finishedAtMs ?? Date.now();
|
|
const thresholdMs = options.thresholdMs ?? sshSlowWarningThresholdMs();
|
|
const elapsedMs = Math.max(0, Math.round(finishedAtMs - options.startedAtMs));
|
|
const elapsedSeconds = Number((elapsedMs / 1000).toFixed(3));
|
|
const slow = elapsedMs > thresholdMs;
|
|
const thresholdSeconds = Number((thresholdMs / 1000).toFixed(3));
|
|
return {
|
|
code: "ssh-runtime-timing",
|
|
level: slow ? "warning" : "info",
|
|
providerId: safeProviderId(options.invocation.providerId),
|
|
route: options.invocation.route.raw,
|
|
transport: options.transport,
|
|
invocationKind: options.invocation.parsed.invocationKind,
|
|
exitCode: options.exitCode,
|
|
elapsedMs,
|
|
elapsedSeconds,
|
|
thresholdMs,
|
|
slow,
|
|
message: slow
|
|
? `ssh operation took ${elapsedSeconds}s, above the ${thresholdSeconds}s warning threshold; consider checking provider/session latency, remote command cost, helper bootstrap, or tran/apply-patch optimization before repeating high-frequency operations.`
|
|
: `ssh operation completed in ${elapsedSeconds}s.`,
|
|
note: "Timing hint is written to stderr and intentionally does not echo the original remote command.",
|
|
};
|
|
}
|
|
|
|
export function formatSshRuntimeTimingHint(hint: SshRuntimeTimingHint): string {
|
|
if (!hint.slow) return "";
|
|
return `UNIDESK_SSH_TIMING ${JSON.stringify(hint)}\n`;
|
|
}
|
|
|
|
export function sshRuntimeTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
const raw = env.UNIDESK_SSH_RUNTIME_TIMEOUT_MS ?? env.UNIDESK_TRAN_RUNTIME_TIMEOUT_MS;
|
|
const parsed = raw === undefined ? NaN : Number(raw);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) return defaultSshRuntimeTimeoutMs;
|
|
return Math.min(maxSshRuntimeTimeoutMs, Math.max(1000, Math.trunc(parsed)));
|
|
}
|
|
|
|
export function sshRuntimeTimeoutHint(options: {
|
|
invocation: ParsedSshInvocation;
|
|
transport: SshRuntimeTimeoutHint["transport"];
|
|
timeoutMs: number;
|
|
}): SshRuntimeTimeoutHint {
|
|
const timeoutSeconds = Number((options.timeoutMs / 1000).toFixed(3));
|
|
return {
|
|
code: "ssh-runtime-timeout",
|
|
level: "warning",
|
|
providerId: safeProviderId(options.invocation.providerId),
|
|
route: options.invocation.route.raw,
|
|
transport: options.transport,
|
|
invocationKind: options.invocation.parsed.invocationKind,
|
|
timeoutMs: options.timeoutMs,
|
|
timeoutSeconds,
|
|
message: `ssh/tran operation exceeded the ${timeoutSeconds}s top-level runtime limit and was disconnected.`,
|
|
action: "Use short query plus poll semantics; do not keep tran open waiting for long CI/CD, trace, logs, or build progress.",
|
|
note: "Timeout hint is written to stderr and intentionally does not echo the original remote command.",
|
|
};
|
|
}
|
|
|
|
export function formatSshRuntimeTimeoutHint(hint: SshRuntimeTimeoutHint): string {
|
|
return `UNIDESK_SSH_RUNTIME_TIMEOUT ${JSON.stringify(hint)}\n`;
|
|
}
|
|
|
|
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 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;
|
|
observedBytesAtTruncation: number;
|
|
forwardedBytes?: number;
|
|
dumpPath: string | null;
|
|
dumpError?: string | null;
|
|
}): SshStdoutTruncationHint {
|
|
const stream = options.stream ?? "stdout";
|
|
const policy = readCliOutputPolicy();
|
|
return {
|
|
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,
|
|
observedBytesAtTruncation: options.observedBytesAtTruncation,
|
|
forwardedBytes: options.forwardedBytes ?? Math.min(options.thresholdBytes, options.observedBytesAtTruncation),
|
|
dumpPath: options.dumpPath,
|
|
dumpError: options.dumpError ?? null,
|
|
disclosurePolicy: {
|
|
name: "unified-cli-dump-preview",
|
|
configPath: policy.configPath,
|
|
maxPreviewBytes: policy.maxStdoutBytes,
|
|
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 {
|
|
const marker = hint.stream === "stderr" ? "UNIDESK_SSH_STDERR_TRUNCATED" : "UNIDESK_SSH_STDOUT_TRUNCATED";
|
|
return `${marker} ${JSON.stringify(hint)}\n`;
|
|
}
|
|
|
|
export function sshTruncationCompletionSummary(options: {
|
|
invocation: ParsedSshInvocation;
|
|
transport: SshTruncationCompletionSummary["transport"];
|
|
exitCode: number;
|
|
timedOut: boolean;
|
|
startedAtMs: number;
|
|
stdout: SshStreamTruncationSummary | null;
|
|
stderr: SshStreamTruncationSummary | null;
|
|
}): SshTruncationCompletionSummary | null {
|
|
if (options.stdout === null && options.stderr === null) return null;
|
|
const elapsedMs = Math.max(0, Date.now() - options.startedAtMs);
|
|
return {
|
|
code: "ssh-truncation-summary",
|
|
level: "info",
|
|
providerId: safeProviderId(options.invocation.providerId),
|
|
route: options.invocation.route.raw,
|
|
transport: options.transport,
|
|
invocationKind: options.invocation.parsed.invocationKind,
|
|
exitCode: options.exitCode,
|
|
timedOut: options.timedOut,
|
|
elapsedMs,
|
|
elapsedSeconds: Math.round(elapsedMs / 100) / 10,
|
|
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.",
|
|
};
|
|
}
|
|
|
|
export function formatSshTruncationCompletionSummary(summary: SshTruncationCompletionSummary | null): string {
|
|
return summary === null ? "" : `UNIDESK_SSH_TRUNCATION_SUMMARY ${JSON.stringify(summary)}\n`;
|
|
}
|
|
|
|
export function classifySshTcpPoolFailure(text: string): SshTcpPoolFailureKind | null {
|
|
const normalized = text.toLowerCase();
|
|
if (normalized.includes("ssh tcp data channel closed")) return "provider-data-channel-closed";
|
|
if (
|
|
normalized.includes("requested ssh tcp data channel is not ready")
|
|
|| normalized.includes("ssh tcp data channel is not available")
|
|
|| normalized.includes('"failurekind":"provider-data-channel-missing"')
|
|
) {
|
|
return "provider-data-channel-missing";
|
|
}
|
|
if (
|
|
normalized.includes("provider ssh tcp data pool has no idle channel")
|
|
|| normalized.includes('"failurekind":"provider-data-pool-exhausted"')
|
|
) {
|
|
return "provider-data-pool-exhausted";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function sshTcpPoolHint(options: {
|
|
invocation: ParsedSshInvocation;
|
|
transport: SshTcpPoolHint["transport"];
|
|
exitCode: number;
|
|
stderrText: string;
|
|
}): SshTcpPoolHint | null {
|
|
if (options.exitCode === 0) return null;
|
|
const failureKind = classifySshTcpPoolFailure(options.stderrText);
|
|
if (failureKind === null) return null;
|
|
const providerId = safeProviderId(options.invocation.providerId);
|
|
return {
|
|
code: "ssh-tcp-pool-transient",
|
|
level: "warning",
|
|
providerId,
|
|
route: options.invocation.route.raw,
|
|
transport: options.transport,
|
|
invocationKind: options.invocation.parsed.invocationKind,
|
|
failureKind,
|
|
exitCode: options.exitCode,
|
|
message: "host.ssh.tcp-pool data channel failed during an SSH operation; classify this as transport/data-pool transient until pool labels and a retry prove otherwise.",
|
|
action: "Inspect providerGatewaySshData* labels, then rerun the same idempotent command through the controlled CLI. Do not treat this alone as HWLAB runtime configuration failure.",
|
|
retry: `trans ${providerId} argv true`,
|
|
diagnostics: {
|
|
poolStatus: `bun scripts/cli.ts debug ssh-pool ${providerId}`,
|
|
fullHealth: "bun scripts/cli.ts debug health",
|
|
},
|
|
note: "Hint is written to stderr and intentionally does not echo the original remote command.",
|
|
};
|
|
}
|
|
|
|
export function formatSshTcpPoolHint(hint: SshTcpPoolHint | null): string {
|
|
return hint === null ? "" : `UNIDESK_SSH_TCP_POOL_HINT ${JSON.stringify(hint)}\n`;
|
|
}
|
|
|
|
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(policy.dumpDir, `${timestamp}-${process.pid}-${suffix}-${slug}.${stream}.bin`);
|
|
}
|
|
|
|
export interface SshStreamForwarder {
|
|
write: (chunk: Buffer) => string | null;
|
|
summary: () => SshStreamTruncationSummary | null;
|
|
}
|
|
|
|
export function createSshStdoutForwarder(options: {
|
|
invocation: ParsedSshInvocation;
|
|
transport: SshStdoutTruncationHint["transport"];
|
|
maxBytes?: number;
|
|
stdout?: NodeJS.WritableStream;
|
|
}): SshStreamForwarder {
|
|
return createSshStreamForwarder({
|
|
invocation: options.invocation,
|
|
transport: options.transport,
|
|
stream: "stdout",
|
|
maxBytes: options.maxBytes ?? sshStdoutStreamMaxBytes(),
|
|
target: options.stdout ?? process.stdout,
|
|
});
|
|
}
|
|
|
|
export function createSshStderrForwarder(options: {
|
|
invocation: ParsedSshInvocation;
|
|
transport: SshStdoutTruncationHint["transport"];
|
|
maxBytes?: number;
|
|
stderr?: NodeJS.WritableStream;
|
|
}): SshStreamForwarder {
|
|
return createSshStreamForwarder({
|
|
invocation: options.invocation,
|
|
transport: options.transport,
|
|
stream: "stderr",
|
|
maxBytes: options.maxBytes ?? sshStderrStreamMaxBytes(),
|
|
target: options.stderr ?? process.stderr,
|
|
});
|
|
}
|
|
|
|
function createSshStreamForwarder(options: {
|
|
invocation: ParsedSshInvocation;
|
|
transport: SshStdoutTruncationHint["transport"];
|
|
stream: SshStdoutTruncationHint["stream"];
|
|
maxBytes: number;
|
|
target: NodeJS.WritableStream;
|
|
}): SshStreamForwarder {
|
|
let observedBytes = 0;
|
|
let forwardedBytes = 0;
|
|
let truncated = false;
|
|
let dumpPath: string | null = null;
|
|
let dumpError: string | null = null;
|
|
let lastHint: SshStdoutTruncationHint | null = null;
|
|
const bufferedChunks: Buffer[] = [];
|
|
|
|
const appendDump = (chunk: Buffer): void => {
|
|
if (dumpError !== null) return;
|
|
try {
|
|
if (dumpPath === null) {
|
|
dumpPath = sshStreamDumpPath(options.invocation, options.stream);
|
|
writeFileSync(dumpPath, Buffer.alloc(0), { mode: 0o600 });
|
|
for (const buffered of bufferedChunks) appendFileSync(dumpPath, buffered);
|
|
bufferedChunks.length = 0;
|
|
}
|
|
appendFileSync(dumpPath, chunk);
|
|
} catch (error) {
|
|
dumpError = error instanceof Error ? error.message : String(error);
|
|
dumpPath = null;
|
|
}
|
|
};
|
|
|
|
return {
|
|
write(chunk: Buffer): string | null {
|
|
observedBytes += chunk.length;
|
|
if (!truncated && observedBytes <= options.maxBytes) {
|
|
bufferedChunks.push(Buffer.from(chunk));
|
|
options.target.write(chunk);
|
|
forwardedBytes += chunk.length;
|
|
return null;
|
|
}
|
|
|
|
if (!truncated) {
|
|
truncated = true;
|
|
const remaining = Math.max(0, options.maxBytes - forwardedBytes);
|
|
if (remaining > 0) {
|
|
const forwarded = chunk.subarray(0, remaining);
|
|
options.target.write(forwarded);
|
|
forwardedBytes += remaining;
|
|
}
|
|
appendDump(chunk);
|
|
lastHint = sshStdoutTruncationHint({
|
|
invocation: options.invocation,
|
|
transport: options.transport,
|
|
stream: options.stream,
|
|
thresholdBytes: options.maxBytes,
|
|
observedBytesAtTruncation: observedBytes,
|
|
forwardedBytes,
|
|
dumpPath,
|
|
dumpError,
|
|
});
|
|
return formatSshStdoutTruncationHint(lastHint);
|
|
}
|
|
|
|
appendDump(chunk);
|
|
return null;
|
|
},
|
|
summary(): SshStreamTruncationSummary | null {
|
|
if (lastHint === null) return null;
|
|
return {
|
|
stream: lastHint.stream,
|
|
thresholdBytes: lastHint.thresholdBytes,
|
|
observedBytesAtTruncation: lastHint.observedBytesAtTruncation,
|
|
forwardedBytes: lastHint.forwardedBytes,
|
|
dumpPath,
|
|
dumpError,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
function brokerSource(): string {
|
|
return String.raw`
|
|
const open = JSON.parse(process.argv[2] || process.argv[1] || "{}");
|
|
const token = process.env.PROVIDER_TOKEN || process.env.UNIDESK_PROVIDER_TOKEN || "";
|
|
const baseUrl = process.env.UNIDESK_SSH_BROKER_URL || "ws://backend-core:8080/ws/ssh";
|
|
const url = baseUrl + "?token=" + encodeURIComponent(token);
|
|
const ws = new WebSocket(url);
|
|
let exitCode = 255;
|
|
let canSend = false;
|
|
let sessionReady = false;
|
|
let opened = false;
|
|
const pending = [];
|
|
const pendingInput = [];
|
|
const openTimer = setTimeout(() => {
|
|
if (opened) return;
|
|
process.stderr.write("unidesk ssh bridge timed out waiting for provider session\n");
|
|
try { ws.close(); } catch {}
|
|
process.exit(255);
|
|
}, Number(open.openTimeoutMs || 15000));
|
|
const runtimeTimeoutMs = Number(open.runtimeTimeoutMs || 60000);
|
|
const runtimeTimeoutMode = open.runtimeTimeoutMode === "inactivity" ? "inactivity" : "wall-clock";
|
|
let runtimeTimer = null;
|
|
function armRuntimeTimer() {
|
|
if (runtimeTimer !== null) clearTimeout(runtimeTimer);
|
|
runtimeTimer = setTimeout(() => {
|
|
const noun = runtimeTimeoutMode === "inactivity" ? "inactivity timeout" : "runtime timeout";
|
|
process.stderr.write("unidesk ssh bridge " + noun + "; use short query plus poll semantics for long non-streaming work\n");
|
|
exitCode = 124;
|
|
try { ws.close(); } catch {}
|
|
setTimeout(() => process.exit(124), 250).unref?.();
|
|
}, runtimeTimeoutMs);
|
|
}
|
|
function clearRuntimeTimer() {
|
|
if (runtimeTimer !== null) clearTimeout(runtimeTimer);
|
|
runtimeTimer = null;
|
|
}
|
|
armRuntimeTimer();
|
|
|
|
function send(value) {
|
|
const text = JSON.stringify(value);
|
|
if (!canSend || ws.readyState !== WebSocket.OPEN) {
|
|
pending.push(text);
|
|
return;
|
|
}
|
|
ws.send(text);
|
|
}
|
|
|
|
function flush() {
|
|
while (pending.length > 0 && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(pending.shift());
|
|
}
|
|
}
|
|
|
|
function sendInput(value) {
|
|
const text = JSON.stringify(value);
|
|
if (!sessionReady || ws.readyState !== WebSocket.OPEN) {
|
|
pendingInput.push(text);
|
|
return;
|
|
}
|
|
ws.send(text);
|
|
}
|
|
|
|
function flushInput() {
|
|
if (!sessionReady || ws.readyState !== WebSocket.OPEN) return;
|
|
while (pendingInput.length > 0) {
|
|
ws.send(pendingInput.shift());
|
|
}
|
|
}
|
|
|
|
function decodeData(data) {
|
|
return typeof data === "string" ? data : Buffer.from(data).toString("utf8");
|
|
}
|
|
|
|
ws.addEventListener("open", () => {
|
|
canSend = true;
|
|
send({
|
|
type: "ssh.open",
|
|
providerId: open.providerId,
|
|
command: open.command || undefined,
|
|
cwd: open.cwd || undefined,
|
|
tty: open.tty === true,
|
|
cols: open.cols || 100,
|
|
rows: open.rows || 30,
|
|
});
|
|
flush();
|
|
});
|
|
|
|
ws.addEventListener("message", (event) => {
|
|
const message = JSON.parse(decodeData(event.data));
|
|
if (runtimeTimeoutMode === "inactivity") armRuntimeTimer();
|
|
if (message.type === "ssh.data") {
|
|
opened = true;
|
|
const chunk = Buffer.from(message.data || "", "base64");
|
|
if (message.stream === "stderr") process.stderr.write(chunk);
|
|
else process.stdout.write(chunk);
|
|
return;
|
|
}
|
|
if (message.type === "ssh.opened") {
|
|
opened = true;
|
|
sessionReady = true;
|
|
clearTimeout(openTimer);
|
|
if (open.stdinEotOnEnd === true) setTimeout(flushInput, 200);
|
|
else flushInput();
|
|
return;
|
|
}
|
|
if (message.type === "ssh.dispatched") {
|
|
return;
|
|
}
|
|
if (message.type === "ssh.error") {
|
|
clearTimeout(openTimer);
|
|
clearRuntimeTimer();
|
|
process.stderr.write(String(message.message || "ssh bridge error") + "\n");
|
|
if (message.failureKind || message.providerId || message.dataChannelId || message.dataPool) {
|
|
process.stderr.write("UNIDESK_SSH_ERROR " + JSON.stringify({
|
|
failureKind: message.failureKind || null,
|
|
providerId: message.providerId || null,
|
|
dataChannelId: message.dataChannelId || null,
|
|
dataPool: message.dataPool || null,
|
|
transport: message.transport || null,
|
|
controlFallback: message.controlFallback === true
|
|
}) + "\n");
|
|
}
|
|
exitCode = 255;
|
|
ws.close();
|
|
return;
|
|
}
|
|
if (message.type === "ssh.exit") {
|
|
clearTimeout(openTimer);
|
|
clearRuntimeTimer();
|
|
exitCode = Number.isInteger(message.exitCode) ? message.exitCode : 255;
|
|
ws.close();
|
|
}
|
|
});
|
|
|
|
ws.addEventListener("close", () => {
|
|
clearTimeout(openTimer);
|
|
clearRuntimeTimer();
|
|
process.exit(exitCode);
|
|
});
|
|
|
|
ws.addEventListener("error", () => {
|
|
process.stderr.write("unidesk ssh bridge websocket error\n");
|
|
process.exit(255);
|
|
});
|
|
|
|
process.stdin.on("data", (chunk) => {
|
|
sendInput({ type: "ssh.input", data: Buffer.from(chunk).toString("base64"), encoding: "base64" });
|
|
});
|
|
process.stdin.on("end", () => {
|
|
if (open.stdinEotOnEnd === true) {
|
|
sendInput({ type: "ssh.input", data: Buffer.from([4]).toString("base64"), encoding: "base64" });
|
|
}
|
|
sendInput({ type: "ssh.eof" });
|
|
});
|
|
`;
|
|
}
|
|
|
|
function terminalSize(): { cols: number; rows: number } {
|
|
return {
|
|
cols: Number(process.stdout.columns) > 0 ? Number(process.stdout.columns) : 100,
|
|
rows: Number(process.stdout.rows) > 0 ? Number(process.stdout.rows) : 30,
|
|
};
|
|
}
|
|
|
|
export function remoteCommandForRoute(route: ParsedSshRoute, command: string[], options: { stdin?: boolean } = {}): string {
|
|
if (route.plane === "k3s") return buildK3sTargetCommand(route, command, options);
|
|
if (route.plane === "win") throw new Error(`ssh apply-patch does not support win routes yet: ${route.raw}`);
|
|
return shellArgv(command);
|
|
}
|
|
|
|
type WindowsApplyPatchFsOperation =
|
|
| "stat"
|
|
| "read-b64-block"
|
|
| "read-bulk-b64"
|
|
| "apply-replacements-bulk-stdin"
|
|
| "write-b64-stdin"
|
|
| "write-b64-begin"
|
|
| "write-b64-append-stdin"
|
|
| "write-b64-commit"
|
|
| "delete";
|
|
|
|
const windowsApplyPatchWriteB64ChunkChars = 12_000;
|
|
const posixApplyPatchWriteB64ChunkChars = 12_000;
|
|
|
|
type PosixApplyPatchFsOperation = Exclude<ApplyPatchV2RemoteOperation, "write-b64-argv">;
|
|
|
|
export function createPosixApplyPatchFileSystem(invocation: ParsedSshInvocation, runRemoteCommand: (command: string[], input?: string) => Promise<SshCaptureResult>): ApplyPatchV2FileSystem {
|
|
async function checked(operation: PosixApplyPatchFsOperation, args: string[], input?: string): Promise<SshCaptureResult> {
|
|
const startedAtMs = Date.now();
|
|
let result: SshCaptureResult;
|
|
try {
|
|
result = await runRemoteCommand(applyPatchV2RemoteCommand(operation, args), input);
|
|
} catch (error) {
|
|
throw new ApplyPatchV2Error(`posix apply-patch fs operation failed: ${operation}`, applyPatchFsFailureDetails({
|
|
operation,
|
|
args,
|
|
input,
|
|
invocation,
|
|
remoteElapsedMs: Math.max(0, Date.now() - startedAtMs),
|
|
cause: error instanceof Error ? { name: error.name, message: error.message } : { message: String(error) },
|
|
}));
|
|
}
|
|
if (result.exitCode === 0) return result;
|
|
throw new ApplyPatchV2Error(`posix apply-patch fs operation failed: ${operation}`, applyPatchFsFailureDetails({
|
|
operation,
|
|
args,
|
|
input,
|
|
invocation,
|
|
result,
|
|
remoteElapsedMs: Math.max(0, Date.now() - startedAtMs),
|
|
}));
|
|
}
|
|
|
|
return {
|
|
async stat(filePath) {
|
|
const result = await checked("stat", [filePath]);
|
|
const [bytesText, sha256] = result.stdout.trim().split(/\s+/u);
|
|
const bytes = Number(bytesText);
|
|
if (!Number.isSafeInteger(bytes) || bytes < 0 || !/^[0-9a-f]{64}$/u.test(sha256 ?? "")) {
|
|
throw new Error(`posix apply-patch fs stat returned invalid metadata: ${JSON.stringify({ filePath, stdout: result.stdout.slice(0, 500) })}`);
|
|
}
|
|
return { bytes, sha256: sha256! };
|
|
},
|
|
async readBlock(filePath, blockIndex, blockBytes) {
|
|
const result = await checked("read-b64-block", [filePath, String(blockIndex), String(blockBytes)]);
|
|
const encoded = result.stdout
|
|
.split(/\r?\n/u)
|
|
.filter((line) => !line.startsWith("UNIDESK_APPLY_PATCH_V2_BLOCK "))
|
|
.join("")
|
|
.replace(/\s+/gu, "");
|
|
return encoded.length === 0 ? Buffer.alloc(0) : Buffer.from(encoded, "base64");
|
|
},
|
|
async writeFile(filePath, content) {
|
|
const encoded = content.toString("base64");
|
|
const expectedBytes = String(content.length);
|
|
const expectedSha256 = createHash("sha256").update(content).digest("hex");
|
|
try {
|
|
await checked("write-b64-stdin", [filePath, expectedBytes, expectedSha256], encoded);
|
|
return;
|
|
} catch {
|
|
const token = `${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}-${expectedSha256.slice(0, 12)}`;
|
|
await checked("write-b64-begin", [filePath, token]);
|
|
for (const chunk of chunkString(encoded, posixApplyPatchWriteB64ChunkChars)) {
|
|
await checked("write-b64-append", [filePath, token, chunk]);
|
|
}
|
|
await checked("write-b64-commit", [filePath, token, expectedBytes, expectedSha256]);
|
|
}
|
|
},
|
|
async deleteFile(filePath) {
|
|
await checked("delete", [filePath]);
|
|
},
|
|
async readFiles(filePaths) {
|
|
const result = await checked("read-bulk-b64", filePaths);
|
|
return decodeApplyPatchV2BulkRead(result.stdout, filePaths);
|
|
},
|
|
async applyReplacementsBulk(filePaths, plans: Map<string, ApplyPatchV2BulkReplacementWritePlan>) {
|
|
const { targets, payload } = formatApplyPatchV2BulkReplacementPayload(filePaths, plans);
|
|
if (targets.length === 0) return;
|
|
await checked("apply-replacements-bulk-stdin", [String(targets.length)], payload);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createWindowsApplyPatchFileSystem(invocation: ParsedSshInvocation, runRemoteCommand: (remoteCommand: string, input?: string) => Promise<SshCaptureResult>): ApplyPatchV2FileSystem {
|
|
async function checked(operation: WindowsApplyPatchFsOperation, args: string[], input?: string): Promise<SshCaptureResult> {
|
|
const command = buildWindowsPowerShellInvocation(windowsApplyPatchFsScript(invocation.route.workspace, operation, args));
|
|
const startedAtMs = Date.now();
|
|
let result: SshCaptureResult;
|
|
try {
|
|
result = await runRemoteCommand(command, input);
|
|
} catch (error) {
|
|
throw new ApplyPatchV2Error(`windows apply-patch fs operation failed: ${operation}`, applyPatchFsFailureDetails({
|
|
operation,
|
|
args,
|
|
input,
|
|
invocation,
|
|
remoteElapsedMs: Math.max(0, Date.now() - startedAtMs),
|
|
cause: error instanceof Error ? { name: error.name, message: error.message } : { message: String(error) },
|
|
}));
|
|
}
|
|
if (result.exitCode === 0) return result;
|
|
throw new ApplyPatchV2Error(`windows apply-patch fs operation failed: ${operation}`, applyPatchFsFailureDetails({
|
|
operation,
|
|
args,
|
|
input,
|
|
invocation,
|
|
result,
|
|
remoteElapsedMs: Math.max(0, Date.now() - startedAtMs),
|
|
}));
|
|
}
|
|
|
|
return {
|
|
async stat(filePath) {
|
|
const result = await checked("stat", [filePath]);
|
|
const [bytesText, sha256] = result.stdout.trim().split(/\s+/u);
|
|
const bytes = Number(bytesText);
|
|
if (!Number.isSafeInteger(bytes) || bytes < 0 || !/^[0-9a-f]{64}$/u.test(sha256 ?? "")) {
|
|
throw new Error(`windows apply-patch fs stat returned invalid metadata: ${JSON.stringify({ filePath, stdout: result.stdout.slice(0, 500) })}`);
|
|
}
|
|
return { bytes, sha256: sha256! };
|
|
},
|
|
async readBlock(filePath, blockIndex, blockBytes) {
|
|
const result = await checked("read-b64-block", [filePath, String(blockIndex), String(blockBytes)]);
|
|
const encoded = result.stdout.replace(/\s+/gu, "");
|
|
return encoded.length === 0 ? Buffer.alloc(0) : Buffer.from(encoded, "base64");
|
|
},
|
|
async writeFile(filePath, content) {
|
|
const encoded = content.toString("base64");
|
|
const expectedBytes = String(content.length);
|
|
const expectedSha256 = createHash("sha256").update(content).digest("hex");
|
|
try {
|
|
await checked("write-b64-stdin", [filePath, expectedBytes, expectedSha256], encoded);
|
|
return;
|
|
} catch {
|
|
const token = `${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}-${expectedSha256.slice(0, 12)}`;
|
|
await checked("write-b64-begin", [filePath, token]);
|
|
for (const chunk of chunkString(encoded, windowsApplyPatchWriteB64ChunkChars)) {
|
|
await checked("write-b64-append-stdin", [filePath, token], chunk);
|
|
}
|
|
await checked("write-b64-commit", [filePath, token, expectedBytes, expectedSha256]);
|
|
}
|
|
},
|
|
async deleteFile(filePath) {
|
|
await checked("delete", [filePath]);
|
|
},
|
|
async readFiles(filePaths) {
|
|
const result = await checked("read-bulk-b64", [String(filePaths.length)], `${filePaths.join("\n")}\n`);
|
|
return decodeApplyPatchV2BulkRead(result.stdout, filePaths);
|
|
},
|
|
async applyReplacementsBulk(filePaths, plans: Map<string, ApplyPatchV2BulkReplacementWritePlan>) {
|
|
const { targets, payload } = formatApplyPatchV2BulkReplacementPayload(filePaths, plans);
|
|
if (targets.length === 0) return;
|
|
await checked("apply-replacements-bulk-stdin", [String(targets.length)], payload);
|
|
},
|
|
};
|
|
}
|
|
|
|
function applyPatchFsFailureDetails(options: {
|
|
operation: string;
|
|
args: string[];
|
|
input?: string;
|
|
invocation: ParsedSshInvocation;
|
|
result?: SshCaptureResult;
|
|
remoteElapsedMs: number;
|
|
cause?: Record<string, unknown>;
|
|
}): Record<string, unknown> {
|
|
const { operation, args, input, invocation, result } = options;
|
|
const inputBytes = input === undefined ? 0 : Buffer.byteLength(input, "utf8");
|
|
const expected = applyPatchFsExpectedWrite(operation, args);
|
|
const targetCount = applyPatchFsBulkTargetCount(operation, args);
|
|
return {
|
|
operation,
|
|
route: invocation.route.raw,
|
|
...(operation === "read-bulk-b64" || operation === "apply-replacements-bulk-stdin" ? {} : { filePath: args[0] ?? "" }),
|
|
...(targetCount === null ? {} : { targetCount }),
|
|
args: args.slice(0, 4),
|
|
inputBytes,
|
|
...(inputBytes === 0 ? {} : { inputSha256: createHash("sha256").update(input, "utf8").digest("hex") }),
|
|
...(expected.expectedBytes === undefined ? {} : { expectedBytes: expected.expectedBytes }),
|
|
...(expected.expectedSha256 === undefined ? {} : { expectedSha256: expected.expectedSha256 }),
|
|
remoteElapsedMs: options.remoteElapsedMs,
|
|
landed: false,
|
|
...(result === undefined ? {} : {
|
|
exitCode: result.exitCode,
|
|
stdoutTail: result.stdout.slice(-2000),
|
|
stderrTail: result.stderr.slice(-4000),
|
|
}),
|
|
...(options.cause === undefined ? {} : { cause: options.cause }),
|
|
};
|
|
}
|
|
|
|
function applyPatchFsExpectedWrite(operation: string, args: string[]): { expectedBytes?: string; expectedSha256?: string } {
|
|
if (operation === "write-b64-stdin") return { expectedBytes: args[1], expectedSha256: args[2] };
|
|
if (operation === "write-b64-commit") return { expectedBytes: args[2], expectedSha256: args[3] };
|
|
return {};
|
|
}
|
|
|
|
function applyPatchFsBulkTargetCount(operation: string, args: string[]): number | null {
|
|
if (operation === "read-bulk-b64") {
|
|
const count = Number(args[0]);
|
|
return Number.isSafeInteger(count) && count >= 0 ? count : args.length;
|
|
}
|
|
if (operation !== "apply-replacements-bulk-stdin") return null;
|
|
const count = Number(args[0]);
|
|
return Number.isSafeInteger(count) && count >= 0 ? count : null;
|
|
}
|
|
|
|
function windowsApplyPatchFsScript(basePath: string | null, operation: WindowsApplyPatchFsOperation, args: string[]): string {
|
|
const target = args[0] ?? "";
|
|
const arg1 = args[1] ?? "";
|
|
const arg2 = args[2] ?? "";
|
|
const arg3 = args[3] ?? "";
|
|
const scriptParts = [
|
|
"$ErrorActionPreference = 'Stop';",
|
|
"$ProgressPreference = 'SilentlyContinue';",
|
|
"[Console]::InputEncoding = [System.Text.UTF8Encoding]::new();",
|
|
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new();",
|
|
"$OutputEncoding = [System.Text.UTF8Encoding]::new();",
|
|
`$basePath = ${powerShellSingleQuote(basePath ?? "")};`,
|
|
`$operation = ${powerShellSingleQuote(operation)};`,
|
|
`$targetArg = ${powerShellSingleQuote(target)};`,
|
|
`$arg1 = ${powerShellSingleQuote(arg1)};`,
|
|
`$arg2 = ${powerShellSingleQuote(arg2)};`,
|
|
`$arg3 = ${powerShellSingleQuote(arg3)};`,
|
|
"function Fail([string]$Message, [int]$Code) { [Console]::Error.WriteLine($Message); exit $Code }",
|
|
"function Resolve-UnideskPath([string]$Raw) { if ([string]::IsNullOrWhiteSpace($Raw)) { Fail 'empty apply-patch path' 2 }; if ([System.IO.Path]::IsPathRooted($Raw)) { return [System.IO.Path]::GetFullPath($Raw) }; if (-not [string]::IsNullOrWhiteSpace($basePath)) { return [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($basePath, $Raw)) }; return [System.IO.Path]::GetFullPath($Raw) }",
|
|
"function Ensure-Parent([string]$Target) { $parent = [System.IO.Path]::GetDirectoryName($Target); if (-not [string]::IsNullOrWhiteSpace($parent)) { [System.IO.Directory]::CreateDirectory($parent) | Out-Null } }",
|
|
"function Get-Sha256([string]$Path) { return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() }",
|
|
"function Get-Sha256Bytes([byte[]]$Bytes) { $sha = [System.Security.Cryptography.SHA256]::Create(); try { return ([System.BitConverter]::ToString($sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() } }",
|
|
"function Decode-Utf8([string]$Encoded) { return [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Encoded)) }",
|
|
"function Encode-Utf8([string]$Value) { return [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Value)) }",
|
|
"function Set-TmpPaths([string]$Target, [string]$Token) { if ($Token -notmatch '^[A-Za-z0-9_.-]+$') { Fail 'invalid apply-patch temp token' 2 }; $dir = [System.IO.Path]::GetDirectoryName($Target); if ([string]::IsNullOrWhiteSpace($dir)) { $dir = (Get-Location).ProviderPath }; $base = [System.IO.Path]::GetFileName($Target); $script:tmp = [System.IO.Path]::Combine($dir, '.' + $base + '.unidesk-v2-' + $Token + '.tmp'); $script:tmpB64 = $script:tmp + '.b64' }",
|
|
"function Verify-Temp([string]$Target, [string]$Tmp, [Int64]$ExpectedBytes, [string]$ExpectedSha256) { $actualBytes = ([System.IO.FileInfo]$Tmp).Length; if ($actualBytes -ne $ExpectedBytes) { Remove-Item -LiteralPath $Tmp -Force -ErrorAction SilentlyContinue; Fail ('apply-patch byte count mismatch for ' + $Target + ': expected=' + $ExpectedBytes + ' actual=' + $actualBytes) 23 }; $actualSha256 = Get-Sha256 $Tmp; if ($actualSha256 -ne $ExpectedSha256) { Remove-Item -LiteralPath $Tmp -Force -ErrorAction SilentlyContinue; Fail ('apply-patch sha256 mismatch for ' + $Target + ': expected=' + $ExpectedSha256 + ' actual=' + $actualSha256) 24 } }",
|
|
"function Decode-ToTarget([string]$Target, [string]$Encoded, [Int64]$ExpectedBytes, [string]$ExpectedSha256) { Ensure-Parent $Target; Set-TmpPaths $Target ([guid]::NewGuid().ToString('N')); try { $bytes = [Convert]::FromBase64String(($Encoded -replace '\\s','')) } catch { Fail ('apply-patch base64 decode failed for ' + $Target + ': ' + $_.Exception.Message) 22 }; [System.IO.File]::WriteAllBytes($script:tmp, $bytes); Verify-Temp $Target $script:tmp $ExpectedBytes $ExpectedSha256; Move-Item -LiteralPath $script:tmp -Destination $Target -Force; $actualSha256 = Get-Sha256 $Target; if ($actualSha256 -ne $ExpectedSha256) { Fail ('apply-patch final sha256 mismatch for ' + $Target) 25 } }",
|
|
"$target = Resolve-UnideskPath $targetArg;",
|
|
"switch ($operation) {",
|
|
" 'stat' { if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { Fail ('file not found: ' + $target) 1 }; $bytes = ([System.IO.FileInfo]$target).Length; $digest = Get-Sha256 $target; [Console]::Out.WriteLine(([string]$bytes) + ' ' + $digest); break }",
|
|
" 'read-b64-block' { $blockIndex = [Int64]$arg1; $blockSize = [Int32]$arg2; if ($blockIndex -lt 0 -or $blockSize -le 0) { Fail 'invalid read block args' 2 }; $fs = [System.IO.File]::Open($target, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite); try { [void]$fs.Seek($blockIndex * $blockSize, [System.IO.SeekOrigin]::Begin); $buffer = New-Object byte[] $blockSize; $read = $fs.Read($buffer, 0, $blockSize); if ($read -gt 0) { [Console]::Out.Write([Convert]::ToBase64String($buffer, 0, $read)) } } finally { $fs.Dispose() }; break }",
|
|
" 'read-bulk-b64' { $expectedCount = [Int32]$targetArg; $items = @([Console]::In.ReadToEnd() -split \"`r?`n\" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }); if ($items.Count -ne $expectedCount) { Fail ('bulk read record count mismatch: expected=' + $expectedCount + ' actual=' + $items.Count) 23 }; [Console]::Out.WriteLine('UNIDESK_APPLY_PATCH_V2_BULK_READ ' + $items.Count); foreach ($item in $items) { $path = Resolve-UnideskPath $item; if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { Fail ('file not found: ' + $path) 1 }; $bytes = [System.IO.File]::ReadAllBytes($path); $pathB64 = Encode-Utf8 $item; $digest = Get-Sha256Bytes $bytes; [Console]::Out.Write($pathB64 + ' ' + $bytes.Length + ' ' + $digest + ' '); [Console]::Out.Write([System.Convert]::ToBase64String($bytes)); [Console]::Out.WriteLine() }; break }",
|
|
" 'apply-replacements-bulk-stdin' { $expectedCount = [Int32]$targetArg; $records = @([Console]::In.ReadToEnd() -split \"`r?`n\" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }); if ($records.Count -ne $expectedCount) { Fail ('bulk replacement record count mismatch: expected=' + $expectedCount + ' actual=' + $records.Count) 23 }; $utf8 = [System.Text.UTF8Encoding]::new($false); $plans = New-Object System.Collections.Generic.List[object]; $index = 0; foreach ($record in $records) { $index += 1; $fields = $record -split '\\s+'; if ($fields.Count -ne 6) { Fail 'bulk replacement malformed record' 23 }; $targetRelative = Decode-Utf8 $fields[0]; $resolved = Resolve-UnideskPath $targetRelative; $originalBytes = [Int64]$fields[1]; $originalSha256 = $fields[2]; $finalBytes = [Int64]$fields[3]; $finalSha256 = $fields[4]; $replacementsText = $fields[5]; if (-not (Test-Path -LiteralPath $resolved -PathType Leaf)) { Fail ('file not found: ' + $resolved) 1 }; $source = [System.IO.File]::ReadAllBytes($resolved); if ($source.Length -ne $originalBytes -or (Get-Sha256Bytes $source) -ne $originalSha256) { Fail ('bulk replacement original integrity mismatch for ' + $resolved) 23 }; $lines = New-Object System.Collections.Generic.List[string]; $sourceText = [System.Text.Encoding]::UTF8.GetString($source); foreach ($line in ($sourceText -split \"`n\", -1)) { $lines.Add($line) | Out-Null }; if ($lines.Count -gt 0 -and $lines[$lines.Count - 1] -eq '') { $lines.RemoveAt($lines.Count - 1) }; $replacements = New-Object System.Collections.Generic.List[object]; if (-not [string]::IsNullOrEmpty($replacementsText)) { foreach ($item in ($replacementsText -split ';')) { if ([string]::IsNullOrWhiteSpace($item)) { continue }; $parts = $item -split ',', 3; if ($parts.Count -ne 3) { Fail 'bulk replacement malformed replacement' 23 }; $newText = Decode-Utf8 $parts[2]; $newLines = New-Object System.Collections.Generic.List[string]; foreach ($line in ($newText -split \"`n\", -1)) { $newLines.Add($line) | Out-Null }; if ($newLines.Count -gt 0 -and $newLines[$newLines.Count - 1] -eq '') { $newLines.RemoveAt($newLines.Count - 1) }; $replacements.Add([pscustomobject]@{ start = [Int32]$parts[0]; oldLength = [Int32]$parts[1]; newLines = [string[]]$newLines }) | Out-Null } }; foreach ($replacement in @($replacements | Sort-Object -Property start -Descending)) { if ($replacement.start -lt 0 -or $replacement.oldLength -lt 0 -or ($replacement.start + $replacement.oldLength) -gt $lines.Count) { Fail ('bulk replacement out of bounds for ' + $resolved) 23 }; $removeCount = $replacement.oldLength; if ($removeCount -gt 0) { $lines.RemoveRange($replacement.start, $removeCount) }; if ($replacement.newLines.Count -gt 0) { $lines.InsertRange($replacement.start, [string[]]$replacement.newLines) } }; $outputText = if ($lines.Count -eq 0) { '' } else { ([string]::Join(\"`n\", [string[]]$lines) + \"`n\") }; $outputBytes = [System.Text.Encoding]::UTF8.GetBytes($outputText); if ($outputBytes.Length -ne $finalBytes -or (Get-Sha256Bytes $outputBytes) -ne $finalSha256) { Fail ('bulk replacement final integrity mismatch for ' + $resolved) 23 }; $tmp = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($resolved), ('.' + [System.IO.Path]::GetFileName($resolved) + '.unidesk-v2-bulk-' + [guid]::NewGuid().ToString('N') + '.tmp')); [System.IO.File]::WriteAllText($tmp, $outputText, $utf8); $plans.Add([pscustomobject]@{ target = $resolved; tmp = $tmp; sha256 = $finalSha256 }) | Out-Null }; foreach ($plan in $plans) { Ensure-Parent $plan.target; Move-Item -LiteralPath $plan.tmp -Destination $plan.target -Force; if ((Get-Sha256 $plan.target) -ne $plan.sha256) { Fail ('bulk replacement final sha256 mismatch for ' + $plan.target) 25 } }; break }",
|
|
" 'write-b64-stdin' { Decode-ToTarget $target ([Console]::In.ReadToEnd()) ([Int64]$arg1) $arg2; break }",
|
|
" 'write-b64-begin' { Ensure-Parent $target; Set-TmpPaths $target $arg1; [System.IO.File]::WriteAllText($script:tmpB64, '', [System.Text.Encoding]::ASCII); break }",
|
|
" 'write-b64-append-stdin' { Set-TmpPaths $target $arg1; $chunk = ([Console]::In.ReadToEnd()) -replace '\\s',''; [System.IO.File]::AppendAllText($script:tmpB64, $chunk, [System.Text.Encoding]::ASCII); break }",
|
|
" 'write-b64-commit' { Set-TmpPaths $target $arg1; $encoded = [System.IO.File]::ReadAllText($script:tmpB64, [System.Text.Encoding]::ASCII); Remove-Item -LiteralPath $script:tmpB64 -Force -ErrorAction SilentlyContinue; Decode-ToTarget $target $encoded ([Int64]$arg2) $arg3; break }",
|
|
" 'delete' { Remove-Item -LiteralPath $target -Force -ErrorAction SilentlyContinue; break }",
|
|
" default { Fail ('unsupported apply-patch fs op: ' + $operation) 2 }",
|
|
"}",
|
|
];
|
|
return scriptParts
|
|
.map((line) => line.startsWith(" 'apply-replacements-bulk-stdin' ") ? windowsBulkReplacementScript() : line)
|
|
.join(" ");
|
|
}
|
|
|
|
function windowsBulkReplacementScript(): string {
|
|
return [
|
|
" 'apply-replacements-bulk-stdin' {",
|
|
" $expectedCount = [Int32]$targetArg;",
|
|
" $records = @([Console]::In.ReadToEnd() -split \"`r?`n\" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) });",
|
|
" if ($records.Count -ne $expectedCount) { Fail ('bulk replacement record count mismatch: expected=' + $expectedCount + ' actual=' + $records.Count) 23 };",
|
|
" $plans = New-Object System.Collections.Generic.List[object];",
|
|
" foreach ($record in $records) {",
|
|
" $fields = $record -split '\\s+'; if ($fields.Count -ne 7) { Fail 'bulk replacement malformed record' 23 };",
|
|
" $targetRelative = Decode-Utf8 $fields[0]; $resolved = Resolve-UnideskPath $targetRelative;",
|
|
" $originalBytes = [Int64]$fields[1]; $originalSha256 = $fields[2]; $finalBytes = [Int64]$fields[3]; $finalSha256 = $fields[4]; $encodingStyle = $fields[5]; $replacementsText = $fields[6];",
|
|
" if (-not (Test-Path -LiteralPath $resolved -PathType Leaf)) { Fail ('file not found: ' + $resolved) 1 };",
|
|
" $source = [System.IO.File]::ReadAllBytes($resolved); $actualOriginalBytes = [Int64]$source.Length; $actualOriginalSha256 = Get-Sha256Bytes $source;",
|
|
" if ($actualOriginalBytes -ne $originalBytes) { Fail ('bulk replacement original integrity mismatch for ' + $resolved) 23 };",
|
|
" if (-not [string]::Equals($actualOriginalSha256, $originalSha256, [StringComparison]::OrdinalIgnoreCase)) { Fail ('bulk replacement original integrity mismatch for ' + $resolved) 23 };",
|
|
" $textEncoding = if ($encodingStyle -eq 'legacy-single-byte') { [System.Text.Encoding]::GetEncoding(28591) } else { [System.Text.UTF8Encoding]::new($false, $true) };",
|
|
" try { $sourceText = $textEncoding.GetString($source) } catch { Fail ('bulk replacement source decode failed encoding=' + $encodingStyle + ' for ' + $resolved) 25 };",
|
|
" $lines = New-Object System.Collections.Generic.List[string]; foreach ($line in ($sourceText -split \"`n\", -1)) { $lines.Add($line) | Out-Null }; if ($lines.Count -gt 0 -and $lines[$lines.Count - 1] -eq '') { $lines.RemoveAt($lines.Count - 1) };",
|
|
" $replacements = New-Object System.Collections.Generic.List[object];",
|
|
" if (-not [string]::IsNullOrEmpty($replacementsText)) { foreach ($item in ($replacementsText -split ';')) { if ([string]::IsNullOrWhiteSpace($item)) { continue }; $parts = $item -split ',', 3; if ($parts.Count -ne 3) { Fail 'bulk replacement malformed replacement' 23 }; $newText = Decode-Utf8 $parts[2]; $newLines = New-Object System.Collections.Generic.List[string]; foreach ($line in ($newText -split \"`n\", -1)) { $newLines.Add($line) | Out-Null }; if ($newLines.Count -gt 0 -and $newLines[$newLines.Count - 1] -eq '') { $newLines.RemoveAt($newLines.Count - 1) }; $replacements.Add([pscustomobject]@{ start = [Int32]$parts[0]; oldLength = [Int32]$parts[1]; newLines = [string[]]$newLines }) | Out-Null } };",
|
|
" foreach ($replacement in @($replacements | Sort-Object -Property start -Descending)) { if ($replacement.start -lt 0 -or $replacement.oldLength -lt 0 -or ($replacement.start + $replacement.oldLength) -gt $lines.Count) { Fail ('bulk replacement out of bounds for ' + $resolved) 23 }; if ($replacement.oldLength -gt 0) { $lines.RemoveRange($replacement.start, $replacement.oldLength) }; if ($replacement.newLines.Count -gt 0) { $lines.InsertRange($replacement.start, [string[]]$replacement.newLines) } };",
|
|
" $outputText = if ($lines.Count -eq 0) { '' } else { ([string]::Join(\"`n\", [string[]]$lines) + \"`n\") };",
|
|
" try { $outputBytes = $textEncoding.GetBytes($outputText) } catch { Fail ('bulk replacement final encode failed encoding=' + $encodingStyle + ' for ' + $resolved) 25 };",
|
|
" if ($outputBytes.Length -ne $finalBytes -or (Get-Sha256Bytes $outputBytes) -ne $finalSha256) { Fail ('bulk replacement final integrity mismatch for ' + $resolved) 23 };",
|
|
" $tmp = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($resolved), ('.' + [System.IO.Path]::GetFileName($resolved) + '.unidesk-v2-bulk-' + [guid]::NewGuid().ToString('N') + '.tmp')); [System.IO.File]::WriteAllBytes($tmp, $outputBytes); $plans.Add([pscustomobject]@{ target = $resolved; tmp = $tmp; sha256 = $finalSha256 }) | Out-Null;",
|
|
" };",
|
|
" foreach ($plan in $plans) { Ensure-Parent $plan.target; Move-Item -LiteralPath $plan.tmp -Destination $plan.target -Force; if ((Get-Sha256 $plan.target) -ne $plan.sha256) { Fail ('bulk replacement final sha256 mismatch for ' + $plan.target) 25 } };",
|
|
" break",
|
|
" }",
|
|
].join(" ");
|
|
}
|
|
|
|
function chunkString(value: string, chunkSize: number): string[] {
|
|
const chunks: string[] = [];
|
|
for (let index = 0; index < value.length; index += chunkSize) {
|
|
chunks.push(value.slice(index, index + chunkSize));
|
|
}
|
|
return chunks.length > 0 ? chunks : [""];
|
|
}
|
|
|
|
async function runSshCaptureCommand(config: UniDeskConfig, invocation: ParsedSshInvocation, command: string[], input?: string): Promise<SshCaptureResult> {
|
|
const remoteCommand = remoteCommandForRoute(invocation.route, command, { stdin: input !== undefined });
|
|
return await runSshCaptureRemoteCommand(config, invocation, remoteCommand, input);
|
|
}
|
|
|
|
async function runSshCaptureRemoteCommand(config: UniDeskConfig, invocation: ParsedSshInvocation, remoteCommand: string, input?: string): Promise<SshCaptureResult> {
|
|
const startedAtMs = Date.now();
|
|
const size = terminalSize();
|
|
const runtimeTimeoutMs = sshRuntimeTimeoutMs();
|
|
const payload = {
|
|
providerId: invocation.providerId,
|
|
command: wrapSshRemoteCommand(remoteCommand),
|
|
cwd: sshRoutePayloadCwd(invocation.route),
|
|
tty: false,
|
|
stdinEotOnEnd: false,
|
|
openTimeoutMs: Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000)),
|
|
runtimeTimeoutMs,
|
|
cols: size.cols,
|
|
rows: size.rows,
|
|
};
|
|
const payloadJson = JSON.stringify(payload);
|
|
const encodedBrokerSource = Buffer.from(brokerSource(), "utf8").toString("base64");
|
|
const script = [
|
|
"set -eu",
|
|
`payload=${shellQuote(payloadJson)}`,
|
|
"if command -v backend-core >/dev/null 2>&1; then",
|
|
' exec backend-core --ssh-broker "$payload"',
|
|
"fi",
|
|
`export UNIDESK_SSH_BROKER_URL=${shellQuote("ws://127.0.0.1:8080/ws/ssh")}`,
|
|
'broker_js="$(mktemp "${TMPDIR:-/tmp}/unidesk-ssh-broker.XXXXXX")"',
|
|
'trap \'rm -f "$broker_js"\' EXIT',
|
|
`printf %s ${shellQuote(encodedBrokerSource)} | base64 -d >"$broker_js"`,
|
|
'bun "$broker_js" "$payload"',
|
|
].join("\n");
|
|
const child = spawn("docker", ["exec", "-i", "unidesk-backend-core", "sh", "-c", script], {
|
|
cwd: repoRoot,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
if (input !== undefined) {
|
|
writeChunkedStdin(child.stdin, input);
|
|
} else {
|
|
child.stdin.end();
|
|
}
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
stdout += chunk.toString("utf8");
|
|
});
|
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
stderr += chunk.toString("utf8");
|
|
});
|
|
return await new Promise<SshCaptureResult>((resolve) => {
|
|
let settled = false;
|
|
let killTimer: NodeJS.Timeout | null = null;
|
|
const finish = (exitCode: number): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(runtimeTimer);
|
|
if (killTimer !== null) clearTimeout(killTimer);
|
|
const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({
|
|
invocation,
|
|
transport: "backend-core-broker",
|
|
exitCode,
|
|
startedAtMs,
|
|
}));
|
|
if (timingHint) stderr += timingHint;
|
|
stderr += formatSshTcpPoolHint(sshTcpPoolHint({
|
|
invocation,
|
|
transport: "backend-core-broker",
|
|
exitCode,
|
|
stderrText: stderr,
|
|
}));
|
|
stderr += sshWindowsPlaneHint(invocation, exitCode, stderr);
|
|
stderr += sshWindowsPowerShellPipelineHint(invocation, exitCode, stderr);
|
|
resolve({ exitCode, stdout, stderr });
|
|
};
|
|
const runtimeTimer = setTimeout(() => {
|
|
const hint = formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({
|
|
invocation,
|
|
transport: "backend-core-broker",
|
|
timeoutMs: runtimeTimeoutMs,
|
|
}));
|
|
stderr += hint;
|
|
try {
|
|
child.kill("SIGTERM");
|
|
} catch {
|
|
// Ignore.
|
|
}
|
|
killTimer = setTimeout(() => {
|
|
try {
|
|
child.kill("SIGKILL");
|
|
} catch {
|
|
// Ignore.
|
|
}
|
|
}, 2000);
|
|
finish(124);
|
|
}, runtimeTimeoutMs);
|
|
child.on("error", (error) => {
|
|
stderr += `unidesk ssh failed to start broker: ${error.message}\n`;
|
|
finish(255);
|
|
});
|
|
child.on("close", (code) => finish(code ?? 255));
|
|
});
|
|
}
|
|
|
|
async function runSshStreamRemoteCommand(
|
|
config: UniDeskConfig,
|
|
invocation: ParsedSshInvocation,
|
|
remoteCommand: string,
|
|
handlers: SshRemoteCommandStreamHandlers,
|
|
input?: string,
|
|
options: { inactivityTimeoutMs?: number } = {},
|
|
): Promise<SshCaptureResult> {
|
|
const streamInvocation = {
|
|
...invocation,
|
|
parsed: { ...invocation.parsed, remoteCommand, requiresStdin: input !== undefined, invocationKind: "helper" as const },
|
|
};
|
|
const startedAtMs = Date.now();
|
|
const size = terminalSize();
|
|
const inactivityTimeoutMs = options.inactivityTimeoutMs ?? sshRuntimeTimeoutMs();
|
|
const runtimeTimeoutMs = options.inactivityTimeoutMs === undefined ? inactivityTimeoutMs : Math.max(inactivityTimeoutMs * 4, 60 * 60_000);
|
|
const payload = {
|
|
providerId: invocation.providerId,
|
|
command: wrapSshRemoteCommand(remoteCommand),
|
|
cwd: sshRoutePayloadCwd(invocation.route),
|
|
tty: false,
|
|
stdinEotOnEnd: false,
|
|
openTimeoutMs: Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000)),
|
|
runtimeTimeoutMs,
|
|
runtimeTimeoutMode: options.inactivityTimeoutMs === undefined ? "wall-clock" : "inactivity",
|
|
cols: size.cols,
|
|
rows: size.rows,
|
|
};
|
|
const payloadJson = JSON.stringify(payload);
|
|
const encodedBrokerSource = Buffer.from(brokerSource(), "utf8").toString("base64");
|
|
const script = [
|
|
"set -eu",
|
|
`payload=${shellQuote(payloadJson)}`,
|
|
"if command -v backend-core >/dev/null 2>&1; then",
|
|
' exec backend-core --ssh-broker "$payload"',
|
|
"fi",
|
|
`export UNIDESK_SSH_BROKER_URL=${shellQuote("ws://127.0.0.1:8080/ws/ssh")}`,
|
|
'broker_js="$(mktemp "${TMPDIR:-/tmp}/unidesk-ssh-broker.XXXXXX")"',
|
|
'trap \'rm -f "$broker_js"\' EXIT',
|
|
`printf %s ${shellQuote(encodedBrokerSource)} | base64 -d >"$broker_js"`,
|
|
'bun "$broker_js" "$payload"',
|
|
].join("\n");
|
|
const child = spawn("docker", ["exec", "-i", "unidesk-backend-core", "sh", "-c", script], {
|
|
cwd: repoRoot,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
if (input !== undefined) {
|
|
writeChunkedStdin(child.stdin, input);
|
|
} else {
|
|
child.stdin.end();
|
|
}
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let streamError: unknown = null;
|
|
let streamWrites = Promise.resolve();
|
|
const queueStreamWrite = (chunk: Buffer, stream: "stdout" | "stderr"): void => {
|
|
streamWrites = streamWrites.then(async () => {
|
|
if (stream === "stdout") await handlers.onStdout(chunk);
|
|
else if (handlers.onStderr !== undefined) await handlers.onStderr(chunk);
|
|
}).catch((error) => {
|
|
streamError = error;
|
|
stderr += `unidesk ssh stream sink failed: ${error instanceof Error ? error.message : String(error)}\n`;
|
|
try {
|
|
child.kill("SIGTERM");
|
|
} catch {
|
|
// Ignore kill failures after a local stream sink error.
|
|
}
|
|
});
|
|
};
|
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
queueStreamWrite(chunk, "stdout");
|
|
});
|
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
stderr += chunk.toString("utf8");
|
|
queueStreamWrite(chunk, "stderr");
|
|
});
|
|
return await new Promise<SshCaptureResult>((resolve) => {
|
|
let settled = false;
|
|
let killTimer: NodeJS.Timeout | null = null;
|
|
let inactivityTimer: NodeJS.Timeout | null = null;
|
|
const refreshActivityTimer = (): void => {
|
|
if (settled || options.inactivityTimeoutMs === undefined) return;
|
|
if (inactivityTimer !== null) clearTimeout(inactivityTimer);
|
|
inactivityTimer = setTimeout(() => {
|
|
const hint = formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({
|
|
invocation: streamInvocation,
|
|
transport: "backend-core-broker",
|
|
timeoutMs: inactivityTimeoutMs,
|
|
}));
|
|
stderr += hint;
|
|
try {
|
|
child.kill("SIGTERM");
|
|
} catch {
|
|
// Ignore.
|
|
}
|
|
killTimer = setTimeout(() => {
|
|
try {
|
|
child.kill("SIGKILL");
|
|
} catch {
|
|
// Ignore.
|
|
}
|
|
}, 2000);
|
|
finish(124);
|
|
}, inactivityTimeoutMs);
|
|
};
|
|
const finish = (exitCode: number): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(runtimeTimer);
|
|
if (inactivityTimer !== null) clearTimeout(inactivityTimer);
|
|
if (killTimer !== null) clearTimeout(killTimer);
|
|
void streamWrites.then(() => {
|
|
const finalCode = streamError === null ? exitCode : 255;
|
|
const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({
|
|
invocation: streamInvocation,
|
|
transport: "backend-core-broker",
|
|
exitCode: finalCode,
|
|
startedAtMs,
|
|
}));
|
|
if (timingHint) stderr += timingHint;
|
|
stderr += formatSshTcpPoolHint(sshTcpPoolHint({
|
|
invocation: streamInvocation,
|
|
transport: "backend-core-broker",
|
|
exitCode: finalCode,
|
|
stderrText: stderr,
|
|
}));
|
|
resolve({ exitCode: finalCode, stdout, stderr });
|
|
});
|
|
};
|
|
const runtimeTimer = setTimeout(() => {
|
|
const hint = formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({
|
|
invocation: streamInvocation,
|
|
transport: "backend-core-broker",
|
|
timeoutMs: runtimeTimeoutMs,
|
|
}));
|
|
stderr += hint;
|
|
try {
|
|
child.kill("SIGTERM");
|
|
} catch {
|
|
// Ignore.
|
|
}
|
|
killTimer = setTimeout(() => {
|
|
try {
|
|
child.kill("SIGKILL");
|
|
} catch {
|
|
// Ignore.
|
|
}
|
|
}, 2000);
|
|
finish(124);
|
|
}, runtimeTimeoutMs);
|
|
refreshActivityTimer();
|
|
child.on("error", (error) => {
|
|
stderr += `unidesk ssh failed to start broker: ${error.message}\n`;
|
|
finish(255);
|
|
});
|
|
child.on("close", (code) => finish(code ?? 255));
|
|
child.stdout.on("data", refreshActivityTimer);
|
|
child.stderr.on("data", refreshActivityTimer);
|
|
});
|
|
}
|
|
|
|
async function runRemoteSsh(config: UniDeskConfig, host: string, providerId: string, args: string[]): Promise<number> {
|
|
const { runRemoteSshCli } = await import("./remote-ssh");
|
|
return await runRemoteSshCli({
|
|
host,
|
|
user: "root",
|
|
port: 22,
|
|
projectRoot: repoRoot,
|
|
identityFile: null,
|
|
transport: "frontend",
|
|
args: ["ssh", providerId, ...args],
|
|
}, config);
|
|
}
|
|
|
|
export async function runSshCommandCapture(config: UniDeskConfig, target: string, args: string[], input?: string): Promise<SshCaptureResult> {
|
|
const invocation = parseSshInvocation(target, args);
|
|
const parsed = invocation.parsed;
|
|
if (parsed.remoteCommand === null) throw new Error(`ssh ${target} capture requires a non-interactive operation`);
|
|
const stdin = parsed.stdinPrefix !== undefined || parsed.stdinSuffix !== undefined
|
|
? `${parsed.stdinPrefix ?? ""}${input ?? ""}${parsed.stdinSuffix ?? ""}`
|
|
: input;
|
|
const plan = sshCaptureBackendPlan(config, process.env);
|
|
if (plan.backend === "remote-frontend-websocket" && plan.remoteHost !== null) {
|
|
return await runRemoteSshCapture(config, plan.remoteHost, target, args, stdin);
|
|
}
|
|
const local = await runSshCaptureRemoteCommand(config, invocation, parsed.remoteCommand, stdin);
|
|
const fallbackHost = sshCaptureRemoteHost(config, process.env);
|
|
if (local.exitCode !== 0 && fallbackHost !== null && isLocalSshCaptureBackendUnavailable(local)) {
|
|
return await runRemoteSshCapture(config, fallbackHost, target, args, stdin);
|
|
}
|
|
return local;
|
|
}
|
|
|
|
async function runRemoteSshCapture(config: UniDeskConfig, host: string, target: string, args: string[], input?: string): Promise<SshCaptureResult> {
|
|
const { runRemoteSshCommandCapture } = await import("./remote-ssh");
|
|
return await runRemoteSshCommandCapture(config, host, target, args, input);
|
|
}
|
|
|
|
export function sshCaptureBackendPlan(config: UniDeskConfig, env: NodeJS.ProcessEnv = process.env): SshCaptureBackendPlan {
|
|
const configuredBackend = readTransSshBackendConfig(env);
|
|
const remoteHost = sshCaptureRemoteHost(config, env);
|
|
if (configuredBackend !== null) return configuredSshCaptureBackendPlan(configuredBackend, remoteHost);
|
|
const localBackendCore = detectLocalBackendCoreStatus(env);
|
|
const runnerEnv = isRunnerEnvironment(env);
|
|
if (runnerEnv && remoteHost !== null) return { backend: "remote-frontend-websocket", remoteHost, reason: "runner-environment", localBackendCore };
|
|
if (!localBackendCore.backendCoreContainer && remoteHost !== null) return { backend: "remote-frontend-websocket", remoteHost, reason: "local-backend-core-unavailable", localBackendCore };
|
|
return { backend: "local-backend-core-broker", remoteHost: null, reason: "main-server-local-backend-core", localBackendCore };
|
|
}
|
|
|
|
function configuredSshCaptureBackendPlan(configuredBackend: TransSshBackendConfig, remoteHost: string | null): SshCaptureBackendPlan {
|
|
if (configuredBackend.backend === "local-backend-core-broker") {
|
|
return {
|
|
backend: "local-backend-core-broker",
|
|
remoteHost: null,
|
|
reason: `configured:${configuredBackend.sourceRef}`,
|
|
localBackendCore: {
|
|
dockerExecutable: true,
|
|
backendCoreContainer: true,
|
|
error: null,
|
|
source: configuredBackend.sourceRef,
|
|
},
|
|
};
|
|
}
|
|
if (remoteHost === null) {
|
|
throw new Error(`${configuredBackend.sourceRef} selects ${configuredBackend.raw}, but no remote host is configured for frontend websocket SSH`);
|
|
}
|
|
return {
|
|
backend: "remote-frontend-websocket",
|
|
remoteHost,
|
|
reason: `configured:${configuredBackend.sourceRef}`,
|
|
localBackendCore: {
|
|
dockerExecutable: false,
|
|
backendCoreContainer: false,
|
|
error: null,
|
|
source: configuredBackend.sourceRef,
|
|
},
|
|
};
|
|
}
|
|
|
|
function sshCaptureRemoteHost(config: UniDeskConfig, env: NodeJS.ProcessEnv): string | null {
|
|
return normalizeRemoteHost(env.UNIDESK_MAIN_SERVER_IP)
|
|
?? normalizeRemoteHost(env.UNIDESK_MAIN_SERVER_HOST)
|
|
?? normalizeRemoteHost(env.CODE_QUEUE_DEV_CONTAINER_MASTER_HOST)
|
|
?? normalizeRemoteHost(readHostK8sPublicHost() ?? undefined)
|
|
?? normalizeRemoteHost(config.network.publicHost);
|
|
}
|
|
|
|
function normalizeRemoteHost(raw: string | undefined): string | null {
|
|
const value = raw?.trim() ?? "";
|
|
if (value.length === 0 || value === "localhost" || value === "127.0.0.1" || value === "::1") return null;
|
|
return value.replace(/\/+$/u, "");
|
|
}
|
|
|
|
function isRunnerEnvironment(env: NodeJS.ProcessEnv): boolean {
|
|
return Boolean(
|
|
env.AGENTRUN_BOOT_MODE
|
|
|| env.AGENTRUN_RUN_ID
|
|
|| env.AGENTRUN_K8S_JOB_NAME
|
|
|| env.CODE_QUEUE_SERVICE_ROLE
|
|
|| env.CODE_QUEUE_INSTANCE_ID
|
|
|| env.KUBERNETES_SERVICE_HOST,
|
|
);
|
|
}
|
|
|
|
function detectLocalBackendCoreStatus(env: NodeJS.ProcessEnv = process.env): SshCaptureBackendPlan["localBackendCore"] {
|
|
const timeout = sshBackendCoreDetectTimeoutMs(env);
|
|
const inspect = spawnSync("docker", ["inspect", "unidesk-backend-core", "--format", "{{.State.Running}}"], { encoding: "utf8", timeout });
|
|
if (inspect.error !== undefined) return { dockerExecutable: false, backendCoreContainer: false, error: inspect.error.message, source: "docker-inspect" };
|
|
const inspectOutput = `${inspect.stdout ?? ""}\n${inspect.stderr ?? ""}`.trim();
|
|
if (inspect.status === 0) {
|
|
const running = String(inspect.stdout ?? "").trim() === "true";
|
|
return {
|
|
dockerExecutable: true,
|
|
backendCoreContainer: running,
|
|
error: running ? null : inspectOutput || "docker inspect unidesk-backend-core reported not running",
|
|
source: "docker-inspect",
|
|
};
|
|
}
|
|
const result = spawnSync("docker", ["ps", "--format", "{{.Names}}"], { encoding: "utf8", timeout });
|
|
if (result.error !== undefined) return { dockerExecutable: false, backendCoreContainer: false, error: result.error.message, source: "docker-ps" };
|
|
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
|
return {
|
|
dockerExecutable: result.status === 0,
|
|
backendCoreContainer: result.status === 0 && String(result.stdout ?? "").split(/\r?\n/u).includes("unidesk-backend-core"),
|
|
error: result.status === 0 ? null : inspectOutput || output || `docker ps exited ${result.status ?? "unknown"}`,
|
|
source: "docker-ps",
|
|
};
|
|
}
|
|
|
|
function sshBackendCoreDetectTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
const parsed = Number(env.UNIDESK_SSH_BACKEND_CORE_DETECT_TIMEOUT_MS);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) return defaultSshBackendCoreDetectTimeoutMs;
|
|
return Math.min(maxSshBackendCoreDetectTimeoutMs, Math.max(2000, Math.trunc(parsed)));
|
|
}
|
|
|
|
function isLocalSshCaptureBackendUnavailable(result: SshCaptureResult): boolean {
|
|
return /No such container: unidesk-backend-core|failed to start broker|Executable not found.*"docker"|docker: not found|Cannot connect to the Docker daemon/iu.test(result.stderr);
|
|
}
|
|
|
|
function writeChunkedStdin(stdin: NodeJS.WritableStream, input: string): void {
|
|
const buffer = Buffer.from(input, "utf8");
|
|
const chunkSize = 32 * 1024;
|
|
for (let offset = 0; offset < buffer.length; offset += chunkSize) {
|
|
stdin.write(buffer.subarray(offset, Math.min(buffer.length, offset + chunkSize)));
|
|
}
|
|
stdin.end();
|
|
}
|
|
|
|
export async function runSsh(config: UniDeskConfig, providerId: string, args: string[]): Promise<number> {
|
|
const normalizedArgs = normalizeSshOperationArgs(args);
|
|
process.stderr.write(sshRouteSeparatorCompatibilityHint(args, normalizedArgs));
|
|
const plan = sshCaptureBackendPlan(config, process.env);
|
|
if (plan.backend === "remote-frontend-websocket" && plan.remoteHost !== null) {
|
|
return await runRemoteSsh(config, plan.remoteHost, providerId, normalizedArgs);
|
|
}
|
|
const invocation = parseSshInvocation(providerId, normalizedArgs);
|
|
const parsed = invocation.parsed;
|
|
const operationName = normalizedArgs[0] ?? "";
|
|
if (isSshFileTransferOperation(normalizedArgs)) {
|
|
const executor: SshRemoteCommandExecutor = {
|
|
runRemoteCommand: (remoteCommand, input) => runSshCaptureRemoteCommand(config, invocation, remoteCommand, input),
|
|
streamRemoteCommand: (remoteCommand, handlers, input, options) => runSshStreamRemoteCommand(config, invocation, remoteCommand, handlers, input, options),
|
|
};
|
|
return await runSshFileTransferOperation(invocation, normalizedArgs, executor, {
|
|
buildRouteCommand: remoteCommandForRoute,
|
|
buildWindowsPowerShellCommand: buildWindowsPowerShellInvocation,
|
|
});
|
|
}
|
|
if (operationName === "apply-patch") {
|
|
const applyPatch = effectiveApplyPatchV2Invocation(invocation, normalizedArgs.slice(1));
|
|
const executor: ApplyPatchV2Executor = applyPatch.invocation.route.plane === "win"
|
|
? { fs: createWindowsApplyPatchFileSystem(applyPatch.invocation, (remoteCommand, input) => runSshCaptureRemoteCommand(config, applyPatch.invocation, remoteCommand, input)) }
|
|
: applyPatch.invocation.route.plane === "host"
|
|
? { fs: createPosixApplyPatchFileSystem(applyPatch.invocation, (command, input) => runSshCaptureCommand(config, applyPatch.invocation, command, input)) }
|
|
: { run: (command, input) => runSshCaptureCommand(config, applyPatch.invocation, command, input) };
|
|
return await runApplyPatchV2({
|
|
executor,
|
|
stdin: process.stdin,
|
|
stdout: process.stdout,
|
|
stderr: process.stderr,
|
|
argv: applyPatch.argv,
|
|
timing: {
|
|
providerId: applyPatch.invocation.providerId,
|
|
route: applyPatch.invocation.route.raw,
|
|
transport: "backend-core-broker",
|
|
},
|
|
});
|
|
}
|
|
const startedAtMs = Date.now();
|
|
const size = terminalSize();
|
|
const openTimeoutMs = Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000));
|
|
const runtimeTimeoutMs = sshRuntimeTimeoutMs();
|
|
const payload = {
|
|
providerId: invocation.providerId,
|
|
command: wrapSshRemoteCommand(parsed.remoteCommand, parsed.requiredHelpers),
|
|
cwd: sshRoutePayloadCwd(invocation.route),
|
|
tty: parsed.remoteCommand === null,
|
|
stdinEotOnEnd: parsed.remoteCommand !== null && parsed.requiresStdin !== true,
|
|
openTimeoutMs,
|
|
runtimeTimeoutMs,
|
|
cols: size.cols,
|
|
rows: size.rows,
|
|
};
|
|
const payloadJson = JSON.stringify(payload);
|
|
const encodedBrokerSource = Buffer.from(brokerSource(), "utf8").toString("base64");
|
|
const script = [
|
|
"set -eu",
|
|
`payload=${shellQuote(payloadJson)}`,
|
|
"if command -v backend-core >/dev/null 2>&1; then",
|
|
' exec backend-core --ssh-broker "$payload"',
|
|
"fi",
|
|
`export UNIDESK_SSH_BROKER_URL=${shellQuote("ws://127.0.0.1:8080/ws/ssh")}`,
|
|
'broker_js="$(mktemp "${TMPDIR:-/tmp}/unidesk-ssh-broker.XXXXXX")"',
|
|
'trap \'rm -f "$broker_js"\' EXIT',
|
|
`printf %s ${shellQuote(encodedBrokerSource)} | base64 -d >"$broker_js"`,
|
|
'bun "$broker_js" "$payload"',
|
|
].join("\n");
|
|
const child = spawn("docker", [
|
|
"exec",
|
|
"-i",
|
|
"unidesk-backend-core",
|
|
"sh",
|
|
"-c",
|
|
script,
|
|
], {
|
|
cwd: repoRoot,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
|
|
const rawMode = parsed.remoteCommand === null && process.stdin.isTTY;
|
|
if (rawMode) process.stdin.setRawMode(true);
|
|
process.stdin.resume();
|
|
if (parsed.stdinPrefix !== undefined || parsed.stdinSuffix !== undefined) {
|
|
if (parsed.stdinPrefix) child.stdin.write(parsed.stdinPrefix);
|
|
process.stdin.pipe(child.stdin, { end: false });
|
|
process.stdin.once("end", () => {
|
|
if (parsed.stdinSuffix) child.stdin.write(parsed.stdinSuffix);
|
|
child.stdin.end();
|
|
});
|
|
} else {
|
|
process.stdin.pipe(child.stdin);
|
|
}
|
|
let stderrTail = "";
|
|
const appendStderrTail = (chunk: Buffer | string): void => {
|
|
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
|
|
stderrTail = (stderrTail + text).slice(-16_384);
|
|
};
|
|
let stdoutForwarder: SshStreamForwarder | null = null;
|
|
let stderrForwarder: SshStreamForwarder | null = null;
|
|
if (parsed.remoteCommand === null) {
|
|
child.stdout.pipe(process.stdout);
|
|
} else {
|
|
stdoutForwarder = createSshStdoutForwarder({
|
|
invocation,
|
|
transport: "backend-core-broker",
|
|
});
|
|
stderrForwarder = createSshStderrForwarder({
|
|
invocation,
|
|
transport: "backend-core-broker",
|
|
});
|
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
const hint = stdoutForwarder.write(chunk);
|
|
if (hint !== null) {
|
|
appendStderrTail(hint);
|
|
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);
|
|
});
|
|
}
|
|
|
|
return await new Promise<number>((resolve) => {
|
|
let settled = false;
|
|
let timedOut = false;
|
|
let killTimer: NodeJS.Timeout | null = null;
|
|
const runtimeTimer = setTimeout(() => {
|
|
if (settled) return;
|
|
timedOut = true;
|
|
const hint = sshRuntimeTimeoutHint({
|
|
invocation,
|
|
transport: "backend-core-broker",
|
|
timeoutMs: runtimeTimeoutMs,
|
|
});
|
|
const formatted = formatSshRuntimeTimeoutHint(hint);
|
|
appendStderrTail(formatted);
|
|
process.stderr.write(formatted);
|
|
try {
|
|
child.stdin.destroy();
|
|
} catch {
|
|
// Ignore stdin teardown failures on the timeout path.
|
|
}
|
|
try {
|
|
child.kill("SIGTERM");
|
|
} catch {
|
|
// Ignore kill failures and fall through to finish.
|
|
}
|
|
killTimer = setTimeout(() => {
|
|
try {
|
|
child.kill("SIGKILL");
|
|
} catch {
|
|
// Process may have already exited.
|
|
}
|
|
}, 2000);
|
|
finish(124);
|
|
}, runtimeTimeoutMs);
|
|
const restore = (): void => {
|
|
clearTimeout(runtimeTimer);
|
|
if (killTimer && !timedOut) clearTimeout(killTimer);
|
|
process.stdin.unpipe(child.stdin);
|
|
if (rawMode) process.stdin.setRawMode(false);
|
|
};
|
|
const finish = (exitCode: number): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
restore();
|
|
const hint = timedOut ? null : sshFailureHint(invocation.providerId, parsed, exitCode, stderrTail);
|
|
if (hint !== null) process.stderr.write(formatSshFailureHint(hint));
|
|
if (!timedOut) {
|
|
const windowsPlaneHint = sshWindowsPlaneHint(invocation, exitCode, stderrTail);
|
|
if (windowsPlaneHint) process.stderr.write(windowsPlaneHint);
|
|
const pipelineHint = sshWindowsPowerShellPipelineHint(invocation, exitCode, stderrTail);
|
|
if (pipelineHint) process.stderr.write(pipelineHint);
|
|
}
|
|
if (!timedOut) {
|
|
const tcpPoolHint = formatSshTcpPoolHint(sshTcpPoolHint({
|
|
invocation,
|
|
transport: "backend-core-broker",
|
|
exitCode,
|
|
stderrText: stderrTail,
|
|
}));
|
|
if (tcpPoolHint) process.stderr.write(tcpPoolHint);
|
|
}
|
|
const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({
|
|
invocation,
|
|
transport: "backend-core-broker",
|
|
exitCode,
|
|
startedAtMs,
|
|
}));
|
|
if (timingHint) process.stderr.write(timingHint);
|
|
const truncationSummary = formatSshTruncationCompletionSummary(sshTruncationCompletionSummary({
|
|
invocation,
|
|
transport: "backend-core-broker",
|
|
exitCode,
|
|
timedOut,
|
|
startedAtMs,
|
|
stdout: stdoutForwarder?.summary() ?? null,
|
|
stderr: stderrForwarder?.summary() ?? null,
|
|
}));
|
|
if (truncationSummary) process.stderr.write(truncationSummary);
|
|
resolve(exitCode);
|
|
};
|
|
child.on("error", (error) => {
|
|
const message = `unidesk ssh failed to start broker: ${error.message}\n`;
|
|
appendStderrTail(message);
|
|
process.stderr.write(message);
|
|
finish(255);
|
|
});
|
|
child.on("close", (code) => {
|
|
finish(code ?? 255);
|
|
});
|
|
});
|
|
}
|