feat(gateway): support bun-compiled embedded gateway
This commit is contained in:
@@ -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) });
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
135
src/macos/gateway-daemon.ts
Normal 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
155
src/media/image-ops.ts
Normal 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();
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user