import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { parseSshTarget } from "../../infra/ssh-tunnel.js"; import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js"; import { resolveUserPath } from "../../utils.js"; import type { SandboxBackendCommandResult } from "./backend.js"; export type SshSandboxSettings = { command: string; target: string; strictHostKeyChecking: boolean; updateHostKeys: boolean; identityFile?: string; certificateFile?: string; knownHostsFile?: string; identityData?: string; certificateData?: string; knownHostsData?: string; }; export type SshSandboxSession = { command: string; configPath: string; host: string; }; export type RunSshSandboxCommandParams = { session: SshSandboxSession; remoteCommand: string; stdin?: Buffer | string; allowFailure?: boolean; signal?: AbortSignal; tty?: boolean; }; function normalizeInlineSshMaterial(contents: string, filename: string): string { const withoutBom = contents.replace(/^\uFEFF/, ""); const normalizedNewlines = withoutBom.replace(/\r\n?/g, "\n"); const normalizedEscapedNewlines = normalizedNewlines .replace(/\\r\\n/g, "\\n") .replace(/\\r/g, "\\n"); const expanded = filename === "identity" || filename === "certificate.pub" ? normalizedEscapedNewlines.replace(/\\n/g, "\n") : normalizedEscapedNewlines; return expanded.endsWith("\n") ? expanded : `${expanded}\n`; } function buildSshFailureMessage(stderr: string, exitCode?: number): string { const trimmed = stderr.trim(); if ( trimmed.includes("error in libcrypto") && (trimmed.includes('Load key "') || trimmed.includes("Permission denied (publickey)")) ) { return `${trimmed}\nSSH sandbox failed to load the configured identity. The private key contents may be malformed (for example CRLF or escaped newlines). Prefer identityFile when possible.`; } return ( trimmed || (exitCode !== undefined ? `ssh exited with code ${exitCode}` : "ssh exited with a non-zero status") ); } export function shellEscape(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } export function buildRemoteCommand(argv: string[]): string { return argv.map((entry) => shellEscape(entry)).join(" "); } export function buildExecRemoteCommand(params: { command: string; workdir?: string; env: Record; }): string { const body = params.workdir ? `cd ${shellEscape(params.workdir)} && ${params.command}` : params.command; const argv = Object.keys(params.env).length > 0 ? [ "env", ...Object.entries(params.env).map(([key, value]) => `${key}=${value}`), "/bin/sh", "-c", body, ] : ["/bin/sh", "-c", body]; return buildRemoteCommand(argv); } export function buildSshSandboxArgv(params: { session: SshSandboxSession; remoteCommand: string; tty?: boolean; }): string[] { return [ params.session.command, "-F", params.session.configPath, ...(params.tty ? ["-tt", "-o", "RequestTTY=force", "-o", "SetEnv=TERM=xterm-256color"] : ["-T", "-o", "RequestTTY=no"]), params.session.host, params.remoteCommand, ]; } export async function createSshSandboxSessionFromConfigText(params: { configText: string; host?: string; command?: string; }): Promise { const host = params.host?.trim() || parseSshConfigHost(params.configText); if (!host) { throw new Error("Failed to parse SSH config output."); } const configDir = await fs.mkdtemp(path.join(resolveSshTmpRoot(), "openclaw-sandbox-ssh-")); const configPath = path.join(configDir, "config"); await fs.writeFile(configPath, params.configText, { encoding: "utf8", mode: 0o600 }); await fs.chmod(configPath, 0o600); return { command: params.command?.trim() || "ssh", configPath, host, }; } export async function createSshSandboxSessionFromSettings( settings: SshSandboxSettings, ): Promise { const parsed = parseSshTarget(settings.target); if (!parsed) { throw new Error(`Invalid sandbox SSH target: ${settings.target}`); } const configDir = await fs.mkdtemp(path.join(resolveSshTmpRoot(), "openclaw-sandbox-ssh-")); try { const materializedIdentity = settings.identityData ? await writeSecretMaterial(configDir, "identity", settings.identityData) : undefined; const materializedCertificate = settings.certificateData ? await writeSecretMaterial(configDir, "certificate.pub", settings.certificateData) : undefined; const materializedKnownHosts = settings.knownHostsData ? await writeSecretMaterial(configDir, "known_hosts", settings.knownHostsData) : undefined; const identityFile = materializedIdentity ?? resolveOptionalLocalPath(settings.identityFile); const certificateFile = materializedCertificate ?? resolveOptionalLocalPath(settings.certificateFile); const knownHostsFile = materializedKnownHosts ?? resolveOptionalLocalPath(settings.knownHostsFile); const hostAlias = "openclaw-sandbox"; const configPath = path.join(configDir, "config"); const lines = [ `Host ${hostAlias}`, ` HostName ${parsed.host}`, ` Port ${parsed.port}`, " BatchMode yes", " ConnectTimeout 5", " ServerAliveInterval 15", " ServerAliveCountMax 3", ` StrictHostKeyChecking ${settings.strictHostKeyChecking ? "yes" : "no"}`, ` UpdateHostKeys ${settings.updateHostKeys ? "yes" : "no"}`, ]; if (parsed.user) { lines.push(` User ${parsed.user}`); } if (knownHostsFile) { lines.push(` UserKnownHostsFile ${knownHostsFile}`); } else if (!settings.strictHostKeyChecking) { lines.push(" UserKnownHostsFile /dev/null"); } if (identityFile) { lines.push(` IdentityFile ${identityFile}`); } if (certificateFile) { lines.push(` CertificateFile ${certificateFile}`); } if (identityFile || certificateFile) { lines.push(" IdentitiesOnly yes"); } await fs.writeFile(configPath, `${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o600, }); await fs.chmod(configPath, 0o600); return { command: settings.command.trim() || "ssh", configPath, host: hostAlias, }; } catch (error) { await fs.rm(configDir, { recursive: true, force: true }); throw error; } } export async function disposeSshSandboxSession(session: SshSandboxSession): Promise { await fs.rm(path.dirname(session.configPath), { recursive: true, force: true }); } export async function runSshSandboxCommand( params: RunSshSandboxCommandParams, ): Promise { const argv = buildSshSandboxArgv({ session: params.session, remoteCommand: params.remoteCommand, tty: params.tty, }); return await new Promise((resolve, reject) => { const child = spawn(argv[0], argv.slice(1), { stdio: ["pipe", "pipe", "pipe"], env: process.env, signal: params.signal, }); const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); child.on("error", reject); child.on("close", (code) => { const stdout = Buffer.concat(stdoutChunks); const stderr = Buffer.concat(stderrChunks); const exitCode = code ?? 0; if (exitCode !== 0 && !params.allowFailure) { reject( Object.assign(new Error(buildSshFailureMessage(stderr.toString("utf8"), exitCode)), { code: exitCode, stdout, stderr, }), ); return; } resolve({ stdout, stderr, code: exitCode }); }); if (params.stdin !== undefined) { child.stdin.end(params.stdin); return; } child.stdin.end(); }); } export async function uploadDirectoryToSshTarget(params: { session: SshSandboxSession; localDir: string; remoteDir: string; signal?: AbortSignal; }): Promise { const remoteCommand = buildRemoteCommand([ "/bin/sh", "-c", 'mkdir -p -- "$1" && tar -xf - -C "$1"', "openclaw-sandbox-upload", params.remoteDir, ]); const sshArgv = buildSshSandboxArgv({ session: params.session, remoteCommand, }); await new Promise((resolve, reject) => { const tar = spawn("tar", ["-C", params.localDir, "-cf", "-", "."], { stdio: ["ignore", "pipe", "pipe"], signal: params.signal, }); const ssh = spawn(sshArgv[0], sshArgv.slice(1), { stdio: ["pipe", "pipe", "pipe"], env: process.env, signal: params.signal, }); const tarStderr: Buffer[] = []; const sshStdout: Buffer[] = []; const sshStderr: Buffer[] = []; let tarClosed = false; let sshClosed = false; let tarCode = 0; let sshCode = 0; tar.stderr.on("data", (chunk) => tarStderr.push(Buffer.from(chunk))); ssh.stdout.on("data", (chunk) => sshStdout.push(Buffer.from(chunk))); ssh.stderr.on("data", (chunk) => sshStderr.push(Buffer.from(chunk))); const fail = (error: unknown) => { tar.kill("SIGKILL"); ssh.kill("SIGKILL"); reject(error); }; tar.on("error", fail); ssh.on("error", fail); tar.stdout.pipe(ssh.stdin); tar.on("close", (code) => { tarClosed = true; tarCode = code ?? 0; maybeResolve(); }); ssh.on("close", (code) => { sshClosed = true; sshCode = code ?? 0; maybeResolve(); }); function maybeResolve() { if (!tarClosed || !sshClosed) { return; } if (tarCode !== 0) { reject( new Error( Buffer.concat(tarStderr).toString("utf8").trim() || `tar exited with code ${tarCode}`, ), ); return; } if (sshCode !== 0) { reject( new Error( Buffer.concat(sshStderr).toString("utf8").trim() || `ssh exited with code ${sshCode}`, ), ); return; } resolve(); } }); } function parseSshConfigHost(configText: string): string | null { const hostMatch = configText.match(/^\s*Host\s+(\S+)/m); return hostMatch?.[1]?.trim() || null; } function resolveSshTmpRoot(): string { return path.resolve(resolvePreferredOpenClawTmpDir() ?? os.tmpdir()); } function resolveOptionalLocalPath(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed ? resolveUserPath(trimmed) : undefined; } async function writeSecretMaterial( dir: string, filename: string, contents: string, ): Promise { const pathname = path.join(dir, filename); await fs.writeFile(pathname, normalizeInlineSshMaterial(contents, filename), { encoding: "utf8", mode: 0o600, }); await fs.chmod(pathname, 0o600); return pathname; }