From 623a8ee067208dd4e921e567aa9fbc24372bf9dc Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 6 Apr 2026 16:44:15 -0700 Subject: [PATCH] =?UTF-8?q?fix(cli):=20harden=20Docker=20rendering=20?= =?UTF-8?q?=E2=80=94=20shell=20injection,=20cleanup,=20Dockerfile=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace execSync string interpolation with execFileSync array form to eliminate shell injection surface on docker build/inspect commands - Use try/finally for temp directory cleanup instead of duplicated rmSync - Remove redundant docker info pre-check — dockerImageExists already fails with a clear error if Docker isn't running - Remove duplicate mkdirSync (output dir already created by caller) - Fix Dockerfile.render drift — add hf-render wrapper script so manual builds use chrome-headless-shell for deterministic BeginFrame rendering - Fix printRenderComplete TOCTOU — statSync directly with catch instead of existsSync guard - Remove unused imports (existsSync, execSync) Co-Authored-By: Claude Opus 4.6 (1M context) --- Dockerfile.render | 9 +++- packages/cli/src/commands/render.ts | 70 +++++++++++------------------ 2 files changed, 35 insertions(+), 44 deletions(-) diff --git a/Dockerfile.render b/Dockerfile.render index af45b5e17..2de8c8309 100644 --- a/Dockerfile.render +++ b/Dockerfile.render @@ -63,6 +63,13 @@ RUN npx --yes @puppeteer/browsers install chrome-headless-shell@stable \ # Install hyperframes CLI (bundles producer, engine, core) RUN npm install -g hyperframes@${HYPERFRAMES_VERSION} +# Wrapper script: resolves chrome-headless-shell path at build time, +# sets PRODUCER_HEADLESS_SHELL_PATH at runtime so the engine uses +# BeginFrame rendering instead of falling back to system Chromium. +RUN SHELL_PATH=$(find /root/.cache/puppeteer/chrome-headless-shell -name "chrome-headless-shell" -type f | head -1) \ + && printf '#!/bin/sh\nexport PRODUCER_HEADLESS_SHELL_PATH=%s\nexec hyperframes render "$@"\n' "$SHELL_PATH" > /usr/local/bin/hf-render \ + && chmod +x /usr/local/bin/hf-render + WORKDIR /project -ENTRYPOINT ["hyperframes", "render"] +ENTRYPOINT ["hf-render"] diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 4e3184fbc..6db29d552 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -1,6 +1,6 @@ import { defineCommand } from "citty"; import type { Example } from "./_examples.js"; -import { existsSync, mkdirSync, statSync, writeFileSync, rmSync } from "node:fs"; +import { mkdirSync, statSync, writeFileSync, rmSync } from "node:fs"; export const examples: Example[] = [ ["Render to MP4", "hyperframes render --output output.mp4"], @@ -11,7 +11,7 @@ export const examples: Example[] = [ ]; import { cpus, freemem, tmpdir } from "node:os"; import { resolve, dirname, join, basename } from "node:path"; -import { execSync, spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { resolveProject } from "../utils/project.js"; import { lintProject, shouldBlockRender } from "../utils/lintProject.js"; import { formatLintFindings } from "../utils/lintFormat.js"; @@ -271,18 +271,12 @@ interface RenderOptions { browserPath?: string; } -// ── Docker image name + Dockerfile template ──────────────────────────────── - const DOCKER_IMAGE_PREFIX = "hyperframes-renderer"; function dockerImageTag(version: string): string { return `${DOCKER_IMAGE_PREFIX}:${version}`; } -/** - * Generate the Dockerfile content for the render image. - * Installs the same hyperframes version as the running CLI. - */ function generateDockerfile(version: string): string { return `FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends \\ @@ -311,22 +305,15 @@ ENTRYPOINT ["hf-render"] `; } -/** - * Check if a Docker image exists locally. - */ function dockerImageExists(tag: string): boolean { try { - execSync(`docker image inspect ${tag}`, { stdio: "pipe", timeout: 10_000 }); + execFileSync("docker", ["image", "inspect", tag], { stdio: "pipe", timeout: 10_000 }); return true; } catch { return false; } } -/** - * Build the Docker render image if it doesn't already exist. - * Returns the image tag. - */ function ensureDockerImage(version: string, quiet: boolean): string { const tag = dockerImageTag(version); @@ -337,28 +324,25 @@ function ensureDockerImage(version: string, quiet: boolean): string { if (!quiet) console.log(c.dim(` Building Docker image: ${tag}...`)); - // Write Dockerfile to a temp directory const tmpDir = join(tmpdir(), `hyperframes-docker-${Date.now()}`); mkdirSync(tmpDir, { recursive: true }); const dockerfilePath = join(tmpDir, "Dockerfile"); writeFileSync(dockerfilePath, generateDockerfile(version)); - // Build for linux/amd64 — chrome-headless-shell doesn't ship ARM Linux - // binaries, so we use x86 emulation via Docker Desktop's Rosetta/QEMU. + // linux/amd64 forced — chrome-headless-shell doesn't ship ARM Linux binaries try { - execSync(`docker build --platform linux/amd64 -t ${tag} -f ${dockerfilePath} ${tmpDir}`, { - stdio: quiet ? "pipe" : "inherit", - timeout: 600_000, // 10 minutes - }); + execFileSync( + "docker", + ["build", "--platform", "linux/amd64", "-t", tag, "-f", dockerfilePath, tmpDir], + { stdio: quiet ? "pipe" : "inherit", timeout: 600_000 }, + ); } catch (error: unknown) { - // Clean up temp dir before throwing - rmSync(tmpDir, { recursive: true, force: true }); const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to build Docker image: ${message}`); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); } - rmSync(tmpDir, { recursive: true, force: true }); - if (!quiet) console.log(c.dim(` Docker image: ${tag} (built)`)); return tag; } @@ -370,27 +354,26 @@ async function renderDocker( ): Promise { const startTime = Date.now(); - // ── Verify Docker is available ─────────────────────────────────────────── + // ensureDockerImage calls `docker image inspect` which fails with a clear + // error if Docker isn't running — no need for a separate `docker info` check. + let imageTag: string; try { - execSync("docker info", { stdio: "pipe", timeout: 10_000 }); - } catch { + imageTag = ensureDockerImage(VERSION, options.quiet); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const isDockerMissing = /connect|not found|ENOENT/i.test(message); errorBox( - "Docker not available", - "Docker is not running or not installed.", - "Start Docker Desktop or install from https://docs.docker.com/get-docker/", + isDockerMissing ? "Docker not available" : "Docker image build failed", + message, + isDockerMissing + ? "Start Docker Desktop or install from https://docs.docker.com/get-docker/" + : "Check Docker is running: docker info", ); process.exit(1); } - // ── Build or reuse the render image ────────────────────────────────────── - const imageTag = ensureDockerImage(VERSION, options.quiet); - - // ── Prepare output directory ───────────────────────────────────────────── const outputDir = dirname(outputPath); const outputFilename = basename(outputPath); - mkdirSync(outputDir, { recursive: true }); - - // ── Run the render inside Docker ───────────────────────────────────────── const dockerArgs = [ "run", "--rm", @@ -567,9 +550,10 @@ function printRenderComplete(outputPath: string, elapsedMs: number, quiet: boole if (quiet) return; let fileSize = "unknown"; - if (existsSync(outputPath)) { - const stat = statSync(outputPath); - fileSize = formatBytes(stat.size); + try { + fileSize = formatBytes(statSync(outputPath).size); + } catch { + // file doesn't exist or is inaccessible } const duration = formatDuration(elapsedMs);