From bb7f4abd4b4176512c4cbafebeac5cfea5d380e6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 19 Dec 2025 19:20:41 +0100 Subject: [PATCH] feat(gateway): support bun-compiled embedded gateway --- src/agents/pi-tools.ts | 53 +++++++----- src/browser/screenshot.ts | 23 +++--- src/gateway/control-ui.ts | 2 +- src/gateway/server.ts | 27 +++++-- src/macos/gateway-daemon.ts | 135 +++++++++++++++++++++++++++++++ src/media/image-ops.ts | 155 ++++++++++++++++++++++++++++++++++++ src/version.ts | 23 +++++- src/web/media.ts | 17 ++-- 8 files changed, 380 insertions(+), 55 deletions(-) create mode 100644 src/macos/gateway-daemon.ts create mode 100644 src/media/image-ops.ts diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index f1eb24485..7b2279391 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -1,8 +1,8 @@ import type { AgentTool, AgentToolResult } from "@mariozechner/pi-ai"; import { bashTool, codingTools, readTool } from "@mariozechner/pi-coding-agent"; import type { TSchema } from "@sinclair/typebox"; -import sharp from "sharp"; +import { getImageMetadata, resizeToJpeg } from "../media/image-ops.js"; import { detectMime } from "../media/mime.js"; // TODO(steipete): Remove this wrapper once pi-mono ships file-magic MIME detection @@ -125,11 +125,9 @@ async function resizeImageBase64IfNeeded(params: { maxDimensionPx: number; }): Promise<{ base64: string; mimeType: string; resized: boolean }> { const buf = Buffer.from(params.base64, "base64"); - const img = sharp(buf, { failOnError: false }); - const meta = await img.metadata(); - - const width = meta.width; - const height = meta.height; + const meta = await getImageMetadata(buf); + const width = meta?.width; + const height = meta?.height; if ( typeof width !== "number" || typeof height !== "number" || @@ -138,23 +136,36 @@ async function resizeImageBase64IfNeeded(params: { return { base64: params.base64, mimeType: params.mimeType, resized: false }; } - const resized = img.resize({ - width: params.maxDimensionPx, - height: params.maxDimensionPx, - fit: "inside", - withoutEnlargement: true, - }); - const mime = params.mimeType.toLowerCase(); let out: Buffer; - if (mime === "image/jpeg" || mime === "image/jpg") { - out = await resized.jpeg({ quality: 85 }).toBuffer(); - } else if (mime === "image/webp") { - out = await resized.webp({ quality: 85 }).toBuffer(); - } else if (mime === "image/png") { - out = await resized.png().toBuffer(); - } else { - out = await resized.png().toBuffer(); + try { + const mod = (await import("sharp")) as unknown as { + default?: typeof import("sharp"); + }; + const sharp = mod.default ?? (mod as unknown as typeof import("sharp")); + const img = sharp(buf, { failOnError: false }).resize({ + width: params.maxDimensionPx, + height: params.maxDimensionPx, + fit: "inside", + withoutEnlargement: true, + }); + if (mime === "image/jpeg" || mime === "image/jpg") { + out = await img.jpeg({ quality: 85 }).toBuffer(); + } else if (mime === "image/webp") { + out = await img.webp({ quality: 85 }).toBuffer(); + } else if (mime === "image/png") { + out = await img.png().toBuffer(); + } else { + out = await img.png().toBuffer(); + } + } catch { + // Bun can't load sharp native addons. Fall back to a JPEG conversion. + out = await resizeToJpeg({ + buffer: buf, + maxSide: params.maxDimensionPx, + quality: 85, + withoutEnlargement: true, + }); } const sniffed = detectMime({ buffer: out.slice(0, 256) }); diff --git a/src/browser/screenshot.ts b/src/browser/screenshot.ts index 6c5005ff5..1c1e3039f 100644 --- a/src/browser/screenshot.ts +++ b/src/browser/screenshot.ts @@ -1,4 +1,4 @@ -import sharp from "sharp"; +import { getImageMetadata, resizeToJpeg } from "../media/image-ops.js"; export const DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE = 2000; export const DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES = 5 * 1024 * 1024; @@ -19,9 +19,9 @@ export async function normalizeBrowserScreenshot( Math.round(opts?.maxBytes ?? DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES), ); - const meta = await sharp(buffer, { failOnError: false }).metadata(); - const width = Number(meta.width ?? 0); - const height = Number(meta.height ?? 0); + const meta = await getImageMetadata(buffer); + const width = Number(meta?.width ?? 0); + const height = Number(meta?.height ?? 0); const maxDim = Math.max(width, height); if ( @@ -42,15 +42,12 @@ export async function normalizeBrowserScreenshot( for (const side of sideGrid) { for (const quality of qualities) { - const out = await sharp(buffer, { failOnError: false }) - .resize({ - width: side, - height: side, - fit: "inside", - withoutEnlargement: true, - }) - .jpeg({ quality, mozjpeg: true }) - .toBuffer(); + const out = await resizeToJpeg({ + buffer, + maxSide: side, + quality, + withoutEnlargement: true, + }); if (!smallest || out.byteLength < smallest.size) { smallest = { buffer: out, size: out.byteLength }; diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index db4a60dd9..3ac0449f4 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -3,7 +3,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; -const UI_PREFIX = "/ui/"; +const _UI_PREFIX = "/ui/"; const ROOT_PREFIX = "/"; function resolveControlUiRoot(): string | null { diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 6e236c680..ebd870db8 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -14,10 +14,6 @@ import { normalizeThinkLevel, normalizeVerboseLevel, } from "../auto-reply/thinking.js"; -import { - startBrowserControlServerFromConfig, - stopBrowserControlServer, -} from "../browser/server.js"; import { type CanvasHostServer, startCanvasHost, @@ -99,6 +95,23 @@ import { sendMessageWhatsApp } from "../web/outbound.js"; import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js"; import { buildMessageWithAttachments } from "./chat-attachments.js"; import { handleControlUiHttpRequest } from "./control-ui.js"; + +let stopBrowserControlServerIfStarted: (() => Promise) | null = null; + +async function startBrowserControlServerIfEnabled(): Promise { + if (process.env.CLAWDIS_SKIP_BROWSER_CONTROL_SERVER === "1") return; + // Lazy import to keep optional heavyweight deps (playwright/electron) out of + // embedded/daemon builds. + const spec = + process.env.CLAWDIS_BROWSER_CONTROL_MODULE ?? + // Intentionally not a static string literal so bun bundling can omit it. + // (The embedded gateway sets CLAWDIS_SKIP_BROWSER_CONTROL_SERVER=1.) + ["..", "browser", "server.js"].join("/"); + const mod = await import(spec); + stopBrowserControlServerIfStarted = mod.stopBrowserControlServer; + await mod.startBrowserControlServerFromConfig(defaultRuntime); +} + import { type ConnectParams, ErrorCodes, @@ -4040,7 +4053,7 @@ export async function startGatewayServer( defaultRuntime.log(`gateway log file: ${getResolvedLoggerSettings().file}`); // Start clawd browser control server (unless disabled via config). - void startBrowserControlServerFromConfig(defaultRuntime).catch((err) => { + void startBrowserControlServerIfEnabled().catch((err) => { logError(`gateway: clawd browser server failed to start: ${String(err)}`); }); @@ -4110,7 +4123,9 @@ export async function startGatewayServer( } } clients.clear(); - await stopBrowserControlServer().catch(() => {}); + if (stopBrowserControlServerIfStarted) { + await stopBrowserControlServerIfStarted().catch(() => {}); + } await Promise.allSettled(providerTasks); await new Promise((resolve) => wss.close(() => resolve())); await new Promise((resolve, reject) => diff --git a/src/macos/gateway-daemon.ts b/src/macos/gateway-daemon.ts new file mode 100644 index 000000000..8653708c6 --- /dev/null +++ b/src/macos/gateway-daemon.ts @@ -0,0 +1,135 @@ +#!/usr/bin/env node +import process from "node:process"; + +declare const __CLAWDIS_VERSION__: string; + +const BUNDLED_VERSION = + typeof __CLAWDIS_VERSION__ === "string" ? __CLAWDIS_VERSION__ : "0.0.0"; + +function argValue(args: string[], flag: string): string | undefined { + const idx = args.indexOf(flag); + if (idx < 0) return undefined; + const value = args[idx + 1]; + return value && !value.startsWith("-") ? value : undefined; +} + +function hasFlag(args: string[], flag: string): boolean { + return args.includes(flag); +} + +const args = process.argv.slice(2); + +if (hasFlag(args, "--version") || hasFlag(args, "-v")) { + // Match `clawdis --version` behavior for Swift env/version checks. + // Keep output a single line. + console.log(BUNDLED_VERSION); + process.exit(0); +} + +type GatewayWsLogStyle = "auto" | "full" | "compact"; + +const [ + { loadConfig }, + { startGatewayServer }, + { setGatewayWsLogStyle }, + { setVerbose }, + { defaultRuntime }, +] = await Promise.all([ + import("../config/config.js"), + import("../gateway/server.js"), + import("../gateway/ws-logging.js"), + import("../globals.js"), + import("../runtime.js"), +]); + +setVerbose(hasFlag(args, "--verbose")); + +const wsLogRaw = ( + hasFlag(args, "--compact") ? "compact" : argValue(args, "--ws-log") +) as string | undefined; +const wsLogStyle: GatewayWsLogStyle = + wsLogRaw === "compact" ? "compact" : wsLogRaw === "full" ? "full" : "auto"; +setGatewayWsLogStyle(wsLogStyle); + +const portRaw = + argValue(args, "--port") ?? process.env.CLAWDIS_GATEWAY_PORT ?? "18789"; +const port = Number.parseInt(portRaw, 10); +if (Number.isNaN(port) || port <= 0) { + defaultRuntime.error(`Invalid --port (${portRaw})`); + process.exit(1); +} + +const cfg = loadConfig(); +const bindRaw = + argValue(args, "--bind") ?? + process.env.CLAWDIS_GATEWAY_BIND ?? + cfg.gateway?.bind ?? + "loopback"; +const bind = + bindRaw === "loopback" || + bindRaw === "tailnet" || + bindRaw === "lan" || + bindRaw === "auto" + ? bindRaw + : null; +if (!bind) { + defaultRuntime.error( + 'Invalid --bind (use "loopback", "tailnet", "lan", or "auto")', + ); + process.exit(1); +} + +const token = argValue(args, "--token"); +if (token) process.env.CLAWDIS_GATEWAY_TOKEN = token; + +let server: Awaited> | null = null; +let shuttingDown = false; +let forceExitTimer: ReturnType | null = null; + +const shutdown = (signal: string) => { + process.removeListener("SIGTERM", onSigterm); + process.removeListener("SIGINT", onSigint); + + if (shuttingDown) { + defaultRuntime.log( + `gateway: received ${signal} during shutdown; exiting now`, + ); + process.exit(0); + } + shuttingDown = true; + defaultRuntime.log(`gateway: received ${signal}; shutting down`); + + forceExitTimer = setTimeout(() => { + defaultRuntime.error( + "gateway: shutdown timed out; exiting without full cleanup", + ); + process.exit(0); + }, 5000); + + void (async () => { + try { + await server?.close(); + } catch (err) { + defaultRuntime.error(`gateway: shutdown error: ${String(err)}`); + } finally { + if (forceExitTimer) clearTimeout(forceExitTimer); + process.exit(0); + } + })(); +}; + +const onSigterm = () => shutdown("SIGTERM"); +const onSigint = () => shutdown("SIGINT"); + +process.once("SIGTERM", onSigterm); +process.once("SIGINT", onSigint); + +try { + server = await startGatewayServer(port, { bind }); +} catch (err) { + defaultRuntime.error(`Gateway failed to start: ${String(err)}`); + process.exit(1); +} + +// Keep process alive +await new Promise(() => {}); diff --git a/src/media/image-ops.ts b/src/media/image-ops.ts new file mode 100644 index 000000000..da272623a --- /dev/null +++ b/src/media/image-ops.ts @@ -0,0 +1,155 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { runExec } from "../process/exec.js"; + +type Sharp = typeof import("sharp"); + +export type ImageMetadata = { + width: number; + height: number; +}; + +function isBun(): boolean { + return typeof (process.versions as { bun?: unknown }).bun === "string"; +} + +function prefersSips(): boolean { + return ( + process.env.CLAWDIS_IMAGE_BACKEND === "sips" || + (process.env.CLAWDIS_IMAGE_BACKEND !== "sharp" && + isBun() && + process.platform === "darwin") + ); +} + +async function loadSharp(): Promise<(buffer: Buffer) => ReturnType> { + const mod = (await import("sharp")) as unknown as { default?: Sharp }; + const sharp = mod.default ?? (mod as unknown as Sharp); + return (buffer) => sharp(buffer, { failOnError: false }); +} + +async function withTempDir(fn: (dir: string) => Promise): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-img-")); + try { + return await fn(dir); + } finally { + await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); + } +} + +async function sipsMetadataFromBuffer( + buffer: Buffer, +): Promise { + return await withTempDir(async (dir) => { + const input = path.join(dir, "in.img"); + await fs.writeFile(input, buffer); + const { stdout } = await runExec( + "/usr/bin/sips", + ["-g", "pixelWidth", "-g", "pixelHeight", input], + { + timeoutMs: 10_000, + maxBuffer: 512 * 1024, + }, + ); + const w = stdout.match(/pixelWidth:\s*([0-9]+)/); + const h = stdout.match(/pixelHeight:\s*([0-9]+)/); + if (!w?.[1] || !h?.[1]) return null; + const width = Number.parseInt(w[1], 10); + const height = Number.parseInt(h[1], 10); + if (!Number.isFinite(width) || !Number.isFinite(height)) return null; + if (width <= 0 || height <= 0) return null; + return { width, height }; + }); +} + +async function sipsResizeToJpeg(params: { + buffer: Buffer; + maxSide: number; + quality: number; +}): Promise { + return await withTempDir(async (dir) => { + const input = path.join(dir, "in.img"); + const output = path.join(dir, "out.jpg"); + await fs.writeFile(input, params.buffer); + await runExec( + "/usr/bin/sips", + [ + "-Z", + String(Math.max(1, Math.round(params.maxSide))), + "-s", + "format", + "jpeg", + "-s", + "formatOptions", + String(Math.max(1, Math.min(100, Math.round(params.quality)))), + input, + "--out", + output, + ], + { timeoutMs: 20_000, maxBuffer: 1024 * 1024 }, + ); + return await fs.readFile(output); + }); +} + +export async function getImageMetadata( + buffer: Buffer, +): Promise { + if (prefersSips()) { + return await sipsMetadataFromBuffer(buffer).catch(() => null); + } + + try { + const sharp = await loadSharp(); + const meta = await sharp(buffer).metadata(); + const width = Number(meta.width ?? 0); + const height = Number(meta.height ?? 0); + if (!Number.isFinite(width) || !Number.isFinite(height)) return null; + if (width <= 0 || height <= 0) return null; + return { width, height }; + } catch { + return null; + } +} + +export async function resizeToJpeg(params: { + buffer: Buffer; + maxSide: number; + quality: number; + withoutEnlargement?: boolean; +}): Promise { + if (prefersSips()) { + // Avoid enlarging by checking dimensions first (sips has no withoutEnlargement flag). + if (params.withoutEnlargement !== false) { + const meta = await getImageMetadata(params.buffer); + if (meta) { + const maxDim = Math.max(meta.width, meta.height); + if (maxDim > 0 && maxDim <= params.maxSide) { + return await sipsResizeToJpeg({ + buffer: params.buffer, + maxSide: maxDim, + quality: params.quality, + }); + } + } + } + return await sipsResizeToJpeg({ + buffer: params.buffer, + maxSide: params.maxSide, + quality: params.quality, + }); + } + + const sharp = await loadSharp(); + return await sharp(params.buffer) + .resize({ + width: params.maxSide, + height: params.maxSide, + fit: "inside", + withoutEnlargement: params.withoutEnlargement !== false, + }) + .jpeg({ quality: params.quality, mozjpeg: true }) + .toBuffer(); +} diff --git a/src/version.ts b/src/version.ts index 5ad0d68ba..a132c208e 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,7 +1,22 @@ import { createRequire } from "node:module"; -const require = createRequire(import.meta.url); -const pkg = require("../package.json") as { version?: string }; +declare const __CLAWDIS_VERSION__: string | undefined; -// Single source of truth for the current clawdis version (reads from package.json). -export const VERSION = pkg.version ?? "0.0.0"; +function readVersionFromPackageJson(): string | null { + try { + const require = createRequire(import.meta.url); + const pkg = require("../package.json") as { version?: string }; + return pkg.version ?? null; + } catch { + return null; + } +} + +// Single source of truth for the current clawdis version. +// - Embedded/bundled builds: injected define or env var. +// - Dev/npm builds: package.json. +export const VERSION = + (typeof __CLAWDIS_VERSION__ === "string" && __CLAWDIS_VERSION__) || + process.env.CLAWDIS_BUNDLED_VERSION || + readVersionFromPackageJson() || + "0.0.0"; diff --git a/src/web/media.ts b/src/web/media.ts index 83515704d..b1fc9412c 100644 --- a/src/web/media.ts +++ b/src/web/media.ts @@ -1,6 +1,5 @@ import fs from "node:fs/promises"; import path from "node:path"; -import sharp from "sharp"; import { isVerbose, logVerbose } from "../globals.js"; import { @@ -8,6 +7,7 @@ import { maxBytesForKind, mediaKindFromMime, } from "../media/constants.js"; +import { resizeToJpeg } from "../media/image-ops.js"; import { detectMime } from "../media/mime.js"; export async function loadWebMedia( @@ -130,15 +130,12 @@ export async function optimizeImageToJpeg( for (const side of sides) { for (const quality of qualities) { - const out = await sharp(buffer) - .resize({ - width: side, - height: side, - fit: "inside", - withoutEnlargement: true, - }) - .jpeg({ quality, mozjpeg: true }) - .toBuffer(); + const out = await resizeToJpeg({ + buffer, + maxSide: side, + quality, + withoutEnlargement: true, + }); const size = out.length; if (!smallest || size < smallest.size) { smallest = { buffer: out, size, resizeSide: side, quality };