feat(gateway): support bun-compiled embedded gateway

This commit is contained in:
Peter Steinberger
2025-12-19 19:20:41 +01:00
parent bd63b5a231
commit bb7f4abd4b
8 changed files with 380 additions and 55 deletions

View File

@@ -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) });

View File

@@ -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 };

View File

@@ -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 {

View File

@@ -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<void>) | null = null;
async function startBrowserControlServerIfEnabled(): Promise<void> {
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<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve, reject) =>

135
src/macos/gateway-daemon.ts Normal file
View File

@@ -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<ReturnType<typeof startGatewayServer>> | null = null;
let shuttingDown = false;
let forceExitTimer: ReturnType<typeof setTimeout> | 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<never>(() => {});

155
src/media/image-ops.ts Normal file
View File

@@ -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<Sharp>> {
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<T>(fn: (dir: string) => Promise<T>): Promise<T> {
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<ImageMetadata | null> {
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<Buffer> {
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<ImageMetadata | null> {
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<Buffer> {
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();
}

View File

@@ -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";

View File

@@ -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 };