build(macos): add OPENCLAW_SKIP_MLX_TTS to unblock beta-toolchain builds (#123698)

The openclaw-mlx-tts voice helper pulls in the full mlx-swift Metal shader
stack, which some beta Xcode toolchains (e.g. Xcode 27 / macOS 27 SDK) cannot
compile: the metal compiler dies non-deterministically (a different .metal file
each run, 'Could not read serialized diagnostics file'). The main app builds
fine, so an unrelated dev/proof build should not be blocked by the helper.

Add OPENCLAW_SKIP_MLX_TTS=1 (matching the sibling SKIP_TSC/SKIP_UI_BUILD
toggles) to package the app without the voice helper, gating both the per-arch
build and the bundle copy. Refuse the flag for release builds, which must ship
the helper (notarization verifies it), so a skipped build can never become a
silently incomplete release.
This commit is contained in:
Peter Steinberger
2026-08-14 08:28:43 -07:00
committed by GitHub
parent d9211bf14d
commit f87580ceb8
3 changed files with 96 additions and 12 deletions
+26
View File
@@ -31,6 +31,12 @@ pnpm install
Outputs `dist/OpenClaw.app`. Without an Apple Developer ID certificate, the
script falls back to ad-hoc signing.
Set `OPENCLAW_SKIP_MLX_TTS=1` to package a dev/proof build without the local
MLX voice helper. This skips the `openclaw-mlx-tts` binary and its large
mlx-swift Metal shader stack, which some beta Xcode toolchains cannot compile.
The resulting app has no on-device MLX voice; it is rejected for `release`
builds, which must ship the helper.
For dev run modes, signing flags, and Team ID troubleshooting, see
[apps/macos/README.md](https://github.com/openclaw/openclaw/blob/main/apps/macos/README.md).
Fast dev loop from repo root: `scripts/restart-mac.sh` (add `--no-sign` for
@@ -70,6 +76,26 @@ xcrun swift --version
If versions don't match, update macOS/Xcode and re-run the build.
### Build fails: MLX voice helper Metal shaders
On a beta-only Xcode toolchain (for example Xcode 27 with the macOS 27 SDK),
only the `openclaw-mlx-tts` helper may fail while the main app builds fine. The
mlx-swift Metal compilation errors non-deterministically (a different `.metal`
file each run, `Could not read serialized diagnostics file` then a nonzero
`metal` exit), because the beta `metal` compiler and its separately downloaded
Metal Toolchain are still unstable. This is an upstream toolchain issue, not an
OpenClaw one.
If you do not need on-device MLX voice, skip the helper:
```bash
OPENCLAW_SKIP_MLX_TTS=1 ./scripts/package-mac-app.sh
```
Otherwise, install the Metal Toolchain
(`xcodebuild -downloadComponent MetalToolchain`) and build from a stable Xcode
release.
### App crashes on permission grant
If the app crashes when you try to allow **Speech Recognition** or
+31 -12
View File
@@ -25,6 +25,17 @@ MLX_TTS_HELPER_BUILD_ROOT="$MLX_TTS_HELPER_ROOT/.build"
BUNDLE_ID="${BUNDLE_ID:-ai.openclaw.mac.debug}"
PKG_VERSION="$(cd "$ROOT_DIR" && node -p "require('./package.json').version" 2>/dev/null || echo "0.0.0")"
BUILD_CONFIG="${BUILD_CONFIG:-debug}"
# OPENCLAW_SKIP_MLX_TTS=1 packages the app without the local MLX voice helper.
# The helper pulls in the full mlx-swift Metal shader stack, which some beta
# Xcode toolchains cannot compile (flaky `metal` diagnostics), needlessly
# blocking unrelated dev/proof builds. Release builds must always ship the
# helper (notarization verifies it), so refuse the skip there instead of
# producing a silently incomplete release bundle.
SKIP_MLX_TTS="${OPENCLAW_SKIP_MLX_TTS:-0}"
if [[ "$SKIP_MLX_TTS" == "1" && "$BUILD_CONFIG" == "release" ]]; then
echo "ERROR: OPENCLAW_SKIP_MLX_TTS is not allowed for release builds; the MLX voice helper must ship in release." >&2
exit 1
fi
BUILD_TS="$(openclaw_resolve_build_timestamp)"
if [[ "$BUILD_CONFIG" == "release" ]]; then
OPENCLAW_REQUIRE_BUILD_METADATA=1
@@ -431,8 +442,12 @@ for arch in "${BUILD_ARCHS[@]}"; do
echo "🔨 Building $PRODUCT ($BUILD_CONFIG) [$arch]"
run_with_locked_swift_packages swift build -c "$BUILD_CONFIG" --product "$PRODUCT" --build-path "$BUILD_PATH" --arch "$arch" -Xlinker -rpath -Xlinker @executable_path/../Frameworks
restore_swiftpm_resource_sources
echo "🔨 Building $MLX_TTS_HELPER_PRODUCT ($BUILD_CONFIG) [$arch]"
build_mlx_tts_helper "$arch"
if [[ "$SKIP_MLX_TTS" == "1" ]]; then
echo "🔇 Skipping $MLX_TTS_HELPER_PRODUCT (OPENCLAW_SKIP_MLX_TTS=1) — app will lack the local MLX voice helper [$arch]"
else
echo "🔨 Building $MLX_TTS_HELPER_PRODUCT ($BUILD_CONFIG) [$arch]"
build_mlx_tts_helper "$arch"
fi
done
BIN_PRIMARY="$(bin_for_arch "$PRIMARY_ARCH")"
@@ -490,17 +505,21 @@ chmod +x "$APP_ROOT/Contents/MacOS/OpenClaw"
# SwiftPM outputs ad-hoc signed binaries; strip the signature before install_name_tool to avoid warnings.
/usr/bin/codesign --remove-signature "$APP_ROOT/Contents/MacOS/OpenClaw" 2>/dev/null || true
echo "🚚 Copying MLX TTS helper"
cp "$(helper_bin_for_arch "$PRIMARY_ARCH")" "$APP_ROOT/Contents/MacOS/$MLX_TTS_HELPER_PRODUCT"
if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then
HELPER_BIN_INPUTS=()
for arch in "${BUILD_ARCHS[@]}"; do
HELPER_BIN_INPUTS+=("$(helper_bin_for_arch "$arch")")
done
/usr/bin/lipo -create "${HELPER_BIN_INPUTS[@]}" -output "$APP_ROOT/Contents/MacOS/$MLX_TTS_HELPER_PRODUCT"
if [[ "$SKIP_MLX_TTS" == "1" ]]; then
echo "🔇 Skipping MLX TTS helper copy (OPENCLAW_SKIP_MLX_TTS=1) — bundle omits Contents/MacOS/$MLX_TTS_HELPER_PRODUCT"
else
echo "🚚 Copying MLX TTS helper"
cp "$(helper_bin_for_arch "$PRIMARY_ARCH")" "$APP_ROOT/Contents/MacOS/$MLX_TTS_HELPER_PRODUCT"
if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then
HELPER_BIN_INPUTS=()
for arch in "${BUILD_ARCHS[@]}"; do
HELPER_BIN_INPUTS+=("$(helper_bin_for_arch "$arch")")
done
/usr/bin/lipo -create "${HELPER_BIN_INPUTS[@]}" -output "$APP_ROOT/Contents/MacOS/$MLX_TTS_HELPER_PRODUCT"
fi
chmod +x "$APP_ROOT/Contents/MacOS/$MLX_TTS_HELPER_PRODUCT"
/usr/bin/codesign --remove-signature "$APP_ROOT/Contents/MacOS/$MLX_TTS_HELPER_PRODUCT" 2>/dev/null || true
fi
chmod +x "$APP_ROOT/Contents/MacOS/$MLX_TTS_HELPER_PRODUCT"
/usr/bin/codesign --remove-signature "$APP_ROOT/Contents/MacOS/$MLX_TTS_HELPER_PRODUCT" 2>/dev/null || true
SPARKLE_FRAMEWORK_PRIMARY="$(sparkle_framework_for_arch "$PRIMARY_ARCH")"
if [ -d "$SPARKLE_FRAMEWORK_PRIMARY" ]; then
+39
View File
@@ -827,6 +827,45 @@ describe("package-mac-app plist stamping", () => {
}
});
it("skips the MLX TTS helper build and copy when OPENCLAW_SKIP_MLX_TTS=1", () => {
const script = readFileSync(scriptPath, "utf8");
// Both the per-arch build and the bundle copy are gated on the same flag so
// a skipped build never tries to copy a helper binary that was not built.
expect(script).toContain(
'if [[ "$SKIP_MLX_TTS" == "1" ]]; then\n echo "🔇 Skipping $MLX_TTS_HELPER_PRODUCT (OPENCLAW_SKIP_MLX_TTS=1)',
);
expect(script).toContain(
'if [[ "$SKIP_MLX_TTS" == "1" ]]; then\n echo "🔇 Skipping MLX TTS helper copy (OPENCLAW_SKIP_MLX_TTS=1)',
);
});
it("refuses OPENCLAW_SKIP_MLX_TTS for release builds but allows it for dev builds", () => {
const script = readFileSync(scriptPath, "utf8");
// Run the real guard snippet from the script (not a copy) so the release
// safety invariant stays coupled to source: release bundles must ship the
// voice helper, which notarization later verifies.
const guardStart = script.indexOf('SKIP_MLX_TTS="${OPENCLAW_SKIP_MLX_TTS:-0}"');
const guardEnd = script.indexOf("BUILD_TS=", guardStart);
expect(guardStart).toBeGreaterThanOrEqual(0);
expect(guardEnd).toBeGreaterThan(guardStart);
const guard = script.slice(guardStart, guardEnd);
const released = runHelper(
`set -euo pipefail\nexport OPENCLAW_SKIP_MLX_TTS=1\nBUILD_CONFIG=release\n${guard}\necho reached-build`,
);
expect(released.status).toBe(1);
expect(released.stderr).toContain("not allowed for release builds");
expect(released.stdout).not.toContain("reached-build");
const dev = runHelper(
`set -euo pipefail\nexport OPENCLAW_SKIP_MLX_TTS=1\nBUILD_CONFIG=debug\n${guard}\necho reached-build`,
);
expect(dev.status, dev.stderr).toBe(0);
expect(dev.stdout).toContain("reached-build");
});
it("falls back to corepack pnpm when the pnpm shim is absent", () => {
const helperBlock = getPackageManagerHelperBlock();
const tempRoot = tempDirs.make("openclaw-package-pnpm-root-");