import { spawn } from "node:child_process"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { runApplyPatchV2, type ApplyPatchV2Executor } from "./apply-patch-v2"; import { type UniDeskConfig } from "./config"; import { type RemoteCliOptions } from "./remote-options"; import { buildWindowsPowerShellInvocation, createSshStdoutForwarder, formatSshFailureHint, formatSshRuntimeTimeoutHint, formatSshRuntimeTimingHint, normalizeSshOperationArgs, parseSshInvocation, remoteCommandForRoute, sshFailureHint, sshRoutePayloadCwd, sshRouteSeparatorCompatibilityHint, sshRuntimeTimeoutHint, sshRuntimeTimeoutMs, sshRuntimeTimingHint, wrapSshRemoteCommand, type SshCaptureResult, } from "./ssh"; import { isSshFileTransferOperation, runSshFileTransferOperation, type SshRemoteCommandExecutor, type SshRemoteCommandStreamHandlers, } from "./ssh-file-transfer"; interface FrontendSession { baseUrl: string; cookie: string; sshClientToken: string | null; } interface FetchJsonResult { ok: boolean; status?: number; body?: unknown; error?: string; responseHeaders?: Record; responseTruncated?: boolean; responseBytesRead?: number; responseContentLength?: string | null; } const remoteSshInputChunkBytes = 32 * 1024; function normalizeSshCommandArgs(args: string[]): string[] { if (args[0] === "ssh") return args; return ["ssh", ...args]; } function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } function frontendBaseUrl(host: string, config: UniDeskConfig): string { if (host.startsWith("http://") || host.startsWith("https://")) return host.replace(/\/+$/u, ""); if (/:\d+$/u.test(host)) return `http://${host}`; return `http://${host}:${config.network.frontend.port}`; } function remoteHttpClientMode(env: NodeJS.ProcessEnv = process.env): "curl" | "fetch" { const explicit = env.UNIDESK_REMOTE_HTTP_CLIENT?.trim().toLowerCase(); if (explicit === "fetch") return "fetch"; if (explicit === "curl") return "curl"; return isRunnerEnvironment(env) ? "curl" : "fetch"; } 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, ); } async function readJson(url: string, init?: RequestInit, timeoutMs = 8000, maxResponseBytes = 5_000_000): Promise { if (remoteHttpClientMode() === "curl") return readJsonWithCurl(url, init, timeoutMs, maxResponseBytes); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(url, { ...init, signal: controller.signal }); const reader = res.body?.getReader(); const chunks: Uint8Array[] = []; let bytes = 0; let responseTruncated = false; if (reader !== undefined) { while (true) { const { done, value } = await reader.read(); if (done) break; if (bytes + value.byteLength > maxResponseBytes) { const keep = Math.max(0, maxResponseBytes - bytes); if (keep > 0) { chunks.push(value.slice(0, keep)); bytes += keep; } responseTruncated = true; try { await reader.cancel(); } catch { // Ignore cancel failures after the bounded preview has been collected. } break; } chunks.push(value); bytes += value.byteLength; } } const buffer = new Uint8Array(bytes); let offset = 0; for (const chunk of chunks) { buffer.set(chunk, offset); offset += chunk.byteLength; } const text = new TextDecoder().decode(buffer); let body: unknown = null; try { body = text.length > 0 && !responseTruncated ? JSON.parse(text) : null; } catch { body = { text }; } if (responseTruncated) { body = { _unideskResponseTruncated: true, maxResponseBytes, bytesRead: bytes, contentLength: res.headers.get("content-length"), textPreview: text }; } return { ok: res.ok, status: res.status, body, responseHeaders: responseHeadersRecord(res.headers), responseTruncated, responseBytesRead: bytes, responseContentLength: res.headers.get("content-length") }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; } finally { clearTimeout(timer); } } function responseHeadersRecord(headers: Headers): Record { const record: Record = {}; headers.forEach((value, key) => { record[key.toLowerCase()] = value; }); return record; } function requestHeaders(init?: RequestInit): Array<[string, string]> { const headers = new Headers(init?.headers); const output: Array<[string, string]> = []; headers.forEach((value, key) => output.push([key, value])); return output; } async function runCurl(args: string[], timeoutMs: number): Promise<{ status: number | null; stdout: string; stderr: string; timedOut: boolean; error?: string }> { const child = spawn("curl", args, { stdio: ["ignore", "pipe", "pipe"] }); const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; let timedOut = false; const timer = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, timeoutMs + 1000); child.stdout?.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); child.stderr?.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); return await new Promise((resolve) => { child.on("error", (error) => { clearTimeout(timer); resolve({ status: null, stdout: "", stderr: "", timedOut, error: error.message }); }); child.on("close", (status) => { clearTimeout(timer); resolve({ status, stdout: Buffer.concat(stdoutChunks).toString("utf8"), stderr: Buffer.concat(stderrChunks).toString("utf8"), timedOut, }); }); }); } function parseCurlResponseHeaders(raw: string): Record { const blocks = raw.split(/\r?\n\r?\n/u).map((block) => block.trim()).filter(Boolean); const latest = blocks.at(-1) ?? ""; const headers: Record = {}; for (const line of latest.split(/\r?\n/u).slice(1)) { const splitAt = line.indexOf(":"); if (splitAt <= 0) continue; headers[line.slice(0, splitAt).trim().toLowerCase()] = line.slice(splitAt + 1).trim(); } return headers; } function decodeBoundedBody(buffer: Buffer, maxResponseBytes: number): { text: string; truncated: boolean; bytesRead: number } { if (buffer.byteLength <= maxResponseBytes) return { text: buffer.toString("utf8"), truncated: false, bytesRead: buffer.byteLength }; return { text: buffer.subarray(0, maxResponseBytes).toString("utf8"), truncated: true, bytesRead: maxResponseBytes }; } async function readJsonWithCurl(url: string, init?: RequestInit, timeoutMs = 8000, maxResponseBytes = 5_000_000): Promise { const dir = await mkdtemp(path.join(tmpdir(), "unidesk-remote-http-")); const headersFile = path.join(dir, "headers.txt"); const bodyFile = path.join(dir, "body.bin"); const requestBodyFile = path.join(dir, "request-body.bin"); try { const method = init?.method ?? (init?.body === undefined ? "GET" : "POST"); const args = [ "-sS", "--max-time", String(Math.max(1, Math.ceil(timeoutMs / 1000))), "-D", headersFile, "-o", bodyFile, "-w", "%{http_code}", "-X", method, ]; for (const [key, value] of requestHeaders(init)) args.push("-H", `${key}: ${value}`); if (init?.body !== undefined) { await writeFile(requestBodyFile, typeof init.body === "string" ? init.body : String(init.body)); args.push("--data-binary", `@${requestBodyFile}`); } args.push(url); const curl = await runCurl(args, timeoutMs); if (curl.status !== 0) { const curlError = curl.error ?? curl.stderr.trim(); return { ok: false, status: curl.stdout.trim().match(/^\d{3}$/u) ? Number(curl.stdout.trim()) : undefined, error: curl.timedOut ? `curl timed out after ${timeoutMs}ms` : (curlError.length > 0 ? curlError : `curl exited with ${curl.status}`), }; } const status = Number(curl.stdout.trim()); const headersText = await readFile(headersFile, "utf8").catch(() => ""); const responseHeaders = parseCurlResponseHeaders(headersText); const bodyBuffer = await readFile(bodyFile).catch(() => Buffer.alloc(0)); const decoded = decodeBoundedBody(bodyBuffer, maxResponseBytes); let body: unknown = null; try { body = decoded.text.length > 0 && !decoded.truncated ? JSON.parse(decoded.text) : null; } catch { body = { text: decoded.text }; } if (decoded.truncated) { body = { _unideskResponseTruncated: true, maxResponseBytes, bytesRead: decoded.bytesRead, contentLength: responseHeaders["content-length"] ?? null, textPreview: decoded.text, }; } return { ok: status >= 200 && status < 300, status, body, responseHeaders, responseTruncated: decoded.truncated, responseBytesRead: decoded.bytesRead, responseContentLength: responseHeaders["content-length"] ?? null, }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; } finally { await rm(dir, { recursive: true, force: true }).catch(() => undefined); } } async function loginFrontend(host: string, config: UniDeskConfig): Promise { const baseUrl = frontendBaseUrl(host, config); const res = await readJson(`${baseUrl}/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ username: config.auth.username, password: config.auth.password }), }, 8_000, 120_000); if (!res.ok) throw new Error(`frontend login failed via ${baseUrl}: status=${res.status ?? "unknown"} body=${JSON.stringify(res.body ?? res.error).slice(0, 300)}`); const cookie = res.responseHeaders?.["set-cookie"]?.split(";")[0] ?? ""; if (cookie.length === 0) throw new Error(`frontend login via ${baseUrl} did not return a session cookie`); return { baseUrl, cookie, sshClientToken: null }; } function sshClientTokenFromEnv(env: NodeJS.ProcessEnv = process.env): string | null { const token = env.UNIDESK_SSH_CLIENT_TOKEN?.trim() ?? ""; return token.length > 0 ? token : null; } function scopedSshFrontendSession(host: string, config: UniDeskConfig, token: string): FrontendSession { return { baseUrl: frontendBaseUrl(host, config), cookie: "", sshClientToken: token }; } function frontendSshWebSocketUrl(session: FrontendSession): string { const url = new URL("/ws/ssh", session.baseUrl); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; return url.toString(); } function webSocketDataText(data: unknown): string { if (typeof data === "string") return data; if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8"); return String(data); } function openFrontendSshWebSocket(session: FrontendSession): WebSocket { const WebSocketWithHeaders = WebSocket as unknown as new ( url: string, options?: { headers?: Record }, ) => WebSocket; const headers = session.sshClientToken === null ? { cookie: session.cookie } : { authorization: `Bearer ${session.sshClientToken}` }; return new WebSocketWithHeaders(frontendSshWebSocketUrl(session), { headers }); } async function runRemoteSshWebSocket( session: FrontendSession, invocation: ReturnType, ): Promise { const parsed = invocation.parsed; const startedAtMs = Date.now(); const size = { cols: Number(process.stdout.columns) > 0 ? Number(process.stdout.columns) : 100, rows: Number(process.stdout.rows) > 0 ? Number(process.stdout.rows) : 30, }; 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, openTimeoutMs, runtimeTimeoutMs, cols: size.cols, rows: size.rows, }; const ws = openFrontendSshWebSocket(session); let exitCode = 255; let settled = false; let canSend = false; let sessionReady = false; const pending: string[] = []; const pendingSessionMessages: string[] = []; const send = (value: unknown): void => { const text = JSON.stringify(value); if (!canSend || ws.readyState !== WebSocket.OPEN) { pending.push(text); return; } ws.send(text); }; const flush = (): void => { while (pending.length > 0 && ws.readyState === WebSocket.OPEN) ws.send(pending.shift()!); }; const sendInput = (value: Buffer | string): void => { sendWhenSessionReady({ type: "ssh.input", data: Buffer.from(value).toString("base64"), encoding: "base64" }); }; const sendInputChunked = (value: string): void => { const buffer = Buffer.from(value, "utf8"); for (let offset = 0; offset < buffer.length; offset += remoteSshInputChunkBytes) { sendInput(buffer.subarray(offset, Math.min(buffer.length, offset + remoteSshInputChunkBytes))); } }; const sendWhenSessionReady = (value: unknown): void => { const text = JSON.stringify(value); if (!sessionReady || ws.readyState !== WebSocket.OPEN) { pendingSessionMessages.push(text); return; } ws.send(text); }; const flushSessionMessages = (): void => { if (!sessionReady || ws.readyState !== WebSocket.OPEN) return; while (pendingSessionMessages.length > 0) ws.send(pendingSessionMessages.shift()!); }; return await new Promise((resolve) => { const rawMode = parsed.remoteCommand === null && process.stdin.isTTY && typeof process.stdin.setRawMode === "function"; const stdoutForwarder = parsed.remoteCommand === null ? null : createSshStdoutForwarder({ invocation, transport: "frontend-websocket", }); let timedOut = false; const openTimer = setTimeout(() => { if (sessionReady || settled) return; process.stderr.write("unidesk remote frontend ssh bridge timed out waiting for provider session\n"); exitCode = 255; try { ws.close(); } catch { // Ignore close failures while resolving the timeout path. } }, openTimeoutMs); const runtimeTimer = setTimeout(() => { if (settled) return; timedOut = true; exitCode = 124; process.stderr.write(formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({ invocation, transport: "frontend-websocket", timeoutMs: runtimeTimeoutMs, }))); try { ws.close(); } catch { // Ignore close failures while resolving the timeout path. } finish(124); }, runtimeTimeoutMs); const restore = (): void => { clearTimeout(openTimer); clearTimeout(runtimeTimer); process.stdin.off("data", onStdinData); process.stdin.off("end", onStdinEnd); if (rawMode) process.stdin.setRawMode(false); }; const finish = (code: number): void => { if (settled) return; settled = true; restore(); const hint = timedOut ? null : sshFailureHint(invocation.providerId, parsed, code, ""); if (hint !== null) process.stderr.write(formatSshFailureHint(hint)); const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({ invocation, transport: "frontend-websocket", exitCode: code, startedAtMs, })); if (timingHint) process.stderr.write(timingHint); resolve(code); }; const onStdinData = (chunk: Buffer): void => { sendInput(chunk); }; const onStdinEnd = (): void => { if (parsed.stdinSuffix) sendInput(parsed.stdinSuffix); if (payload.stdinEotOnEnd === true) sendInput(Buffer.from([4])); sendWhenSessionReady({ type: "ssh.eof" }); }; ws.addEventListener("open", () => { canSend = true; send({ type: "ssh.open", ...payload }); flush(); }); ws.addEventListener("message", (event) => { const text = webSocketDataText(event.data); let message: Record; try { message = JSON.parse(text) as Record; } catch { process.stderr.write(`${text}\n`); return; } if (message.type === "ssh.dispatched") return; if (message.type === "ssh.opened") { sessionReady = true; clearTimeout(openTimer); flushSessionMessages(); return; } if (message.type === "ssh.data") { const chunk = Buffer.from(String(message.data ?? ""), message.encoding === "base64" ? "base64" : "utf8"); if (message.stream === "stderr") process.stderr.write(chunk); else if (stdoutForwarder === null) { process.stdout.write(chunk); } else { const hint = stdoutForwarder.write(chunk); if (hint !== null) process.stderr.write(hint); } return; } if (message.type === "ssh.error") { process.stderr.write(`${String(message.message || "ssh bridge error")}\n`); exitCode = 255; ws.close(); return; } if (message.type === "ssh.exit") { exitCode = Number.isInteger(message.exitCode) ? Number(message.exitCode) : 255; ws.close(); } }); ws.addEventListener("close", () => finish(exitCode)); ws.addEventListener("error", () => { process.stderr.write("unidesk remote frontend ssh bridge websocket error\n"); finish(255); }); if (rawMode) process.stdin.setRawMode(true); process.stdin.resume(); if (parsed.stdinPrefix) sendInput(parsed.stdinPrefix); process.stdin.on("data", onStdinData); process.stdin.on("end", onStdinEnd); }); } async function runRemoteSshWebSocketCapture( session: FrontendSession, invocation: ReturnType, command: string[], input?: string, ): Promise { const remoteCommand = remoteCommandForRoute(invocation.route, command, { stdin: input !== undefined }); return await runRemoteSshWebSocketCaptureRemoteCommand(session, invocation, remoteCommand, input); } async function runRemoteSshWebSocketCaptureRemoteCommand( session: FrontendSession, invocation: ReturnType, remoteCommand: string, input?: string, ): Promise { const captureInvocation = { ...invocation, parsed: { ...invocation.parsed, remoteCommand, requiresStdin: input !== undefined, invocationKind: "helper" as const }, }; const startedAtMs = Date.now(); const size = { cols: Number(process.stdout.columns) > 0 ? Number(process.stdout.columns) : 100, rows: Number(process.stdout.rows) > 0 ? Number(process.stdout.rows) : 30, }; 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 ws = openFrontendSshWebSocket(session); let exitCode = 255; let settled = false; let canSend = false; let sessionReady = false; let stdout = ""; let stderr = ""; const pending: string[] = []; const pendingSessionMessages: string[] = []; const send = (value: unknown): void => { const text = JSON.stringify(value); if (!canSend || ws.readyState !== WebSocket.OPEN) { pending.push(text); return; } ws.send(text); }; const sendWhenSessionReady = (value: unknown): void => { const text = JSON.stringify(value); if (!sessionReady || ws.readyState !== WebSocket.OPEN) { pendingSessionMessages.push(text); return; } ws.send(text); }; const sendInput = (value: Buffer | string): void => { sendWhenSessionReady({ type: "ssh.input", data: Buffer.from(value).toString("base64"), encoding: "base64" }); }; const sendInputChunked = (value: string): void => { const buffer = Buffer.from(value, "utf8"); for (let offset = 0; offset < buffer.length; offset += remoteSshInputChunkBytes) { sendInput(buffer.subarray(offset, Math.min(buffer.length, offset + remoteSshInputChunkBytes))); } }; const flush = (): void => { while (pending.length > 0 && ws.readyState === WebSocket.OPEN) ws.send(pending.shift()!); }; const flushSessionMessages = (): void => { if (!sessionReady || ws.readyState !== WebSocket.OPEN) return; while (pendingSessionMessages.length > 0) ws.send(pendingSessionMessages.shift()!); }; return await new Promise((resolve) => { let killTimer: ReturnType | null = null; const finish = (code: number): void => { if (settled) return; settled = true; clearTimeout(openTimer); clearTimeout(runtimeTimer); if (killTimer !== null) clearTimeout(killTimer); const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({ invocation: captureInvocation, transport: "frontend-websocket", exitCode: code, startedAtMs, })); if (timingHint) stderr += timingHint; resolve({ exitCode: code, stdout, stderr }); }; const openTimer = setTimeout(() => { if (sessionReady || settled) return; stderr += "unidesk remote frontend ssh bridge timed out waiting for provider session\n"; exitCode = 255; try { ws.close(); } catch { // Ignore. } }, payload.openTimeoutMs); const runtimeTimer = setTimeout(() => { if (settled) return; exitCode = 124; stderr += formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({ invocation: captureInvocation, transport: "frontend-websocket", timeoutMs: runtimeTimeoutMs, })); try { ws.close(); } catch { // Ignore. } killTimer = setTimeout(() => finish(124), 2000); finish(124); }, runtimeTimeoutMs); ws.addEventListener("open", () => { canSend = true; send({ type: "ssh.open", ...payload }); flush(); }); ws.addEventListener("message", (event) => { const text = webSocketDataText(event.data); let message: Record; try { message = JSON.parse(text) as Record; } catch { stderr += `${text}\n`; return; } if (message.type === "ssh.dispatched") return; if (message.type === "ssh.opened") { sessionReady = true; clearTimeout(openTimer); if (input !== undefined) sendInputChunked(input); sendWhenSessionReady({ type: "ssh.eof" }); flushSessionMessages(); return; } if (message.type === "ssh.data") { const chunk = Buffer.from(String(message.data ?? ""), message.encoding === "base64" ? "base64" : "utf8").toString("utf8"); if (message.stream === "stderr") stderr += chunk; else stdout += chunk; return; } if (message.type === "ssh.error") { stderr += `${String(message.message || "ssh bridge error")}\n`; exitCode = 255; ws.close(); return; } if (message.type === "ssh.exit") { exitCode = Number.isInteger(message.exitCode) ? Number(message.exitCode) : 255; ws.close(); } }); ws.addEventListener("close", () => finish(exitCode)); ws.addEventListener("error", () => { stderr += "unidesk remote frontend ssh bridge websocket error\n"; finish(255); }); }); } async function runRemoteSshWebSocketStreamRemoteCommand( session: FrontendSession, invocation: ReturnType, remoteCommand: string, handlers: SshRemoteCommandStreamHandlers, input?: string, options: { inactivityTimeoutMs?: number } = {}, ): Promise { const streamInvocation = { ...invocation, parsed: { ...invocation.parsed, remoteCommand, requiresStdin: input !== undefined, invocationKind: "helper" as const }, }; const startedAtMs = Date.now(); const size = { cols: Number(process.stdout.columns) > 0 ? Number(process.stdout.columns) : 100, rows: Number(process.stdout.rows) > 0 ? Number(process.stdout.rows) : 30, }; 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 ws = openFrontendSshWebSocket(session); let exitCode = 255; let settled = false; let canSend = false; let sessionReady = false; let stdout = ""; let stderr = ""; let streamError: unknown = null; let streamWrites = Promise.resolve(); const pending: string[] = []; const pendingSessionMessages: string[] = []; const send = (value: unknown): void => { const text = JSON.stringify(value); if (!canSend || ws.readyState !== WebSocket.OPEN) { pending.push(text); return; } ws.send(text); }; const sendWhenSessionReady = (value: unknown): void => { const text = JSON.stringify(value); if (!sessionReady || ws.readyState !== WebSocket.OPEN) { pendingSessionMessages.push(text); return; } ws.send(text); }; const sendInput = (value: Buffer | string): void => { sendWhenSessionReady({ type: "ssh.input", data: Buffer.from(value).toString("base64"), encoding: "base64" }); }; const sendInputChunked = (value: string): void => { const buffer = Buffer.from(value, "utf8"); for (let offset = 0; offset < buffer.length; offset += remoteSshInputChunkBytes) { sendInput(buffer.subarray(offset, Math.min(buffer.length, offset + remoteSshInputChunkBytes))); } }; const flush = (): void => { while (pending.length > 0 && ws.readyState === WebSocket.OPEN) ws.send(pending.shift()!); }; const flushSessionMessages = (): void => { if (!sessionReady || ws.readyState !== WebSocket.OPEN) return; while (pendingSessionMessages.length > 0) ws.send(pendingSessionMessages.shift()!); }; 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 remote frontend ssh stream sink failed: ${error instanceof Error ? error.message : String(error)}\n`; exitCode = 255; try { ws.close(); } catch { // Ignore close failures after the local stream sink has failed. } }); }; return await new Promise((resolve) => { let killTimer: ReturnType | null = null; let inactivityTimer: ReturnType | null = null; const refreshActivityTimer = (): void => { if (settled || options.inactivityTimeoutMs === undefined) return; if (inactivityTimer !== null) clearTimeout(inactivityTimer); inactivityTimer = setTimeout(() => { exitCode = 124; stderr += formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({ invocation: streamInvocation, transport: "frontend-websocket", timeoutMs: inactivityTimeoutMs, })); try { ws.close(); } catch { // Ignore. } killTimer = setTimeout(() => finish(124), 2000); finish(124); }, inactivityTimeoutMs); }; const finish = (code: number): void => { if (settled) return; settled = true; clearTimeout(openTimer); clearTimeout(runtimeTimer); if (inactivityTimer !== null) clearTimeout(inactivityTimer); if (killTimer !== null) clearTimeout(killTimer); void streamWrites.then(() => { const finalCode = streamError === null ? code : 255; const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({ invocation: streamInvocation, transport: "frontend-websocket", exitCode: finalCode, startedAtMs, })); if (timingHint) stderr += timingHint; resolve({ exitCode: finalCode, stdout, stderr }); }); }; const openTimer = setTimeout(() => { if (sessionReady || settled) return; stderr += "unidesk remote frontend ssh bridge timed out waiting for provider session\n"; exitCode = 255; try { ws.close(); } catch { // Ignore. } }, payload.openTimeoutMs); const runtimeTimer = setTimeout(() => { if (settled) return; exitCode = 124; stderr += formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({ invocation: streamInvocation, transport: "frontend-websocket", timeoutMs: runtimeTimeoutMs, })); try { ws.close(); } catch { // Ignore. } killTimer = setTimeout(() => finish(124), 2000); finish(124); }, runtimeTimeoutMs); refreshActivityTimer(); ws.addEventListener("open", () => { canSend = true; send({ type: "ssh.open", ...payload }); flush(); }); ws.addEventListener("message", (event) => { const text = webSocketDataText(event.data); let message: Record; try { message = JSON.parse(text) as Record; } catch { stderr += `${text}\n`; return; } if (message.type === "ssh.dispatched") { refreshActivityTimer(); return; } if (message.type === "ssh.opened") { sessionReady = true; clearTimeout(openTimer); refreshActivityTimer(); if (input !== undefined) sendInputChunked(input); sendWhenSessionReady({ type: "ssh.eof" }); flushSessionMessages(); return; } if (message.type === "ssh.data") { refreshActivityTimer(); const chunk = Buffer.from(String(message.data ?? ""), message.encoding === "base64" ? "base64" : "utf8"); if (message.stream === "stderr") { stderr += chunk.toString("utf8"); queueStreamWrite(chunk, "stderr"); } else { queueStreamWrite(chunk, "stdout"); } return; } if (message.type === "ssh.error") { stderr += `${String(message.message || "ssh bridge error")}\n`; exitCode = 255; ws.close(); return; } if (message.type === "ssh.exit") { exitCode = Number.isInteger(message.exitCode) ? Number(message.exitCode) : 255; ws.close(); } }); ws.addEventListener("close", () => finish(exitCode)); ws.addEventListener("error", () => { stderr += "unidesk remote frontend ssh bridge websocket error\n"; finish(255); }); }); } async function runRemoteSshOverFrontend(session: FrontendSession, target: string | undefined, args: string[]): Promise { if (!target) throw new Error("remote ssh requires a route, for example: bun scripts/ssh-cli.ts --main-server-ip 74.48.78.17 ssh D601 hostname"); const normalizedArgs = normalizeSshOperationArgs(args); process.stderr.write(sshRouteSeparatorCompatibilityHint(args, normalizedArgs)); const invocation = parseSshInvocation(target, normalizedArgs); if (isSshFileTransferOperation(normalizedArgs)) { const executor: SshRemoteCommandExecutor = { runRemoteCommand: (remoteCommand, input) => runRemoteSshWebSocketCaptureRemoteCommand(session, invocation, remoteCommand, input), streamRemoteCommand: (remoteCommand, handlers, input, options) => runRemoteSshWebSocketStreamRemoteCommand(session, invocation, remoteCommand, handlers, input, options), }; return await runSshFileTransferOperation(invocation, normalizedArgs, executor, { buildRouteCommand: remoteCommandForRoute, buildWindowsPowerShellCommand: buildWindowsPowerShellInvocation, }); } if ((normalizedArgs[0] ?? "") === "apply-patch") { const executor: ApplyPatchV2Executor = { run: (command, input) => runRemoteSshWebSocketCapture(session, invocation, command, input), }; return await runApplyPatchV2({ executor, stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, argv: normalizedArgs.slice(1), timing: { providerId: invocation.providerId, route: invocation.route.raw, transport: "frontend-websocket", }, }); } return runRemoteSshWebSocket(session, invocation); } async function runRemoteSshCliOverSsh(options: RemoteCliOptions): Promise { if (options.host === null) throw new Error("runRemoteSshCli requires --main-server-ip or --server"); const remoteArgs = normalizeSshCommandArgs(options.args); const remoteCommand = [ "cd", shellQuote(options.projectRoot), "&&", "exec", "bun", "scripts/ssh-cli.ts", ...remoteArgs.map(shellQuote), ].join(" "); const sshArgs = [ "-p", String(options.port), "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "ServerAliveInterval=20", "-o", "ServerAliveCountMax=3", ]; if (options.identityFile !== null) sshArgs.push("-i", options.identityFile); sshArgs.push(`${options.user}@${options.host}`, remoteCommand); const child = spawn("ssh", sshArgs, { stdio: ["inherit", "inherit", "inherit"], }); return await new Promise((resolve) => { child.on("error", (error) => { process.stderr.write(`unidesk remote ssh cli failed to start ssh: ${error.message}\n`); resolve(127); }); child.on("close", (code) => resolve(code ?? 255)); }); } async function runRemoteSshCliOverFrontend(options: RemoteCliOptions, config: UniDeskConfig): Promise { if (options.host === null) throw new Error("runRemoteSshCli requires --main-server-ip or --server"); const args = normalizeSshCommandArgs(options.args); const [top, sub] = args; if (top !== "ssh") throw new Error(`remote ssh cli supports only ssh commands, got: ${top || ""}`); const scopedSshToken = sshClientTokenFromEnv(); const session = scopedSshToken === null ? await loginFrontend(options.host, config) : scopedSshFrontendSession(options.host, config, scopedSshToken); return await runRemoteSshOverFrontend(session, sub, args.slice(2)); } export async function runRemoteSshCli(options: RemoteCliOptions, config: UniDeskConfig): Promise { if (options.host === null) throw new Error("runRemoteSshCli requires --main-server-ip or --server"); const args = normalizeSshCommandArgs(options.args); const useSsh = options.transport === "ssh" || (options.transport === "auto" && options.identityFile !== null); if (useSsh) return runRemoteSshCliOverSsh({ ...options, args }); return runRemoteSshCliOverFrontend({ ...options, args }, config); } export async function runRemoteSshCommandCapture( config: UniDeskConfig, host: string, target: string, args: string[], input?: string, env: NodeJS.ProcessEnv = process.env, ): Promise { const token = sshClientTokenFromEnv(env); const session = token === null ? await loginFrontend(host, config) : scopedSshFrontendSession(host, config, token); const normalizedArgs = normalizeSshOperationArgs(args); const invocation = parseSshInvocation(target, normalizedArgs); const parsed = invocation.parsed; if (parsed.remoteCommand === null) throw new Error(`remote ssh ${target} capture requires a non-interactive operation`); const stdin = parsed.stdinPrefix !== undefined || parsed.stdinSuffix !== undefined ? `${parsed.stdinPrefix ?? ""}${input ?? ""}${parsed.stdinSuffix ?? ""}` : input; return await runRemoteSshWebSocketCaptureRemoteCommand(session, invocation, parsed.remoteCommand, stdin); }