diff --git a/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift b/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift index bf80f5743..5edcfc564 100644 --- a/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift +++ b/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift @@ -64,6 +64,7 @@ struct GatewayCommandResolution { enum GatewayEnvironment { private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.env") private static let supportedBindModes: Set = ["loopback", "tailnet", "lan", "auto"] + private static let bundledGatewayLabel = "Bundled gateway" static func bundledGatewayExecutable() -> String? { guard let res = Bundle.main.resourceURL else { return nil } @@ -116,9 +117,9 @@ enum GatewayEnvironment { let bundledNode = self.bundledNodeExecutable() let bundledNodeVersion = bundledNode.flatMap { self.readNodeVersion(binary: $0) } if let expected, let installed, !installed.compatible(with: expected) { - let message = - "Bundled gateway \(installed.description) is incompatible with app " + - "\(expected.description); rebuild the app bundle." + let message = self.bundledGatewayIncompatibleMessage( + installed: installed, + expected: expected) return GatewayEnvironmentStatus( kind: .incompatible(found: installed.description, required: expected.description), nodeVersion: bundledNodeVersion, @@ -132,7 +133,9 @@ enum GatewayEnvironment { nodeVersion: bundledNodeVersion, gatewayVersion: gatewayVersionText, requiredGateway: expected?.description, - message: "Bundled gateway \(gatewayVersionText) (node \(bundledNodeVersion ?? "unknown"))") + message: self.bundledGatewayStatusMessage( + gatewayVersion: gatewayVersionText, + nodeVersion: bundledNodeVersion)) } let projectRoot = CommandResolver.projectRoot() @@ -381,4 +384,19 @@ enum GatewayEnvironment { return nil } } + + private static func bundledGatewayStatusMessage( + gatewayVersion: String, + nodeVersion: String? + ) -> String { + "\(self.bundledGatewayLabel) \(gatewayVersion) (node \(nodeVersion ?? "unknown"))" + } + + private static func bundledGatewayIncompatibleMessage( + installed: Semver, + expected: Semver + ) -> String { + "\(self.bundledGatewayLabel) \(installed.description) is incompatible with app " + + "\(expected.description); rebuild the app bundle." + } } diff --git a/docs/docs.json b/docs/docs.json index a684807cb..19bb44dad 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -239,7 +239,11 @@ }, { "source": "/mac/bun", - "destination": "/platforms/mac/bun" + "destination": "/platforms/mac/bundled-gateway" + }, + { + "source": "/platforms/mac/bun", + "destination": "/platforms/mac/bundled-gateway" }, { "source": "/mac/canvas", @@ -709,7 +713,7 @@ "platforms/mac/remote", "platforms/mac/signing", "platforms/mac/release", - "platforms/mac/bun", + "platforms/mac/bundled-gateway", "platforms/mac/xpc", "platforms/mac/skills", "platforms/mac/peekaboo" diff --git a/docs/platforms/mac/bundled-gateway.md b/docs/platforms/mac/bundled-gateway.md new file mode 100644 index 000000000..6ddaf96c5 --- /dev/null +++ b/docs/platforms/mac/bundled-gateway.md @@ -0,0 +1,114 @@ +--- +summary: "Bundled gateway runtime: packaging, launchd, signing, and bundling" +read_when: + - Packaging Clawdbot.app + - Debugging the bundled gateway binary + - Changing relay bundling flags or codesigning +--- + +# Bundled Gateway (macOS) + +Goal: ship **Clawdbot.app** with a self-contained relay that can run the CLI and +Gateway daemon. No global `npm install -g clawdbot`, no system Node requirement. + +## What gets bundled + +App bundle layout: + +- `Clawdbot.app/Contents/Resources/Relay/node` + - Node runtime binary (downloaded during packaging, stripped for size) +- `Clawdbot.app/Contents/Resources/Relay/dist/` + - Compiled CLI/gateway payload from `pnpm exec tsc` +- `Clawdbot.app/Contents/Resources/Relay/node_modules/` + - Production dependencies staged via `pnpm deploy --prod --no-optional --legacy` +- `Clawdbot.app/Contents/Resources/Relay/clawdbot` + - Wrapper script that execs the bundled Node + dist entrypoint +- `Clawdbot.app/Contents/Resources/Relay/package.json` + - tiny “Pi runtime compatibility” file (see below, includes `"type": "module"`) +- `Clawdbot.app/Contents/Resources/Relay/skills/` + - Bundled skills payload (required for Pi tools) +- `Clawdbot.app/Contents/Resources/Relay/theme/` + - Pi TUI theme payload (optional, but strongly recommended) +- `Clawdbot.app/Contents/Resources/Relay/a2ui/` + - A2UI host assets (served by the gateway) +- `Clawdbot.app/Contents/Resources/Relay/control-ui/` + - Control UI build output (served by the gateway) + +Why the sidecar files matter: +- The embedded Pi runtime detects “bundled relay mode” and then looks for + `package.json` + `theme/` **next to `process.execPath`** (i.e. next to + `node`). Keep the sidecar files. + +## Build pipeline + +Packaging script: +- [`scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh) + +It builds: +- TS: `pnpm exec tsc` +- Swift app + helper: `swift build …` +- Relay payload: `pnpm deploy --prod --no-optional --legacy` + copy `dist/` +- Node runtime: downloads the latest Node release (override via `NODE_VERSION`) + +Important knobs: +- `NODE_VERSION=22.12.0` → pin a specific Node version +- `NODE_DIST_MIRROR=…` → mirror for downloads (default: nodejs.org) +- `STRIP_NODE=0` → keep symbols (default strips to reduce size) +- `BUNDLED_RUNTIME=bun` → switch the relay build back to Bun (`bun --compile`) + +Version injection: +- The relay wrapper exports `CLAWDBOT_BUNDLED_VERSION` so `--version` works + without reading `package.json` at runtime. + +## Launchd (Gateway as LaunchAgent) + +Label: +- `com.clawdbot.gateway` + +Plist location (per-user): +- `~/Library/LaunchAgents/com.clawdbot.gateway.plist` + +Manager: +- The macOS app owns LaunchAgent install/update for the bundled gateway. + +Behavior: +- “Clawdbot Active” enables/disables the LaunchAgent. +- App quit does **not** stop the gateway (launchd keeps it alive). +- CLI install (`clawdbot daemon install`) writes the same LaunchAgent; `--force` rewrites it. + +Logging: +- launchd stdout/err: `/tmp/clawdbot/clawdbot-gateway.log` + +Default LaunchAgent env: +- `CLAWDBOT_IMAGE_BACKEND=sips` (avoid sharp native addon inside the bundle) + +## Codesigning (hardened runtime + Node) + +Node uses JIT. The bundled runtime is signed with: +- `com.apple.security.cs.allow-jit` +- `com.apple.security.cs.allow-unsigned-executable-memory` + +This is applied by `scripts/codesign-mac-app.sh`. + +## Image processing + +To avoid shipping native `sharp` addons inside the bundle, the gateway defaults +to `/usr/bin/sips` for image ops when run from the app (via launchd env + wrapper). + +## Tests / smoke checks + +From a packaged app (local build): + +```bash +dist/Clawdbot.app/Contents/Resources/Relay/clawdbot --version + +CLAWDBOT_SKIP_PROVIDERS=1 \ +CLAWDBOT_SKIP_CANVAS_HOST=1 \ +dist/Clawdbot.app/Contents/Resources/Relay/clawdbot gateway --port 18999 --bind loopback +``` + +Then, in another shell: + +```bash +pnpm -s clawdbot gateway call health --url ws://127.0.0.1:18999 --timeout 3000 +``` diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index a9f1b9869..e6b3ce646 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -93,6 +93,6 @@ Safety: ## Related docs - [Gateway runbook](/gateway) -- [Bundled Node Gateway](/platforms/mac/bun) +- [Bundled Node Gateway](/platforms/mac/bundled-gateway) - [macOS permissions](/platforms/mac/permissions) - [Canvas](/platforms/mac/canvas) diff --git a/docs/start/hubs.md b/docs/start/hubs.md index 45cbe363c..e2d2ffe68 100644 --- a/docs/start/hubs.md +++ b/docs/start/hubs.md @@ -141,7 +141,7 @@ Use these hubs to discover every page, including deep dives and reference docs t - [macOS remote](https://docs.clawd.bot/platforms/mac/remote) - [macOS signing](https://docs.clawd.bot/platforms/mac/signing) - [macOS release](https://docs.clawd.bot/platforms/mac/release) -- [macOS bundled gateway (Node)](https://docs.clawd.bot/platforms/mac/bun) +- [macOS bundled gateway (Node)](https://docs.clawd.bot/platforms/mac/bundled-gateway) - [macOS XPC](https://docs.clawd.bot/platforms/mac/xpc) - [macOS skills](https://docs.clawd.bot/platforms/mac/skills) - [macOS Peekaboo](https://docs.clawd.bot/platforms/mac/peekaboo) diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index 6b7adb306..6671bffd5 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -198,27 +198,37 @@ download_node_binary() { rm -rf "$tmp_dir" } -stage_relay_payload() { +stage_relay_deps() { local relay_dir="$1" - if [[ "${SKIP_RELAY_DEPS:-0}" != "1" ]]; then - local stage_dir="$relay_dir/.relay-deploy" - rm -rf "$stage_dir" - mkdir -p "$stage_dir" - echo "📦 Staging relay dependencies (pnpm deploy --prod --no-optional --legacy)" - (cd "$ROOT_DIR" && pnpm --filter . deploy "$stage_dir" --prod --no-optional --legacy) - rm -rf "$relay_dir/node_modules" - cp -a "$stage_dir/node_modules" "$relay_dir/node_modules" - rm -rf "$stage_dir" - else + if [[ "${SKIP_RELAY_DEPS:-0}" == "1" ]]; then echo "📦 Skipping relay dependency staging (SKIP_RELAY_DEPS=1)" + return fi + local stage_dir="$relay_dir/.relay-deploy" + rm -rf "$stage_dir" + mkdir -p "$stage_dir" + echo "📦 Staging relay dependencies (pnpm deploy --prod --no-optional --legacy)" + (cd "$ROOT_DIR" && pnpm --filter . deploy "$stage_dir" --prod --no-optional --legacy) + rm -rf "$relay_dir/node_modules" + cp -a "$stage_dir/node_modules" "$relay_dir/node_modules" + rm -rf "$stage_dir" +} + +stage_relay_dist() { + local relay_dir="$1" echo "📦 Copying relay dist payload" rm -rf "$relay_dir/dist" cp -R "$ROOT_DIR/dist" "$relay_dir/dist" } +stage_relay_payload() { + local relay_dir="$1" + stage_relay_deps "$relay_dir" + stage_relay_dist "$relay_dir" +} + write_relay_wrapper() { local relay_dir="$1" local wrapper="$relay_dir/clawdbot" @@ -237,6 +247,61 @@ SH chmod +x "$wrapper" } +package_relay_bun() { + local relay_dir="$1" + RELAY_CMD="$relay_dir/clawdbot" + + if ! command -v bun >/dev/null 2>&1; then + echo "ERROR: bun missing. Install bun or set BUNDLED_RUNTIME=node." >&2 + exit 1 + fi + + echo "🧰 Building bundled relay (bun --compile)" + local relay_build_dir="$relay_dir/.relay-build" + rm -rf "$relay_build_dir" + mkdir -p "$relay_build_dir" + for arch in "${BUILD_ARCHS[@]}"; do + local relay_arch_out="$relay_build_dir/clawdbot-$arch" + build_relay_binary "$arch" "$relay_arch_out" + chmod +x "$relay_arch_out" + done + if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then + /usr/bin/lipo -create "$relay_build_dir"/clawdbot-* -output "$RELAY_CMD" + else + cp "$relay_build_dir/clawdbot-${BUILD_ARCHS[0]}" "$RELAY_CMD" + fi + rm -rf "$relay_build_dir" +} + +package_relay_node() { + local relay_dir="$1" + RELAY_CMD="$relay_dir/clawdbot" + + local node_version + node_version="$(resolve_node_version)" + echo "🧰 Preparing bundled Node runtime (v${node_version})" + local relay_node="$relay_dir/node" + local relay_node_build_dir="$relay_dir/.node-build" + rm -rf "$relay_node_build_dir" + mkdir -p "$relay_node_build_dir" + for arch in "${BUILD_ARCHS[@]}"; do + local node_arch_out="$relay_node_build_dir/node-$arch" + download_node_binary "$node_version" "$arch" "$node_arch_out" + done + if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then + /usr/bin/lipo -create "$relay_node_build_dir"/node-* -output "$relay_node" + else + cp "$relay_node_build_dir/node-${BUILD_ARCHS[0]}" "$relay_node" + fi + chmod +x "$relay_node" + if [[ "${STRIP_NODE:-1}" == "1" ]]; then + /usr/bin/strip -x "$relay_node" 2>/dev/null || true + fi + rm -rf "$relay_node_build_dir" + stage_relay_payload "$relay_dir" + write_relay_wrapper "$relay_dir" +} + validate_bundled_runtime() { case "$BUNDLED_RUNTIME" in node|bun) return 0 ;; @@ -362,52 +427,11 @@ RELAY_DIR="$APP_ROOT/Contents/Resources/Relay" if [[ "${SKIP_GATEWAY_PACKAGE:-0}" != "1" ]]; then validate_bundled_runtime mkdir -p "$RELAY_DIR" - RELAY_CMD="$RELAY_DIR/clawdbot" if [[ "$BUNDLED_RUNTIME" == "bun" ]]; then - if ! command -v bun >/dev/null 2>&1; then - echo "ERROR: bun missing. Install bun or set BUNDLED_RUNTIME=node." >&2 - exit 1 - fi - - echo "🧰 Building bundled relay (bun --compile)" - RELAY_BUILD_DIR="$RELAY_DIR/.relay-build" - rm -rf "$RELAY_BUILD_DIR" - mkdir -p "$RELAY_BUILD_DIR" - for arch in "${BUILD_ARCHS[@]}"; do - RELAY_ARCH_OUT="$RELAY_BUILD_DIR/clawdbot-$arch" - build_relay_binary "$arch" "$RELAY_ARCH_OUT" - chmod +x "$RELAY_ARCH_OUT" - done - if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then - /usr/bin/lipo -create "$RELAY_BUILD_DIR"/clawdbot-* -output "$RELAY_CMD" - else - cp "$RELAY_BUILD_DIR/clawdbot-${BUILD_ARCHS[0]}" "$RELAY_CMD" - fi - rm -rf "$RELAY_BUILD_DIR" + package_relay_bun "$RELAY_DIR" else - NODE_VERSION="$(resolve_node_version)" - echo "🧰 Preparing bundled Node runtime (v${NODE_VERSION})" - RELAY_NODE="$RELAY_DIR/node" - RELAY_NODE_BUILD_DIR="$RELAY_DIR/.node-build" - rm -rf "$RELAY_NODE_BUILD_DIR" - mkdir -p "$RELAY_NODE_BUILD_DIR" - for arch in "${BUILD_ARCHS[@]}"; do - NODE_ARCH_OUT="$RELAY_NODE_BUILD_DIR/node-$arch" - download_node_binary "$NODE_VERSION" "$arch" "$NODE_ARCH_OUT" - done - if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then - /usr/bin/lipo -create "$RELAY_NODE_BUILD_DIR"/node-* -output "$RELAY_NODE" - else - cp "$RELAY_NODE_BUILD_DIR/node-${BUILD_ARCHS[0]}" "$RELAY_NODE" - fi - chmod +x "$RELAY_NODE" - if [[ "${STRIP_NODE:-1}" == "1" ]]; then - /usr/bin/strip -x "$RELAY_NODE" 2>/dev/null || true - fi - rm -rf "$RELAY_NODE_BUILD_DIR" - stage_relay_payload "$RELAY_DIR" - write_relay_wrapper "$RELAY_DIR" + package_relay_node "$RELAY_DIR" fi echo "🧪 Verifying bundled relay (version)"