diff --git a/.gitignore b/.gitignore
index cc8b2a28..ffd26e9d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,8 @@ __pycache__/
*.egg-info/
dist/
build/
+!mac/build/
+mac/launcher/.build/
*.db
.superpowers/
.worktrees/
diff --git a/mac/README.md b/mac/README.md
new file mode 100644
index 00000000..b246ae90
--- /dev/null
+++ b/mac/README.md
@@ -0,0 +1,17 @@
+# taOS Mac App build
+
+Build pipeline for the macOS `.app` bundle (Apple Silicon, macOS 26+).
+
+See `docs/superpowers/specs/2026-04-28-taos-mac-app-c1-design.md` for the full design.
+
+## Quick start
+
+ ./mac/build/build.sh --version 0.1.0 --output dist/
+
+Produces `dist/taOS.app`, `dist/taOS-0.1.0.dmg`, and `dist/taOS-0.1.0.dmg.sig`.
+
+## Layout
+
+- `launcher/` — Swift Package for the native launcher
+- `build/` — shell pipeline (orchestrator + step scripts)
+- `appcast/` — Sparkle appcast.xml + EdDSA public key
diff --git a/mac/appcast/appcast.xml b/mac/appcast/appcast.xml
new file mode 100644
index 00000000..f7b9f8f4
--- /dev/null
+++ b/mac/appcast/appcast.xml
@@ -0,0 +1,9 @@
+
+
+
+ taOS Updates
+ https://taos.app/appcast.xml
+ Sparkle feed for taOS
+ en
+
+
diff --git a/mac/build/RELEASE_TESTING.md b/mac/build/RELEASE_TESTING.md
new file mode 100644
index 00000000..1b789b48
--- /dev/null
+++ b/mac/build/RELEASE_TESTING.md
@@ -0,0 +1,61 @@
+# taOS Mac App — Release Testing Checklist
+
+Run on a fresh macOS 26 install (or a wiped data dir) before tagging a release.
+
+## 1. Fresh install
+
+- [ ] Open `taOS-X.Y.Z.dmg` → drag to Applications.
+- [ ] Right-click `/Applications/taOS.app` → Open → confirm "open" in Gatekeeper dialog.
+- [ ] Status-bar `taOS` icon appears.
+- [ ] First-run wizard opens in **phone-shape mode**.
+- [ ] Setup completes; Dock icon disappears after closing the window.
+
+## 2. Container creation
+
+- [ ] Create a project, add an agent.
+- [ ] Open Terminal: `container ls` shows the new container.
+- [ ] Send chat → response received.
+- [ ] Trace store entry shows `host.containers.internal:4000` reachable.
+
+## 3. Window-mode toggle
+
+- [ ] `Cmd+Shift+M` switches phone ↔ fullscreen.
+- [ ] In fullscreen, `Cmd+Q` is intercepted (no quit).
+- [ ] In fullscreen, `Ctrl+→` moves to the next Space.
+- [ ] In fullscreen, three-finger swipe moves Spaces.
+
+## 4. Quit + relaunch
+
+- [ ] Close window with the red traffic light.
+- [ ] Dock icon hides; status-bar icon stays.
+- [ ] `~/Library/Logs/taOS/server.log` keeps writing.
+- [ ] "Quit taOS" from status bar → graceful shutdown logged → process gone.
+- [ ] Relaunch → restores last window mode + last project.
+
+## 5. Update path
+
+- [ ] Stage a fake v0.1.1 DMG.
+- [ ] Serve a local appcast: `cd staging && python -m http.server 8000`.
+- [ ] In test build, set `SUFeedURL` to `http://127.0.0.1:8000/appcast.xml`.
+- [ ] Sparkle prompts → EdDSA verifies → relaunches at v0.1.1.
+- [ ] Re-run with a tampered DMG → install refused; `~/Library/Logs/taOS/sparkle.log` has a verification-failed entry; app stays on v0.1.0.
+
+## 6. Failure isolation
+
+- [ ] Kill FastAPI from Activity Monitor → status-bar icon goes yellow → "Restart Server" works.
+- [ ] Stop the container daemon → existing containers listed but new creates fail with the documented note → restart daemon → recovers.
+- [ ] Disconnect network → Sparkle silently skips next scheduled check.
+
+## 7. Uninstall
+
+- [ ] Trash `/Applications/taOS.app`.
+- [ ] `~/Library/Application Support/taOS/` untouched.
+- [ ] Reinstall the same DMG → no setup wizard, picks up where it left off.
+- [ ] `brew uninstall --cask --zap taos` (when Cask exists) wipes the four `~/Library/...` dirs.
+
+## Sign-off
+
+Tester: ______________ Date: ______________
+Version: ______________ All boxes checked: yes / no
+
+Failures: file as GitHub issues with `mac-release` label and block release until resolved.
diff --git a/mac/build/assemble_bundle.sh b/mac/build/assemble_bundle.sh
new file mode 100755
index 00000000..06b2bc8f
--- /dev/null
+++ b/mac/build/assemble_bundle.sh
@@ -0,0 +1,92 @@
+#!/usr/bin/env bash
+# Build taOS.app/Contents/ from staging dirs.
+#
+# Args: --version --staging --launcher-binary --output
+set -euo pipefail
+
+VERSION=""
+STAGING=""
+LAUNCHER_BINARY=""
+OUTPUT=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --version) VERSION="$2"; shift 2 ;;
+ --staging) STAGING="$2"; shift 2 ;;
+ --launcher-binary) LAUNCHER_BINARY="$2"; shift 2 ;;
+ --output) OUTPUT="$2"; shift 2 ;;
+ *) echo "assemble_bundle.sh: unknown arg $1" >&2; exit 2 ;;
+ esac
+done
+
+[[ -n "$VERSION" && -n "$STAGING" && -n "$LAUNCHER_BINARY" && -n "$OUTPUT" ]] \
+ || { echo "all args required" >&2; exit 2; }
+
+REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+APP="$OUTPUT/taOS.app"
+CONTENTS="$APP/Contents"
+
+rm -rf "$APP"
+mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" "$CONTENTS/Frameworks"
+
+# Info.plist
+ED_KEY_FILE="$REPO_ROOT/mac/appcast/ed_public.pem"
+if [[ -f "$ED_KEY_FILE" ]]; then
+ SU_PUBLIC_ED_KEY="$(grep -v '^-----' "$ED_KEY_FILE" | tr -d '\n')"
+else
+ echo "[assemble_bundle] no ed_public.pem — Sparkle will be disabled in this build"
+ SU_PUBLIC_ED_KEY=""
+fi
+sed -e "s|\${VERSION}|$VERSION|g" \
+ -e "s|\${SU_PUBLIC_ED_KEY}|$SU_PUBLIC_ED_KEY|g" \
+ "$REPO_ROOT/mac/launcher/Sources/taOSLauncher/Resources/Info.plist.in" \
+ > "$CONTENTS/Info.plist"
+
+echo -n "APPL????" > "$CONTENTS/PkgInfo"
+
+# Launcher binary
+cp "$LAUNCHER_BINARY" "$CONTENTS/MacOS/taOS"
+chmod +x "$CONTENTS/MacOS/taOS"
+
+# Python distribution
+cp -R "$STAGING/python" "$CONTENTS/Resources/python"
+
+# taOS source tree
+mkdir -p "$CONTENTS/Resources/taos"
+cp -R "$REPO_ROOT/tinyagentos" "$CONTENTS/Resources/taos/tinyagentos"
+find "$CONTENTS/Resources/taos" -type d -name __pycache__ -exec rm -rf {} +
+find "$CONTENTS/Resources/taos" -type f -name "*.pyc" -delete
+cp "$REPO_ROOT/pyproject.toml" "$CONTENTS/Resources/taos/pyproject.toml"
+
+# Bundle the data/ skeleton (config example, seed agents, templates) so the
+# launcher can copy it into ~/Library/Application Support/taOS on first run.
+if [[ -d "$REPO_ROOT/data" ]]; then
+ cp -R "$REPO_ROOT/data" "$CONTENTS/Resources/taos/data"
+fi
+# Bundle the app-catalog so backend auto-registration can find service manifests.
+if [[ -d "$REPO_ROOT/app-catalog" ]]; then
+ cp -R "$REPO_ROOT/app-catalog" "$CONTENTS/Resources/taos/app-catalog"
+fi
+
+# Frontend
+cp -R "$STAGING/frontend" "$CONTENTS/Resources/frontend"
+
+# Apple container CLI + libexec plugins (image/network/runtime)
+mkdir -p "$CONTENTS/Resources/bin"
+cp "$STAGING/bin/container" "$CONTENTS/Resources/bin/container"
+chmod +x "$CONTENTS/Resources/bin/container"
+if [[ -d "$STAGING/libexec/container" ]]; then
+ mkdir -p "$CONTENTS/Resources/libexec"
+ cp -R "$STAGING/libexec/container" "$CONTENTS/Resources/libexec/container"
+fi
+
+# Sparkle.framework — fetched/extracted by build.sh prior
+if [[ -d "$STAGING/Sparkle.framework" ]]; then
+ cp -R "$STAGING/Sparkle.framework" "$CONTENTS/Frameworks/Sparkle.framework"
+fi
+
+# AppIcon
+if [[ -f "$STAGING/AppIcon.icns" ]]; then
+ cp "$STAGING/AppIcon.icns" "$CONTENTS/Resources/AppIcon.icns"
+fi
+
+echo "[assemble_bundle] done: $APP"
diff --git a/mac/build/build.sh b/mac/build/build.sh
new file mode 100755
index 00000000..5f516264
--- /dev/null
+++ b/mac/build/build.sh
@@ -0,0 +1,89 @@
+#!/usr/bin/env bash
+# Orchestrate the full build: launcher, python, frontend, container CLI,
+# bundle, sign, DMG, sparkle-sign, notarize.
+#
+# Args:
+# --version
+# --python-version
+# --container-cli-version
+# --output
+set -euo pipefail
+
+VERSION=""
+PYTHON_VER="3.12.13"
+CLI_VER="0.12.0"
+OUTPUT="dist"
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --version) VERSION="$2"; shift 2 ;;
+ --python-version) PYTHON_VER="$2"; shift 2 ;;
+ --container-cli-version) CLI_VER="$2"; shift 2 ;;
+ --output) OUTPUT="$2"; shift 2 ;;
+ *) echo "build.sh: unknown arg $1" >&2; exit 2 ;;
+ esac
+done
+
+[[ -n "$VERSION" ]] || { echo "--version required" >&2; exit 2; }
+
+REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+SCRIPT_DIR="$REPO_ROOT/mac/build"
+STAGING="$REPO_ROOT/$OUTPUT/staging"
+
+echo "[build] env validation"
+command -v swift >/dev/null || { echo "swift not found" >&2; exit 1; }
+command -v create-dmg >/dev/null || { echo "create-dmg not found (brew install create-dmg)" >&2; exit 1; }
+
+mkdir -p "$REPO_ROOT/$OUTPUT"
+rm -rf "$STAGING"
+mkdir -p "$STAGING"
+
+echo "[build] (1/9) launcher"
+cd "$REPO_ROOT/mac/launcher"
+swift build -c release --arch arm64
+LAUNCHER_BINARY="$REPO_ROOT/mac/launcher/.build/arm64-apple-macosx/release/taOSLauncher"
+cd "$REPO_ROOT"
+
+echo "[build] (2/9) python"
+"$SCRIPT_DIR/build_python.sh" --version "$PYTHON_VER" --output "$STAGING"
+
+echo "[build] (3/9) frontend"
+"$SCRIPT_DIR/build_frontend.sh" --output "$STAGING"
+
+echo "[build] (4/9) container CLI"
+"$SCRIPT_DIR/fetch_container_cli.sh" --version "$CLI_VER" --output "$STAGING"
+
+echo "[build] (5/9) assemble bundle"
+"$SCRIPT_DIR/assemble_bundle.sh" \
+ --version "$VERSION" \
+ --staging "$STAGING" \
+ --launcher-binary "$LAUNCHER_BINARY" \
+ --output "$REPO_ROOT/$OUTPUT"
+
+APP="$REPO_ROOT/$OUTPUT/taOS.app"
+
+echo "[build] (6/9) sign"
+"$SCRIPT_DIR/sign.sh" --app "$APP"
+
+echo "[build] (7/9) package DMG"
+"$SCRIPT_DIR/package_dmg.sh" --app "$APP" --version "$VERSION" --output "$REPO_ROOT/$OUTPUT"
+DMG="$REPO_ROOT/$OUTPUT/taOS-$VERSION.dmg"
+
+echo "[build] (8/9) notarize"
+"$SCRIPT_DIR/notarize.sh" --dmg "$DMG"
+
+echo "[build] (9/9) sparkle-sign"
+SPARKLE_KEY="${SPARKLE_ED_PRIVATE_KEY:-$HOME/.taos/sparkle_ed_private.pem}"
+if [[ -f "$SPARKLE_KEY" ]]; then
+ "$SCRIPT_DIR/sparkle_sign.sh" --dmg "$DMG" --version "$VERSION" --output "$REPO_ROOT/$OUTPUT"
+else
+ echo "[sparkle-sign] skipped — no Sparkle EdDSA private key at $SPARKLE_KEY"
+fi
+
+# Clean staging on success
+rm -rf "$STAGING"
+
+echo "[build] done"
+echo " app: $APP"
+echo " dmg: $DMG"
+echo " sig: ${DMG}.sig"
+echo " appcast snippet: $REPO_ROOT/$OUTPUT/appcast-snippet.xml"
diff --git a/mac/build/build_frontend.sh b/mac/build/build_frontend.sh
new file mode 100755
index 00000000..c68e37c0
--- /dev/null
+++ b/mac/build/build_frontend.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+# Build the Vite frontend and copy output to staging.
+#
+# Args: --output
+set -euo pipefail
+
+OUTPUT=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --output) OUTPUT="$2"; shift 2 ;;
+ *) echo "build_frontend.sh: unknown arg $1" >&2; exit 2 ;;
+ esac
+done
+
+[[ -n "$OUTPUT" ]] || { echo "--output required" >&2; exit 2; }
+
+REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+
+cd "$REPO_ROOT/desktop"
+echo "[build_frontend] npm ci"
+npm ci
+echo "[build_frontend] npm run build"
+npm run build
+
+# Vite is configured (desktop/vite.config.ts) to write to ../static/desktop
+DIST="$REPO_ROOT/static/desktop"
+[[ -d "$DIST" ]] || { echo "[build_frontend] missing $DIST" >&2; exit 1; }
+
+mkdir -p "$OUTPUT/frontend"
+cp -R "$DIST"/. "$OUTPUT/frontend/"
+echo "[build_frontend] done: $OUTPUT/frontend"
diff --git a/mac/build/build_python.sh b/mac/build/build_python.sh
new file mode 100755
index 00000000..e88d5f30
--- /dev/null
+++ b/mac/build/build_python.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+# Build the embedded Python distribution from python-build-standalone.
+#
+# Args: --version --output
+# Output: $STAGING_DIR/python/{bin,lib,...}
+set -euo pipefail
+
+PYTHON_VER=""
+OUTPUT=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --version) PYTHON_VER="$2"; shift 2 ;;
+ --output) OUTPUT="$2"; shift 2 ;;
+ *) echo "build_python.sh: unknown arg $1" >&2; exit 2 ;;
+ esac
+done
+
+[[ -n "$PYTHON_VER" ]] || { echo "--version required" >&2; exit 2; }
+[[ -n "$OUTPUT" ]] || { echo "--output required" >&2; exit 2; }
+
+REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+
+# Latest stable release naming convention from astral-sh/python-build-standalone
+TAG="20260414"
+CHECKSUM_FILE="$REPO_ROOT/mac/build/checksums/python-build-standalone-${PYTHON_VER}+${TAG}.sha256"
+[[ -f "$CHECKSUM_FILE" ]] || {
+ echo "[build_python] missing checksum file for ${PYTHON_VER}+${TAG}: $CHECKSUM_FILE" >&2
+ exit 2
+}
+URL="https://github.com/astral-sh/python-build-standalone/releases/download/${TAG}/cpython-${PYTHON_VER}+${TAG}-aarch64-apple-darwin-install_only.tar.gz"
+
+mkdir -p "$OUTPUT"
+TARBALL="$OUTPUT/python-${PYTHON_VER}.tar.gz"
+
+echo "[build_python] downloading $URL"
+curl -L --fail -o "$TARBALL" "$URL"
+
+EXPECTED_SHA="$(cat "$CHECKSUM_FILE")"
+ACTUAL_SHA="$(shasum -a 256 "$TARBALL" | awk '{print $1}')"
+if [[ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]]; then
+ echo "[build_python] SHA mismatch: expected $EXPECTED_SHA got $ACTUAL_SHA" >&2
+ exit 1
+fi
+
+echo "[build_python] extracting"
+mkdir -p "$OUTPUT/python"
+tar -xzf "$TARBALL" -C "$OUTPUT/python" --strip-components=1
+rm "$TARBALL"
+
+PYBIN="$OUTPUT/python/bin/python3"
+"$PYBIN" -m pip install --no-deps -r "$REPO_ROOT/tinyagentos/requirements.lock"
+
+echo "[build_python] done: $OUTPUT/python/bin/python3"
diff --git a/mac/build/bump_version.sh b/mac/build/bump_version.sh
new file mode 100755
index 00000000..edd93330
--- /dev/null
+++ b/mac/build/bump_version.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+# Bump version across Info.plist.in, pyproject.toml, frontend/package.json, CHANGELOG.md.
+#
+# Usage: bump_version.sh
+set -euo pipefail
+
+NEW_VER="${1:-}"
+[[ -n "$NEW_VER" ]] || { echo "usage: bump_version.sh " >&2; exit 2; }
+
+REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+cd "$REPO_ROOT"
+
+# Info.plist.in: replace ${VERSION} marker is left for assemble_bundle. Real version in CFBundleShortVersionString is templated. We bump a sidecar file to track.
+echo "$NEW_VER" > mac/build/.version
+
+# pyproject.toml: update version = "..."
+sed -i.bak -E "s/^version = \".*\"/version = \"$NEW_VER\"/" pyproject.toml
+rm -f pyproject.toml.bak
+
+# frontend/package.json
+node -e "const f='frontend/package.json'; const j=require('./'+f); j.version='$NEW_VER'; require('fs').writeFileSync(f, JSON.stringify(j,null,2)+'\n');"
+
+# CHANGELOG.md: prepend a new section if not present
+if ! grep -q "^## $NEW_VER" CHANGELOG.md 2>/dev/null; then
+ TMP="$(mktemp)"
+ {
+ echo "## $NEW_VER"
+ echo ""
+ echo "- TODO: fill in release notes"
+ echo ""
+ [[ -f CHANGELOG.md ]] && cat CHANGELOG.md
+ } > "$TMP"
+ mv "$TMP" CHANGELOG.md
+fi
+
+echo "[bump_version] -> $NEW_VER"
diff --git a/mac/build/checksums/.gitkeep b/mac/build/checksums/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/mac/build/checksums/apple-container-cli-0.12.0.sha256 b/mac/build/checksums/apple-container-cli-0.12.0.sha256
new file mode 100644
index 00000000..37866f71
--- /dev/null
+++ b/mac/build/checksums/apple-container-cli-0.12.0.sha256
@@ -0,0 +1 @@
+f56598208aedb65e5d6664e767b3926a3ca3af6057935df336e7d854ac344a44
diff --git a/mac/build/checksums/python-build-standalone-3.12.13+20260414.sha256 b/mac/build/checksums/python-build-standalone-3.12.13+20260414.sha256
new file mode 100644
index 00000000..e18b90d2
--- /dev/null
+++ b/mac/build/checksums/python-build-standalone-3.12.13+20260414.sha256
@@ -0,0 +1 @@
+8966b2bcd9fa03ba22c080ad15a86bc12e41a00122b16f4b3740e302261124d9
diff --git a/mac/build/fetch_container_cli.sh b/mac/build/fetch_container_cli.sh
new file mode 100755
index 00000000..74341ae4
--- /dev/null
+++ b/mac/build/fetch_container_cli.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+# Fetch and verify the apple/container CLI release pkg, extract the
+# bundled binary + libexec plugins.
+#
+# Args: --version --output
+set -euo pipefail
+
+CLI_VER=""
+OUTPUT=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --version) CLI_VER="$2"; shift 2 ;;
+ --output) OUTPUT="$2"; shift 2 ;;
+ *) echo "fetch_container_cli.sh: unknown arg $1" >&2; exit 2 ;;
+ esac
+done
+
+[[ -n "$CLI_VER" ]] || { echo "--version required" >&2; exit 2; }
+[[ -n "$OUTPUT" ]] || { echo "--output required" >&2; exit 2; }
+
+REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+CHECKSUM_FILE="$REPO_ROOT/mac/build/checksums/apple-container-cli-${CLI_VER}.sha256"
+[[ -f "$CHECKSUM_FILE" ]] || {
+ echo "[fetch_container_cli] missing checksum file for ${CLI_VER}: $CHECKSUM_FILE" >&2
+ exit 2
+}
+
+URL="https://github.com/apple/container/releases/download/${CLI_VER}/container-${CLI_VER}-installer-signed.pkg"
+mkdir -p "$OUTPUT"
+PKG="$OUTPUT/container-${CLI_VER}.pkg"
+
+echo "[fetch_container_cli] downloading $URL"
+curl -L --fail -o "$PKG" "$URL"
+
+EXPECTED_SHA="$(cat "$CHECKSUM_FILE")"
+ACTUAL_SHA="$(shasum -a 256 "$PKG" | awk '{print $1}')"
+if [[ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]]; then
+ echo "[fetch_container_cli] SHA mismatch: expected $EXPECTED_SHA got $ACTUAL_SHA" >&2
+ exit 1
+fi
+
+WORK="$OUTPUT/.container-extract"
+rm -rf "$WORK"
+mkdir -p "$WORK"
+
+echo "[fetch_container_cli] extracting pkg"
+(cd "$WORK" && xar -xf "$PKG")
+# The pkg's Payload is a gzipped cpio archive at $WORK/Payload (case-sensitive
+# name — note this collides with a directory called "payload" on case-insensitive
+# APFS, so we extract into a separately-named sibling directory).
+mkdir -p "$WORK/extracted"
+(cd "$WORK/extracted" && gunzip -dc "$WORK/Payload" | cpio -i --quiet)
+
+mkdir -p "$OUTPUT/bin" "$OUTPUT/libexec"
+cp "$WORK/extracted/bin/container" "$OUTPUT/bin/container"
+chmod +x "$OUTPUT/bin/container"
+cp -R "$WORK/extracted/libexec/container" "$OUTPUT/libexec/container"
+
+rm -rf "$WORK" "$PKG"
+
+echo "[fetch_container_cli] done: $OUTPUT/bin/container"
diff --git a/mac/build/generate_icon_placeholder.sh b/mac/build/generate_icon_placeholder.sh
new file mode 100755
index 00000000..ef9fe6d8
--- /dev/null
+++ b/mac/build/generate_icon_placeholder.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# Generate placeholder AppIcon.icns from a 1024x1024 PNG.
+#
+# Args: --source --output
+set -euo pipefail
+
+SRC=""
+OUT=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --source) SRC="$2"; shift 2 ;;
+ --output) OUT="$2"; shift 2 ;;
+ *) echo "generate_icon_placeholder.sh: unknown arg $1" >&2; exit 2 ;;
+ esac
+done
+
+[[ -n "$SRC" && -n "$OUT" ]] || { echo "all args required" >&2; exit 2; }
+
+ICONSET="$OUT/AppIcon.iconset"
+mkdir -p "$ICONSET"
+for size in 16 32 128 256 512; do
+ sips -z "$size" "$size" "$SRC" --out "$ICONSET/icon_${size}x${size}.png"
+ double=$((size * 2))
+ sips -z "$double" "$double" "$SRC" --out "$ICONSET/icon_${size}x${size}@2x.png"
+done
+iconutil -c icns "$ICONSET" -o "$OUT/AppIcon.icns"
+rm -rf "$ICONSET"
+echo "[icon] $OUT/AppIcon.icns"
diff --git a/mac/build/notarize.sh b/mac/build/notarize.sh
new file mode 100755
index 00000000..59a2176d
--- /dev/null
+++ b/mac/build/notarize.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+# Notarise the DMG via xcrun notarytool. v0.1: stub when no Dev ID.
+#
+# Args: --dmg
+set -euo pipefail
+
+DMG=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --dmg) DMG="$2"; shift 2 ;;
+ *) echo "notarize.sh: unknown arg $1" >&2; exit 2 ;;
+ esac
+done
+
+[[ -n "$DMG" ]] || { echo "--dmg required" >&2; exit 2; }
+
+if [[ -z "${DEV_ID:-}" || -z "${NOTARY_PROFILE:-}" ]]; then
+ echo "[notarize] skipped — no DEV_ID / NOTARY_PROFILE configured (v0.1)"
+ exit 0
+fi
+
+echo "[notarize] submitting $DMG"
+xcrun notarytool submit "$DMG" --keychain-profile "$NOTARY_PROFILE" --wait
+xcrun stapler staple "$DMG"
+echo "[notarize] stapled"
diff --git a/mac/build/package_dmg.sh b/mac/build/package_dmg.sh
new file mode 100755
index 00000000..5a1088e7
--- /dev/null
+++ b/mac/build/package_dmg.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+# Package taOS.app into a DMG via create-dmg.
+#
+# Args: --app --version --output
+set -euo pipefail
+
+APP=""
+VERSION=""
+OUTPUT=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --app) APP="$2"; shift 2 ;;
+ --version) VERSION="$2"; shift 2 ;;
+ --output) OUTPUT="$2"; shift 2 ;;
+ *) echo "package_dmg.sh: unknown arg $1" >&2; exit 2 ;;
+ esac
+done
+
+[[ -n "$APP" && -n "$VERSION" && -n "$OUTPUT" ]] || { echo "all args required" >&2; exit 2; }
+
+command -v create-dmg >/dev/null || { echo "create-dmg not installed (brew install create-dmg)" >&2; exit 1; }
+
+DMG="$OUTPUT/taOS-$VERSION.dmg"
+rm -f "$DMG"
+
+create-dmg \
+ --volname "taOS $VERSION" \
+ --window-size 600 400 \
+ --icon-size 96 \
+ --icon "taOS.app" 150 200 \
+ --app-drop-link 450 200 \
+ --hdiutil-quiet \
+ "$DMG" \
+ "$APP"
+
+echo "[package_dmg] done: $DMG"
diff --git a/mac/build/sign.sh b/mac/build/sign.sh
new file mode 100755
index 00000000..de44d29e
--- /dev/null
+++ b/mac/build/sign.sh
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+# Recursive ad-hoc codesign of taOS.app.
+# When DEV_ID env var is set, signs with the Developer ID identity instead.
+#
+# Args: --app
+set -euo pipefail
+
+APP=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --app) APP="$2"; shift 2 ;;
+ *) echo "sign.sh: unknown arg $1" >&2; exit 2 ;;
+ esac
+done
+
+[[ -n "$APP" ]] || { echo "--app required" >&2; exit 2; }
+[[ -d "$APP" ]] || { echo "no such bundle: $APP" >&2; exit 1; }
+
+if [[ -n "${DEV_ID:-}" ]]; then
+ IDENTITY="Developer ID Application: $DEV_ID"
+ EXTRA_ARGS=(--options runtime --timestamp)
+ echo "[sign] using Dev ID: $DEV_ID"
+else
+ IDENTITY="-"
+ EXTRA_ARGS=()
+ echo "[sign] ad-hoc signing (v0.1, no Dev ID)"
+fi
+
+# Empty-array expansion is unbound-safe with this guard (macOS ships bash 3.2)
+EA=("${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"}")
+
+# Sign nested binaries deepest-first. We use a while-read loop instead of
+# `xargs ... 2>/dev/null || true` so any codesign failure surfaces and exits.
+while IFS= read -r -d '' bin; do
+ codesign --force --sign "$IDENTITY" "${EA[@]+"${EA[@]}"}" "$bin"
+done < <(
+ find "$APP/Contents" -depth \( -type f -perm -u+x -o -type f -name "*.dylib" \) -print0
+)
+
+# Sign frameworks
+find "$APP/Contents/Frameworks" -name "*.framework" -maxdepth 2 -type d -print0 \
+ | xargs -0 -I{} codesign --force --sign "$IDENTITY" "${EA[@]+"${EA[@]}"}" {}
+
+# Sign the bundle itself last
+codesign --force --deep --sign "$IDENTITY" "${EA[@]+"${EA[@]}"}" "$APP"
+
+echo "[sign] verifying"
+codesign --verify --deep --strict --verbose=2 "$APP"
+
+echo "[sign] done"
diff --git a/mac/build/sparkle_sign.sh b/mac/build/sparkle_sign.sh
new file mode 100755
index 00000000..52d68f50
--- /dev/null
+++ b/mac/build/sparkle_sign.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+# Sparkle-sign the DMG and produce an appcast snippet.
+#
+# Args: --dmg --version --output
+set -euo pipefail
+
+DMG=""
+VERSION=""
+OUTPUT=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --dmg) DMG="$2"; shift 2 ;;
+ --version) VERSION="$2"; shift 2 ;;
+ --output) OUTPUT="$2"; shift 2 ;;
+ *) echo "sparkle_sign.sh: unknown arg $1" >&2; exit 2 ;;
+ esac
+done
+
+[[ -n "$DMG" && -n "$VERSION" && -n "$OUTPUT" ]] || { echo "all args required" >&2; exit 2; }
+
+REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+PRIVATE_KEY="${SPARKLE_ED_PRIVATE_KEY:-$HOME/.taos/sparkle_ed_private.pem}"
+[[ -f "$PRIVATE_KEY" ]] || { echo "Sparkle private key not at $PRIVATE_KEY" >&2; exit 1; }
+
+# Locate Sparkle's sign_update tool — bundled with Sparkle release tarball
+SIGN_UPDATE="$(command -v sign_update || true)"
+if [[ -z "$SIGN_UPDATE" ]]; then
+ for c in "$REPO_ROOT/mac/build/staging/Sparkle.framework/Versions/B/Resources/sign_update" \
+ "/Applications/Sparkle.framework/Versions/B/Resources/sign_update"; do
+ [[ -x "$c" ]] && { SIGN_UPDATE="$c"; break; }
+ done
+fi
+[[ -n "$SIGN_UPDATE" ]] || { echo "sign_update tool not found" >&2; exit 1; }
+
+SIGNATURE_LINE="$("$SIGN_UPDATE" "$DMG" "$PRIVATE_KEY")"
+# sign_update prints e.g.: sparkle:edSignature="..." length="N"
+echo "[sparkle_sign] $SIGNATURE_LINE"
+
+# Write the .sig sidecar
+SIG_FIELD="$(echo "$SIGNATURE_LINE" | sed -nE 's/.*sparkle:edSignature="([^"]+)".*/\1/p')"
+[[ -n "$SIG_FIELD" ]] || { echo "failed to parse sparkle:edSignature" >&2; exit 1; }
+echo -n "$SIG_FIELD" > "${DMG}.sig"
+
+# Build the appcast snippet
+mkdir -p "$OUTPUT"
+SNIPPET="$OUTPUT/appcast-snippet.xml"
+NOTES_FILE="$REPO_ROOT/CHANGELOG.md"
+NOTES="$(awk -v v="$VERSION" 'BEGIN{p=0} /^## /{p=($2==v)} p' "$NOTES_FILE" 2>/dev/null || echo "")"
+
+cat > "$SNIPPET" <
+ v${VERSION}
+ ${VERSION}
+ 26.0
+
+
+
+XML
+
+echo "[sparkle_sign] done: $SNIPPET"
diff --git a/mac/launcher/Package.swift b/mac/launcher/Package.swift
new file mode 100644
index 00000000..17e828b9
--- /dev/null
+++ b/mac/launcher/Package.swift
@@ -0,0 +1,35 @@
+// swift-tools-version: 6.2
+import PackageDescription
+
+// Sparkle 2.6.0 is wired in by the build pipeline (mac/build/build.sh) when
+// the build host has network access to GitHub releases. SparkleBridge.swift
+// gates its real code path behind `#if canImport(Sparkle)`, so the package
+// builds and the launcher functions identically without it.
+//
+// To enable Sparkle for a local development build, append:
+//
+// .binaryTarget(
+// name: "Sparkle",
+// url: "https://github.com/sparkle-project/Sparkle/releases/download/2.6.0/Sparkle-for-Swift-Package-Manager.zip",
+// checksum: "a5088d48a37ba415081335502e009dece75acae9d130705fee6c6988b90d0877"
+// ),
+//
+// to the targets list and add "Sparkle" to the executableTarget dependencies.
+
+let package = Package(
+ name: "taOSLauncher",
+ platforms: [.macOS(.v26)],
+ targets: [
+ .executableTarget(
+ name: "taOSLauncher",
+ dependencies: [],
+ path: "Sources/taOSLauncher",
+ resources: [.process("Resources")]
+ ),
+ .testTarget(
+ name: "taOSLauncherTests",
+ dependencies: ["taOSLauncher"],
+ path: "Tests/taOSLauncherTests"
+ ),
+ ]
+)
diff --git a/mac/launcher/Sources/taOSLauncher/App.swift b/mac/launcher/Sources/taOSLauncher/App.swift
new file mode 100644
index 00000000..219e7e9e
--- /dev/null
+++ b/mac/launcher/Sources/taOSLauncher/App.swift
@@ -0,0 +1,143 @@
+import AppKit
+import Foundation
+
+@main
+struct AppEntry {
+ static func main() {
+ let app = NSApplication.shared
+ let delegate = TaOSAppDelegate()
+ app.delegate = delegate
+ app.setActivationPolicy(.accessory)
+ app.run()
+ }
+}
+
+@MainActor
+final class TaOSAppDelegate: NSObject, NSApplicationDelegate {
+ private var statusItem: NSStatusItem!
+ private var menuBar: NSMenu!
+ private var serverProcess: ServerProcess?
+ private var windowController: WindowController!
+ private var keyboardMonitor = KeyboardMonitor()
+ private var sparkle = SparkleBridge()
+ private let port: Int = 7117
+ private var signalSources: [DispatchSourceSignal] = []
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ installStatusItem()
+ startServer()
+ windowController = WindowController(serverPort: port)
+ keyboardMonitor.install()
+ sparkle.startAutomaticUpdates()
+ installSignalHandlers()
+
+ // NSWindow.willCloseNotification is posted on the default center, not
+ // the workspace one. Object filter limits the observer to the taOS main
+ // window so Sparkle / future auxiliary windows can't reset our state.
+ NotificationCenter.default.addObserver(
+ self, selector: #selector(windowWillClose(_:)),
+ name: NSWindow.willCloseNotification, object: nil
+ )
+ }
+
+ // Route shell-delivered SIGTERM/SIGINT through the normal NSApp.terminate
+ // path so applicationWillTerminate runs and the Python child is reaped.
+ // Without this, `pkill -TERM` on the launcher orphans the server.
+ private func installSignalHandlers() {
+ for sig in [SIGTERM, SIGINT] {
+ signal(sig, SIG_IGN)
+ let source = DispatchSource.makeSignalSource(signal: sig, queue: .main)
+ source.setEventHandler { NSApp.terminate(nil) }
+ source.resume()
+ signalSources.append(source)
+ }
+ }
+
+ func applicationWillTerminate(_ notification: Notification) {
+ serverProcess?.stop(gracefulTimeoutSeconds: 5)
+ }
+
+ private func installStatusItem() {
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+ statusItem.button?.title = "taOS"
+ let actions = MenuBar.Actions(
+ openDesktop: { [weak self] in self?.openDesktop() },
+ openMobile: { [weak self] in self?.openMobile() },
+ togglePause: { },
+ openPreferences: { },
+ checkForUpdates: { [weak self] in self?.sparkle.checkForUpdates() },
+ quit: { NSApp.terminate(nil) }
+ )
+ menuBar = MenuBar.buildMenu(actions: actions, isPaused: false)
+ statusItem.menu = menuBar
+ }
+
+ private func startServer() {
+ let resources = Bundle.main.resourceURL!
+ let python = resources.appendingPathComponent("python/bin/python3")
+ let taosRoot = resources.appendingPathComponent("taos")
+ let containerBin = resources.appendingPathComponent("bin/container")
+
+ let dataDir = FileManager.default
+ .homeDirectoryForCurrentUser
+ .appendingPathComponent("Library/Application Support/taOS")
+ let logDir = FileManager.default
+ .homeDirectoryForCurrentUser
+ .appendingPathComponent("Library/Logs/taOS")
+
+ let env: [String: String] = [
+ "PYTHONPATH": taosRoot.path,
+ // Keep .pyc files out of the bundle — Python writes them next to
+ // sources by default, which would invalidate the codesign seal.
+ "PYTHONDONTWRITEBYTECODE": "1",
+ "TAOS_DATA_DIR": dataDir.path,
+ "TAOS_HOST": "127.0.0.1",
+ "TAOS_PORT": "\(port)",
+ "TAOS_CONTAINER_BIN": containerBin.path,
+ ]
+
+ let server = ServerProcess(
+ executable: python,
+ arguments: ["-m", "tinyagentos"],
+ env: env,
+ logFile: logDir.appendingPathComponent("server.log")
+ )
+ do {
+ try server.start()
+ self.serverProcess = server
+ } catch {
+ NSLog("[taOS] server failed to start: \(error)")
+ statusItem.button?.title = "taOS ⚠"
+ return
+ }
+
+ Task { @MainActor in
+ let url = URL(string: "http://127.0.0.1:\(port)/api/health")!
+ let ready = await server.waitForReady(timeoutSeconds: 15, healthURL: url)
+ if !ready {
+ self.statusItem.button?.title = "taOS ⚠"
+ }
+ }
+ }
+
+ private func openDesktop() {
+ if windowController.mode != .fullscreen { windowController.toggleMode() }
+ NSApp.setActivationPolicy(.regular)
+ windowController.showWindow()
+ keyboardMonitor.fullscreen = true
+ }
+
+ private func openMobile() {
+ if windowController.mode != .phone { windowController.toggleMode() }
+ NSApp.setActivationPolicy(.regular)
+ windowController.showWindow()
+ keyboardMonitor.fullscreen = false
+ }
+
+ @objc private func windowWillClose(_ notification: Notification) {
+ guard let closingWindow = notification.object as? NSWindow,
+ closingWindow == windowController.window else { return }
+ NSApp.setActivationPolicy(.accessory)
+ keyboardMonitor.fullscreen = false
+ }
+}
diff --git a/mac/launcher/Sources/taOSLauncher/KeyboardMonitor.swift b/mac/launcher/Sources/taOSLauncher/KeyboardMonitor.swift
new file mode 100644
index 00000000..ce6370df
--- /dev/null
+++ b/mac/launcher/Sources/taOSLauncher/KeyboardMonitor.swift
@@ -0,0 +1,48 @@
+import AppKit
+
+/// Local NSEvent monitor that swallows kiosk-busting shortcuts when the
+/// app is in fullscreen mode. Spaces gestures (Cmd+Tab, Ctrl+arrow) are
+/// always allowed through.
+public final class KeyboardMonitor {
+ public var fullscreen: Bool = false
+ private var monitor: Any?
+
+ private let blockedCmdKeys: Set = [
+ 12, // Q
+ 13, // W
+ 4, // H
+ 46, // M
+ 7, // X
+ ]
+
+ private let alwaysPassCmd: Set = [
+ 48, // Tab
+ 50, // `
+ ]
+
+ public init() {}
+
+ public func shouldIntercept(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Bool {
+ guard fullscreen else { return false }
+ if modifiers.contains(.control) { return false }
+ if modifiers.contains(.command), alwaysPassCmd.contains(keyCode) { return false }
+ if modifiers.contains(.command), blockedCmdKeys.contains(keyCode) { return true }
+ return false
+ }
+
+ public func install() {
+ monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
+ guard let self else { return event }
+ if self.shouldIntercept(keyCode: event.keyCode,
+ modifiers: event.modifierFlags.intersection(.deviceIndependentFlagsMask)) {
+ return nil
+ }
+ return event
+ }
+ }
+
+ public func uninstall() {
+ if let m = monitor { NSEvent.removeMonitor(m) }
+ monitor = nil
+ }
+}
diff --git a/mac/launcher/Sources/taOSLauncher/MenuBar.swift b/mac/launcher/Sources/taOSLauncher/MenuBar.swift
new file mode 100644
index 00000000..ce8a927a
--- /dev/null
+++ b/mac/launcher/Sources/taOSLauncher/MenuBar.swift
@@ -0,0 +1,58 @@
+import AppKit
+
+/// Builds and owns the NSStatusItem menu.
+public enum MenuBar {
+ public struct Actions {
+ public var openDesktop: () -> Void
+ public var openMobile: () -> Void
+ public var togglePause: () -> Void
+ public var openPreferences: () -> Void
+ public var checkForUpdates: () -> Void
+ public var quit: () -> Void
+
+ public init(openDesktop: @escaping () -> Void,
+ openMobile: @escaping () -> Void,
+ togglePause: @escaping () -> Void,
+ openPreferences: @escaping () -> Void,
+ checkForUpdates: @escaping () -> Void,
+ quit: @escaping () -> Void) {
+ self.openDesktop = openDesktop
+ self.openMobile = openMobile
+ self.togglePause = togglePause
+ self.openPreferences = openPreferences
+ self.checkForUpdates = checkForUpdates
+ self.quit = quit
+ }
+ }
+
+ public static func buildMenu(actions: Actions, isPaused: Bool) -> NSMenu {
+ let menu = NSMenu()
+ menu.addItem(makeItem(title: "Open taOS", action: actions.openDesktop))
+ menu.addItem(makeItem(title: "Open Mobile View", action: actions.openMobile))
+ menu.addItem(makeItem(title: isPaused ? "Resume Agents" : "Pause Agents",
+ action: actions.togglePause))
+ menu.addItem(NSMenuItem.separator())
+ menu.addItem(makeItem(title: "Preferences…", action: actions.openPreferences))
+ menu.addItem(makeItem(title: "Check for Updates…", action: actions.checkForUpdates))
+ menu.addItem(NSMenuItem.separator())
+ menu.addItem(makeItem(title: "Quit taOS", action: actions.quit, keyEquivalent: "q"))
+ return menu
+ }
+
+ private static func makeItem(title: String,
+ action: @escaping () -> Void,
+ keyEquivalent: String = "") -> NSMenuItem {
+ let item = NSMenuItem(title: title, action: nil, keyEquivalent: keyEquivalent)
+ let target = ActionWrapper(action: action)
+ item.target = target
+ item.action = #selector(ActionWrapper.invoke)
+ item.representedObject = target
+ return item
+ }
+
+ private final class ActionWrapper: NSObject {
+ let action: () -> Void
+ init(action: @escaping () -> Void) { self.action = action }
+ @objc func invoke() { action() }
+ }
+}
diff --git a/mac/launcher/Sources/taOSLauncher/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/mac/launcher/Sources/taOSLauncher/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 00000000..a9802be3
--- /dev/null
+++ b/mac/launcher/Sources/taOSLauncher/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ { "idiom" : "mac", "scale" : "1x", "size" : "16x16", "filename" : "icon_16x16.png" },
+ { "idiom" : "mac", "scale" : "2x", "size" : "16x16", "filename" : "icon_16x16@2x.png" },
+ { "idiom" : "mac", "scale" : "1x", "size" : "32x32", "filename" : "icon_32x32.png" },
+ { "idiom" : "mac", "scale" : "2x", "size" : "32x32", "filename" : "icon_32x32@2x.png" },
+ { "idiom" : "mac", "scale" : "1x", "size" : "128x128","filename" : "icon_128x128.png" },
+ { "idiom" : "mac", "scale" : "2x", "size" : "128x128","filename" : "icon_128x128@2x.png" },
+ { "idiom" : "mac", "scale" : "1x", "size" : "256x256","filename" : "icon_256x256.png" },
+ { "idiom" : "mac", "scale" : "2x", "size" : "256x256","filename" : "icon_256x256@2x.png" },
+ { "idiom" : "mac", "scale" : "1x", "size" : "512x512","filename" : "icon_512x512.png" },
+ { "idiom" : "mac", "scale" : "2x", "size" : "512x512","filename" : "icon_512x512@2x.png" }
+ ],
+ "info" : { "author" : "xcode", "version" : 1 }
+}
diff --git a/mac/launcher/Sources/taOSLauncher/Resources/Assets.xcassets/Contents.json b/mac/launcher/Sources/taOSLauncher/Resources/Assets.xcassets/Contents.json
new file mode 100644
index 00000000..73c00596
--- /dev/null
+++ b/mac/launcher/Sources/taOSLauncher/Resources/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/mac/launcher/Sources/taOSLauncher/Resources/Info.plist.in b/mac/launcher/Sources/taOSLauncher/Resources/Info.plist.in
new file mode 100644
index 00000000..52ec1943
--- /dev/null
+++ b/mac/launcher/Sources/taOSLauncher/Resources/Info.plist.in
@@ -0,0 +1,36 @@
+
+
+
+
+ CFBundleIdentifier
+ com.taos.app
+ CFBundleName
+ taOS
+ CFBundleDisplayName
+ taOS
+ CFBundleExecutable
+ taOS
+ CFBundleVersion
+ ${VERSION}
+ CFBundleShortVersionString
+ ${VERSION}
+ CFBundlePackageType
+ APPL
+ CFBundleIconFile
+ AppIcon
+ LSMinimumSystemVersion
+ 26.0
+ LSUIElement
+
+ NSHighResolutionCapable
+
+ SUFeedURL
+ https://taos.app/appcast.xml
+ SUPublicEDKey
+ ${SU_PUBLIC_ED_KEY}
+ SUEnableAutomaticChecks
+
+ SUScheduledCheckInterval
+ 86400
+
+
diff --git a/mac/launcher/Sources/taOSLauncher/ServerProcess.swift b/mac/launcher/Sources/taOSLauncher/ServerProcess.swift
new file mode 100644
index 00000000..95865579
--- /dev/null
+++ b/mac/launcher/Sources/taOSLauncher/ServerProcess.swift
@@ -0,0 +1,101 @@
+import Foundation
+
+/// Manages the lifecycle of the embedded uvicorn FastAPI server process.
+///
+/// Spawns a child Process, polls /api/health to confirm readiness, and
+/// terminates with SIGTERM -> SIGKILL fallback on shutdown.
+// Lifecycle is single-threaded under the launcher: start() runs on main during
+// applicationDidFinishLaunching, waitForReady polls async, stop() runs synchronously
+// from applicationWillTerminate. No concurrent mutation, so unchecked Sendable
+// is correct here.
+final class ServerProcess: @unchecked Sendable {
+ let executable: URL
+ let arguments: [String]
+ let env: [String: String]
+ let logFile: URL
+
+ private var process: Process?
+ private var logHandle: FileHandle?
+
+ init(executable: URL, arguments: [String], env: [String: String], logFile: URL) {
+ self.executable = executable
+ self.arguments = arguments
+ self.env = env
+ self.logFile = logFile
+ }
+
+ var isRunning: Bool {
+ process?.isRunning ?? false
+ }
+
+ func start() throws {
+ guard process == nil else { return }
+
+ let dir = logFile.deletingLastPathComponent()
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ if !FileManager.default.fileExists(atPath: logFile.path) {
+ FileManager.default.createFile(atPath: logFile.path, contents: nil)
+ }
+ let handle = try FileHandle(forWritingTo: logFile)
+ try handle.seekToEnd()
+ self.logHandle = handle
+
+ let proc = Process()
+ proc.executableURL = executable
+ proc.arguments = arguments
+ proc.environment = env
+ proc.standardOutput = handle
+ proc.standardError = handle
+ try proc.run()
+ self.process = proc
+ }
+
+ /// Polls the given health URL until it returns 200 or timeout elapses.
+ func waitForReady(timeoutSeconds: Double, healthURL: URL) async -> Bool {
+ let deadline = Date().addingTimeInterval(timeoutSeconds)
+ let session = URLSession(configuration: .ephemeral)
+ var request = URLRequest(url: healthURL)
+ request.timeoutInterval = 1.0
+
+ while Date() < deadline {
+ if !isRunning { return false }
+ do {
+ let (_, response) = try await session.data(for: request)
+ if let http = response as? HTTPURLResponse, http.statusCode == 200 {
+ return true
+ }
+ } catch {
+ // server not ready yet — keep polling
+ }
+ try? await Task.sleep(nanoseconds: 250_000_000)
+ }
+ return false
+ }
+
+ /// Sends SIGTERM, waits up to graceful timeout, then SIGKILL if still running.
+ /// Synchronous so it can run on the main thread during applicationWillTerminate
+ /// without deadlocking on a Task that needs the main actor.
+ func stop(gracefulTimeoutSeconds: Double) {
+ guard let proc = process, proc.isRunning else {
+ cleanup()
+ return
+ }
+
+ proc.terminate()
+ let deadline = Date().addingTimeInterval(gracefulTimeoutSeconds)
+ while proc.isRunning && Date() < deadline {
+ Thread.sleep(forTimeInterval: 0.1)
+ }
+ if proc.isRunning {
+ kill(proc.processIdentifier, SIGKILL)
+ proc.waitUntilExit()
+ }
+ cleanup()
+ }
+
+ private func cleanup() {
+ try? logHandle?.close()
+ logHandle = nil
+ process = nil
+ }
+}
diff --git a/mac/launcher/Sources/taOSLauncher/SparkleBridge.swift b/mac/launcher/Sources/taOSLauncher/SparkleBridge.swift
new file mode 100644
index 00000000..1cb8b02c
--- /dev/null
+++ b/mac/launcher/Sources/taOSLauncher/SparkleBridge.swift
@@ -0,0 +1,48 @@
+import Foundation
+#if canImport(Sparkle)
+import Sparkle
+#endif
+
+/// Thin wrapper around Sparkle. Test-friendly because the controller is
+/// owned only after the real bundle keys are present.
+public final class SparkleBridge {
+ public let feedURL: URL?
+ public let publicKey: String?
+ #if canImport(Sparkle)
+ private var controller: SPUStandardUpdaterController?
+ #endif
+
+ public init(infoDict: [String: Any]) {
+ if let s = infoDict["SUFeedURL"] as? String {
+ self.feedURL = URL(string: s)
+ } else {
+ self.feedURL = nil
+ }
+ self.publicKey = infoDict["SUPublicEDKey"] as? String
+ }
+
+ public convenience init() {
+ self.init(infoDict: Bundle.main.infoDictionary ?? [:])
+ }
+
+ public var canCheckForUpdates: Bool {
+ guard feedURL != nil else { return false }
+ let key = publicKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ return !key.isEmpty
+ }
+
+ public func startAutomaticUpdates() {
+ #if canImport(Sparkle)
+ guard canCheckForUpdates else { return }
+ controller = SPUStandardUpdaterController(
+ startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil
+ )
+ #endif
+ }
+
+ public func checkForUpdates() {
+ #if canImport(Sparkle)
+ controller?.checkForUpdates(nil)
+ #endif
+ }
+}
diff --git a/mac/launcher/Sources/taOSLauncher/WindowController.swift b/mac/launcher/Sources/taOSLauncher/WindowController.swift
new file mode 100644
index 00000000..60939c6d
--- /dev/null
+++ b/mac/launcher/Sources/taOSLauncher/WindowController.swift
@@ -0,0 +1,83 @@
+import AppKit
+import WebKit
+
+public enum WindowMode: String {
+ case fullscreen
+ case phone
+}
+
+public final class WindowController {
+ public private(set) var mode: WindowMode
+ private let serverPort: Int
+ private let defaults: UserDefaults
+ public private(set) var window: NSWindow?
+ private var webView: WKWebView?
+
+ public init(serverPort: Int, defaults: UserDefaults = .standard) {
+ self.serverPort = serverPort
+ self.defaults = defaults
+ let raw = defaults.string(forKey: "lastWindowMode") ?? "phone"
+ self.mode = WindowMode(rawValue: raw) ?? .phone
+ }
+
+ public static func route(for mode: WindowMode, port: Int) -> URL {
+ let path = (mode == .fullscreen) ? "/" : "/mobile"
+ return URL(string: "http://127.0.0.1:\(port)\(path)")!
+ }
+
+ public func toggleMode() {
+ mode = (mode == .phone) ? .fullscreen : .phone
+ defaults.set(mode.rawValue, forKey: "lastWindowMode")
+ applyMode()
+ }
+
+ public func showWindow() {
+ if window == nil { buildWindow() }
+ applyMode()
+ window?.makeKeyAndOrderFront(nil)
+ }
+
+ public func hideWindow() {
+ window?.orderOut(nil)
+ }
+
+ private func buildWindow() {
+ let frame = NSRect(x: 100, y: 100, width: 393, height: 852)
+ let style: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView]
+ let w = NSWindow(contentRect: frame, styleMask: style, backing: .buffered, defer: false)
+ w.title = "taOS"
+ w.titlebarAppearsTransparent = true
+ w.minSize = NSSize(width: 320, height: 694)
+ w.maxSize = NSSize(width: 480, height: 1040)
+
+ let webView = WKWebView(frame: w.contentView!.bounds)
+ webView.autoresizingMask = [.width, .height]
+ webView.layer?.cornerRadius = 47
+ webView.layer?.masksToBounds = true
+ w.contentView?.addSubview(webView)
+
+ self.window = w
+ self.webView = webView
+ }
+
+ private func applyMode() {
+ guard let w = window, let webView = webView else { return }
+ let url = Self.route(for: mode, port: serverPort)
+ webView.load(URLRequest(url: url))
+
+ switch mode {
+ case .fullscreen:
+ if !(w.styleMask.contains(.fullScreen)) {
+ w.toggleFullScreen(nil)
+ }
+ w.collectionBehavior = [.fullScreenPrimary]
+ case .phone:
+ if w.styleMask.contains(.fullScreen) {
+ w.toggleFullScreen(nil)
+ }
+ let target = NSRect(x: w.frame.origin.x, y: w.frame.origin.y,
+ width: 393, height: 852)
+ w.setFrame(target, display: true, animate: true)
+ }
+ }
+}
diff --git a/mac/launcher/Tests/taOSLauncherTests/KeyboardMonitorTests.swift b/mac/launcher/Tests/taOSLauncherTests/KeyboardMonitorTests.swift
new file mode 100644
index 00000000..094b669b
--- /dev/null
+++ b/mac/launcher/Tests/taOSLauncherTests/KeyboardMonitorTests.swift
@@ -0,0 +1,35 @@
+import XCTest
+import AppKit
+@testable import taOSLauncher
+
+final class KeyboardMonitorTests: XCTestCase {
+ func testInterceptsCmdQInFullscreen() {
+ let monitor = KeyboardMonitor()
+ monitor.fullscreen = true
+ XCTAssertTrue(monitor.shouldIntercept(keyCode: 12, modifiers: .command))
+ }
+
+ func testInterceptsCmdWInFullscreen() {
+ let monitor = KeyboardMonitor()
+ monitor.fullscreen = true
+ XCTAssertTrue(monitor.shouldIntercept(keyCode: 13, modifiers: .command))
+ }
+
+ func testDoesNotInterceptInPhoneMode() {
+ let monitor = KeyboardMonitor()
+ monitor.fullscreen = false
+ XCTAssertFalse(monitor.shouldIntercept(keyCode: 12, modifiers: .command))
+ }
+
+ func testCmdTabAlwaysPassesThrough() {
+ let monitor = KeyboardMonitor()
+ monitor.fullscreen = true
+ XCTAssertFalse(monitor.shouldIntercept(keyCode: 48, modifiers: .command))
+ }
+
+ func testCtrlArrowAlwaysPassesThrough() {
+ let monitor = KeyboardMonitor()
+ monitor.fullscreen = true
+ XCTAssertFalse(monitor.shouldIntercept(keyCode: 124, modifiers: .control))
+ }
+}
diff --git a/mac/launcher/Tests/taOSLauncherTests/MenuBarTests.swift b/mac/launcher/Tests/taOSLauncherTests/MenuBarTests.swift
new file mode 100644
index 00000000..278f7203
--- /dev/null
+++ b/mac/launcher/Tests/taOSLauncherTests/MenuBarTests.swift
@@ -0,0 +1,46 @@
+import XCTest
+import AppKit
+@testable import taOSLauncher
+
+final class MenuBarTests: XCTestCase {
+ func testMenuItemsInOrder() {
+ let actions = MenuBar.Actions(
+ openDesktop: {},
+ openMobile: {},
+ togglePause: {},
+ openPreferences: {},
+ checkForUpdates: {},
+ quit: {}
+ )
+ let menu = MenuBar.buildMenu(actions: actions, isPaused: false)
+ let titles = menu.items.map { $0.title }
+ XCTAssertEqual(titles, [
+ "Open taOS",
+ "Open Mobile View",
+ "Pause Agents",
+ "",
+ "Preferences…",
+ "Check for Updates…",
+ "",
+ "Quit taOS",
+ ])
+ }
+
+ func testPauseTogglesTitle() {
+ let actions = MenuBar.Actions(
+ openDesktop: {}, openMobile: {}, togglePause: {},
+ openPreferences: {}, checkForUpdates: {}, quit: {}
+ )
+ let paused = MenuBar.buildMenu(actions: actions, isPaused: true)
+ XCTAssertEqual(paused.items[2].title, "Resume Agents")
+ }
+
+ func testQuitHasCommandQ() {
+ let actions = MenuBar.Actions(
+ openDesktop: {}, openMobile: {}, togglePause: {},
+ openPreferences: {}, checkForUpdates: {}, quit: {}
+ )
+ let menu = MenuBar.buildMenu(actions: actions, isPaused: false)
+ XCTAssertEqual(menu.items.last?.keyEquivalent, "q")
+ }
+}
diff --git a/mac/launcher/Tests/taOSLauncherTests/PlaceholderTests.swift b/mac/launcher/Tests/taOSLauncherTests/PlaceholderTests.swift
new file mode 100644
index 00000000..f6c4ae13
--- /dev/null
+++ b/mac/launcher/Tests/taOSLauncherTests/PlaceholderTests.swift
@@ -0,0 +1,7 @@
+import XCTest
+
+final class PlaceholderTests: XCTestCase {
+ func testPackageBuilds() {
+ XCTAssertTrue(true)
+ }
+}
diff --git a/mac/launcher/Tests/taOSLauncherTests/ServerProcessTests.swift b/mac/launcher/Tests/taOSLauncherTests/ServerProcessTests.swift
new file mode 100644
index 00000000..4e87f5ff
--- /dev/null
+++ b/mac/launcher/Tests/taOSLauncherTests/ServerProcessTests.swift
@@ -0,0 +1,35 @@
+import XCTest
+@testable import taOSLauncher
+
+final class ServerProcessTests: XCTestCase {
+ func testSpawnAndStop() async throws {
+ let tmp = FileManager.default.temporaryDirectory
+ .appendingPathComponent("taos-server-test-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: tmp) }
+
+ let script = tmp.appendingPathComponent("fake-server.sh")
+ try """
+ #!/bin/bash
+ echo "Uvicorn running on http://127.0.0.1:7117"
+ sleep 60
+ """.write(to: script, atomically: true, encoding: .utf8)
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o755], ofItemAtPath: script.path)
+
+ let log = tmp.appendingPathComponent("server.log")
+ let server = ServerProcess(
+ executable: URL(fileURLWithPath: "/bin/bash"),
+ arguments: [script.path],
+ env: ProcessInfo.processInfo.environment,
+ logFile: log
+ )
+
+ try server.start()
+ XCTAssertTrue(server.isRunning)
+
+ try await Task.sleep(nanoseconds: 200_000_000)
+ server.stop(gracefulTimeoutSeconds: 2.0)
+ XCTAssertFalse(server.isRunning)
+ }
+}
diff --git a/mac/launcher/Tests/taOSLauncherTests/SparkleBridgeTests.swift b/mac/launcher/Tests/taOSLauncherTests/SparkleBridgeTests.swift
new file mode 100644
index 00000000..50d8c52e
--- /dev/null
+++ b/mac/launcher/Tests/taOSLauncherTests/SparkleBridgeTests.swift
@@ -0,0 +1,19 @@
+import XCTest
+@testable import taOSLauncher
+
+final class SparkleBridgeTests: XCTestCase {
+ func testFeedURLReadFromBundle() {
+ let bridge = SparkleBridge(infoDict: [
+ "SUFeedURL": "https://taos.app/appcast.xml",
+ "SUPublicEDKey": "fakekey=="
+ ])
+ XCTAssertEqual(bridge.feedURL, URL(string: "https://taos.app/appcast.xml"))
+ XCTAssertEqual(bridge.publicKey, "fakekey==")
+ }
+
+ func testMissingFeedURLDisablesUpdates() {
+ let bridge = SparkleBridge(infoDict: [:])
+ XCTAssertNil(bridge.feedURL)
+ XCTAssertFalse(bridge.canCheckForUpdates)
+ }
+}
diff --git a/mac/launcher/Tests/taOSLauncherTests/WindowControllerTests.swift b/mac/launcher/Tests/taOSLauncherTests/WindowControllerTests.swift
new file mode 100644
index 00000000..6f4c5ba5
--- /dev/null
+++ b/mac/launcher/Tests/taOSLauncherTests/WindowControllerTests.swift
@@ -0,0 +1,36 @@
+import XCTest
+import AppKit
+@testable import taOSLauncher
+
+final class WindowControllerTests: XCTestCase {
+ func testInitialModeFromDefaults() {
+ let suite = "test_\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suite)!
+ defaults.set("phone", forKey: "lastWindowMode")
+
+ let wc = WindowController(serverPort: 6969, defaults: defaults)
+ XCTAssertEqual(wc.mode, .phone)
+ }
+
+ func testFirstLaunchDefaultsToPhone() {
+ let suite = "test_\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suite)!
+ let wc = WindowController(serverPort: 6969, defaults: defaults)
+ XCTAssertEqual(wc.mode, .phone)
+ }
+
+ func testToggleSwitchesModeAndPersists() {
+ let suite = "test_\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suite)!
+ let wc = WindowController(serverPort: 6969, defaults: defaults)
+ XCTAssertEqual(wc.mode, .phone)
+ wc.toggleMode()
+ XCTAssertEqual(wc.mode, .fullscreen)
+ XCTAssertEqual(defaults.string(forKey: "lastWindowMode"), "fullscreen")
+ }
+
+ func testRouteForMode() {
+ XCTAssertEqual(WindowController.route(for: .fullscreen, port: 6969).path, "/")
+ XCTAssertEqual(WindowController.route(for: .phone, port: 6969).path, "/mobile")
+ }
+}
diff --git a/tests/test_app_apple_backend_wiring.py b/tests/test_app_apple_backend_wiring.py
new file mode 100644
index 00000000..52a701d7
--- /dev/null
+++ b/tests/test_app_apple_backend_wiring.py
@@ -0,0 +1,20 @@
+"""Smoke test: when detect_runtime returns 'apple', create_app installs AppleContainerBackend."""
+from __future__ import annotations
+
+from unittest.mock import patch
+
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_create_app_installs_apple_backend_on_darwin(tmp_path, monkeypatch):
+ monkeypatch.setenv("TAOS_CONTAINER_BIN", "/usr/local/bin/container")
+ with patch("tinyagentos.containers.backend.detect_runtime", return_value="apple"):
+ from tinyagentos.app import create_app
+ from tinyagentos.containers.backend import get_backend
+ from tinyagentos.containers.apple_backend import AppleContainerBackend
+
+ app = create_app(data_dir=tmp_path)
+ async with app.router.lifespan_context(app):
+ backend = get_backend()
+ assert isinstance(backend, AppleContainerBackend)
diff --git a/tests/test_apple_backend.py b/tests/test_apple_backend.py
new file mode 100644
index 00000000..bfd1c87e
--- /dev/null
+++ b/tests/test_apple_backend.py
@@ -0,0 +1,343 @@
+"""Unit tests for AppleContainerBackend (subprocess mocked)."""
+from __future__ import annotations
+
+import json
+import os
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+
+def _backend(monkeypatch, bin_path: str | None = None):
+ from tinyagentos.containers.apple_backend import AppleContainerBackend
+ if bin_path is None:
+ monkeypatch.delenv("TAOS_CONTAINER_BIN", raising=False)
+ else:
+ monkeypatch.setenv("TAOS_CONTAINER_BIN", bin_path)
+ return AppleContainerBackend()
+
+
+def test_resolves_cli_from_env(monkeypatch):
+ b = _backend(monkeypatch, "/Applications/taOS.app/Contents/Resources/bin/container")
+ assert b.binary == "/Applications/taOS.app/Contents/Resources/bin/container"
+
+
+def test_falls_back_to_path(monkeypatch):
+ b = _backend(monkeypatch)
+ assert b.binary == "container"
+
+
+@pytest.mark.asyncio
+async def test_run_invokes_subprocess(monkeypatch):
+ b = _backend(monkeypatch, "/usr/local/bin/container")
+
+ async def fake_exec(*cmd, **kwargs):
+ class P:
+ returncode = 0
+ async def communicate(self):
+ return (b"hello", b"")
+ return P()
+
+ with patch("asyncio.create_subprocess_exec", side_effect=fake_exec) as m:
+ code, out = await b._run([b.binary, "ls"])
+ assert code == 0
+ assert out == "hello"
+ # First positional arg is the binary
+ assert m.call_args.args[0] == "/usr/local/bin/container"
+
+
+@pytest.mark.asyncio
+async def test_list_containers_filters_by_prefix(monkeypatch):
+ b = _backend(monkeypatch)
+ payload = json.dumps([
+ {"name": "taos-agent-alice", "status": "running",
+ "ip": "192.168.65.3", "memory": "2GB", "cpus": 2},
+ {"name": "other-thing", "status": "running",
+ "ip": "192.168.65.4", "memory": "1GB", "cpus": 1},
+ ])
+ with patch.object(b, "_run", new_callable=AsyncMock) as m:
+ m.return_value = (0, payload)
+ items = await b.list_containers()
+
+ assert len(items) == 1
+ assert items[0].name == "taos-agent-alice"
+ assert items[0].status == "running"
+ assert items[0].ip == "192.168.65.3"
+ assert items[0].memory_mb == 2048
+ assert items[0].cpu_cores == 2
+
+
+@pytest.mark.asyncio
+async def test_list_containers_returns_empty_on_failure(monkeypatch):
+ b = _backend(monkeypatch)
+ with patch.object(b, "_run", new_callable=AsyncMock) as m:
+ m.return_value = (1, "container daemon not running")
+ items = await b.list_containers()
+ assert items == []
+
+
+@pytest.mark.asyncio
+async def test_list_containers_returns_empty_on_bad_json(monkeypatch):
+ b = _backend(monkeypatch)
+ with patch.object(b, "_run", new_callable=AsyncMock) as m:
+ m.return_value = (0, "not json {{{")
+ items = await b.list_containers()
+ assert items == []
+
+
+@pytest.mark.asyncio
+async def test_create_container_builds_argv(monkeypatch):
+ b = _backend(monkeypatch, "/usr/local/bin/container")
+ with patch.object(b, "_run", new_callable=AsyncMock) as m:
+ m.return_value = (0, "abc123")
+ result = await b.create_container(
+ name="taos-agent-bob",
+ image="docker.io/library/debian:bookworm",
+ memory_limit="2GB",
+ cpu_limit=2,
+ mounts=[("/host/data", "/data")],
+ env={"FOO": "bar"},
+ )
+
+ assert result["success"] is True
+ argv = m.call_args.args[0]
+ assert argv[0] == "/usr/local/bin/container"
+ assert argv[1] == "run"
+ assert "-d" in argv
+ assert "--name" in argv and "taos-agent-bob" in argv
+ assert "--memory" in argv and "2g" in argv
+ assert "--cpus" in argv and "2" in argv
+ assert "-v" in argv and "/host/data:/data" in argv
+ assert "-e" in argv and "FOO=bar" in argv
+ assert "docker.io/library/debian:bookworm" in argv
+
+
+@pytest.mark.asyncio
+async def test_create_container_returns_failure(monkeypatch):
+ b = _backend(monkeypatch)
+ with patch.object(b, "_run", new_callable=AsyncMock) as m:
+ m.return_value = (125, "image not found")
+ result = await b.create_container(name="taos-agent-bad", image="nonexistent")
+ assert result["success"] is False
+ assert "image not found" in result["output"]
+
+
+@pytest.mark.asyncio
+async def test_create_container_with_root_size_gib_does_not_orphan(monkeypatch):
+ """set_root_quota returns accounting-only success; create_container still succeeds."""
+ b = _backend(monkeypatch)
+ with patch.object(b, "_run", new_callable=AsyncMock) as m:
+ m.return_value = (0, "abc123")
+ result = await b.create_container(
+ name="taos-agent-quota",
+ image="docker.io/library/debian:bookworm",
+ root_size_gib=10,
+ )
+ assert result["success"] is True
+
+
+@pytest.mark.asyncio
+async def test_exec_in_container(monkeypatch):
+ b = _backend(monkeypatch)
+ with patch.object(b, "_run", new_callable=AsyncMock) as m:
+ m.return_value = (0, "hello world\n")
+ code, output = await b.exec_in_container("taos-agent-x", ["echo", "hi"])
+ assert code == 0
+ assert "hello" in output
+ argv = m.call_args.args[0]
+ assert argv[1] == "exec"
+ assert "taos-agent-x" in argv
+ assert "echo" in argv and "hi" in argv
+
+
+@pytest.mark.asyncio
+async def test_push_file(monkeypatch):
+ b = _backend(monkeypatch)
+ with patch.object(b, "_run", new_callable=AsyncMock) as m:
+ m.return_value = (0, "")
+ code, output = await b.push_file("taos-agent-x", "/tmp/foo", "/etc/foo")
+ argv = m.call_args.args[0]
+ assert argv[1] == "cp"
+ assert "/tmp/foo" in argv
+ assert "taos-agent-x:/etc/foo" in argv
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "method,verb,extra",
+ [
+ ("start_container", "start", {}),
+ ("restart_container", "restart", {}),
+ ("destroy_container", "rm", {}),
+ ],
+)
+async def test_lifecycle_simple(monkeypatch, method, verb, extra):
+ b = _backend(monkeypatch)
+ with patch.object(b, "_run", new_callable=AsyncMock) as m:
+ m.return_value = (0, "ok")
+ result = await getattr(b, method)("taos-agent-x", **extra)
+ argv = m.call_args.args[0]
+ assert argv[1] == verb
+ assert "taos-agent-x" in argv
+ assert result["success"] is True
+
+
+@pytest.mark.asyncio
+async def test_stop_uses_kill_when_force(monkeypatch):
+ b = _backend(monkeypatch)
+ with patch.object(b, "_run", new_callable=AsyncMock) as m:
+ m.return_value = (0, "ok")
+ await b.stop_container("x", force=True)
+ assert m.call_args.args[0][1] == "kill"
+
+ with patch.object(b, "_run", new_callable=AsyncMock) as m:
+ m.return_value = (0, "ok")
+ await b.stop_container("x", force=False)
+ assert m.call_args.args[0][1] == "stop"
+
+
+@pytest.mark.asyncio
+async def test_destroy_force_removes_running(monkeypatch):
+ b = _backend(monkeypatch)
+ with patch.object(b, "_run", new_callable=AsyncMock) as m:
+ m.return_value = (0, "ok")
+ await b.destroy_container("x")
+ argv = m.call_args.args[0]
+ assert "-f" in argv # rm -f to remove running containers
+
+
+@pytest.mark.asyncio
+async def test_get_container_logs(monkeypatch):
+ b = _backend(monkeypatch)
+ with patch.object(b, "_run", new_callable=AsyncMock) as m:
+ m.return_value = (0, "line1\nline2\n")
+ out = await b.get_container_logs("x", lines=42)
+ argv = m.call_args.args[0]
+ assert argv[1] == "logs"
+ assert "--tail" in argv and "42" in argv
+ assert "x" in argv
+ assert "line1" in out
+
+
+@pytest.mark.asyncio
+async def test_rename_container(monkeypatch):
+ b = _backend(monkeypatch)
+ with patch.object(b, "_run", new_callable=AsyncMock) as m:
+ m.return_value = (0, "")
+ result = await b.rename_container("old", "new")
+ argv = m.call_args.args[0]
+ assert argv[1] == "rename"
+ assert argv[-2:] == ["old", "new"]
+ assert result["success"] is True
+
+
+@pytest.mark.asyncio
+async def test_get_container_logs_returns_error_message_on_failure(monkeypatch):
+ b = _backend(monkeypatch)
+ with patch.object(b, "_run", new_callable=AsyncMock) as m:
+ m.return_value = (1, "no such container: x")
+ out = await b.get_container_logs("x")
+ assert out.startswith("Error getting logs:")
+ assert "no such container" in out
+
+
+@pytest.mark.asyncio
+async def test_snapshot_create(monkeypatch):
+ b = _backend(monkeypatch)
+ with patch.object(b, "_run", new_callable=AsyncMock) as m:
+ m.return_value = (0, "sha256:deadbeef")
+ result = await b.snapshot_create("x", "v1")
+ argv = m.call_args.args[0]
+ assert argv[1] == "commit"
+ assert "x" in argv
+ assert "taos/v1:latest" in argv
+ assert result["success"] is True
+
+
+@pytest.mark.asyncio
+async def test_snapshot_restore_unsupported(monkeypatch):
+ b = _backend(monkeypatch)
+ result = await b.snapshot_restore("x", "v1")
+ assert result["success"] is False
+ assert "not supported" in result["note"]
+
+
+@pytest.mark.asyncio
+async def test_snapshot_list_parses_taos_images(monkeypatch):
+ b = _backend(monkeypatch)
+ payload = '[{"reference":"taos/v1:latest"},{"reference":"taos/v2:latest"}]'
+ with patch.object(b, "_run", new_callable=AsyncMock) as m:
+ m.return_value = (0, payload)
+ result = await b.snapshot_list("x")
+ assert result["success"] is True
+ assert result["snapshots"] == ["v1", "v2"]
+
+
+@pytest.mark.asyncio
+async def test_snapshot_create_failure(monkeypatch):
+ b = _backend(monkeypatch)
+ with patch.object(b, "_run", new_callable=AsyncMock) as m:
+ m.return_value = (1, "no such container: x")
+ result = await b.snapshot_create("x", "v1")
+ assert result["success"] is False
+ assert "no such container" in result["output"]
+
+
+@pytest.mark.asyncio
+async def test_snapshot_list_returns_failure_on_nonzero_exit(monkeypatch):
+ b = _backend(monkeypatch)
+ with patch.object(b, "_run", new_callable=AsyncMock) as m:
+ m.return_value = (1, "images daemon error")
+ result = await b.snapshot_list("x")
+ assert result["success"] is False
+ assert result["snapshots"] == []
+
+
+@pytest.mark.asyncio
+async def test_snapshot_list_returns_failure_on_bad_json(monkeypatch):
+ b = _backend(monkeypatch)
+ with patch.object(b, "_run", new_callable=AsyncMock) as m:
+ m.return_value = (0, "not json {{{")
+ result = await b.snapshot_list("x")
+ assert result["success"] is False
+ assert result["snapshots"] == []
+
+
+@pytest.mark.asyncio
+async def test_snapshot_list_excludes_non_taos_images(monkeypatch):
+ b = _backend(monkeypatch)
+ payload = (
+ '[{"reference":"taos/v1:latest"},'
+ '{"reference":"library/debian:bookworm"},'
+ '{"reference":"taos/v2:latest"}]'
+ )
+ with patch.object(b, "_run", new_callable=AsyncMock) as m:
+ m.return_value = (0, payload)
+ result = await b.snapshot_list("x")
+ assert result["success"] is True
+ assert result["snapshots"] == ["v1", "v2"]
+
+
+@pytest.mark.asyncio
+async def test_add_proxy_device_is_noop(monkeypatch):
+ b = _backend(monkeypatch)
+ result = await b.add_proxy_device("x", "litellm", "tcp:127.0.0.1:4000",
+ "tcp:127.0.0.1:4000")
+ assert result["success"] is True
+ assert "host.containers.internal" in result["note"]
+
+
+@pytest.mark.asyncio
+async def test_set_env_unsupported(monkeypatch):
+ b = _backend(monkeypatch)
+ result = await b.set_env("x", "KEY", "value")
+ assert result["success"] is False
+ assert "recreate" in result["note"]
+
+
+@pytest.mark.asyncio
+async def test_set_root_quota_accounting_only(monkeypatch):
+ b = _backend(monkeypatch)
+ result = await b.set_root_quota("x", 5)
+ assert result["success"] is True
+ assert "accounting-only" in result["note"]
diff --git a/tests/test_container_detection.py b/tests/test_container_detection.py
index db53f78e..ab451c46 100644
--- a/tests/test_container_detection.py
+++ b/tests/test_container_detection.py
@@ -1,9 +1,15 @@
+import os
+import sys
import pytest
from unittest.mock import patch
from tinyagentos.containers.backend import detect_runtime
class TestDetectRuntime:
+ @pytest.fixture(autouse=True)
+ def clear_apple_env(self, monkeypatch):
+ monkeypatch.delenv("TAOS_CONTAINER_BIN", raising=False)
+
def test_detect_lxc(self):
with patch("shutil.which", side_effect=lambda x: "/usr/bin/incus" if x == "incus" else None):
assert detect_runtime() == "lxc"
@@ -27,3 +33,35 @@ def which(cmd):
return None
with patch("shutil.which", side_effect=which):
assert detect_runtime() == "lxc"
+
+
+class TestDetectRuntimeApple:
+ def test_apple_selected_on_darwin_with_env(self):
+ with patch.object(sys, "platform", "darwin"), \
+ patch.dict(os.environ, {"TAOS_CONTAINER_BIN": "/x/container"}, clear=False), \
+ patch("shutil.which", return_value=None):
+ assert detect_runtime() == "apple"
+
+ def test_apple_wins_over_docker_on_darwin(self):
+ with patch.object(sys, "platform", "darwin"), \
+ patch.dict(os.environ, {"TAOS_CONTAINER_BIN": "/x/container"}, clear=False), \
+ patch("shutil.which", side_effect=lambda x: "/usr/bin/docker" if x == "docker" else None):
+ assert detect_runtime() == "apple"
+
+ def test_no_apple_without_env_var(self, monkeypatch):
+ monkeypatch.delenv("TAOS_CONTAINER_BIN", raising=False)
+ with patch.object(sys, "platform", "darwin"), \
+ patch("shutil.which", side_effect=lambda x: "/usr/bin/docker" if x == "docker" else None):
+ assert detect_runtime() == "docker"
+
+ def test_apple_not_selected_on_linux_even_with_env(self, monkeypatch):
+ monkeypatch.setenv("TAOS_CONTAINER_BIN", "/x/container")
+ with patch.object(sys, "platform", "linux"), \
+ patch("shutil.which", side_effect=lambda x: "/usr/bin/docker" if x == "docker" else None):
+ assert detect_runtime() == "docker"
+
+ def test_apple_wins_over_lxc_on_darwin(self):
+ with patch.object(sys, "platform", "darwin"), \
+ patch.dict(os.environ, {"TAOS_CONTAINER_BIN": "/x/container"}, clear=False), \
+ patch("shutil.which", side_effect=lambda x: "/usr/bin/incus" if x == "incus" else None):
+ assert detect_runtime() == "apple"
diff --git a/tests/test_main_entry.py b/tests/test_main_entry.py
new file mode 100644
index 00000000..4ae9e511
--- /dev/null
+++ b/tests/test_main_entry.py
@@ -0,0 +1,42 @@
+"""Verify python -m tinyagentos respects TAOS_HOST/TAOS_PORT env vars."""
+from __future__ import annotations
+
+from unittest.mock import patch
+
+
+def test_main_uses_env_host_port(monkeypatch):
+ monkeypatch.setenv("TAOS_HOST", "127.0.0.1")
+ monkeypatch.setenv("TAOS_PORT", "7117")
+ from tinyagentos import __main__ as m
+
+ captured = {}
+
+ def fake_run(app, host, port, **kwargs):
+ captured["host"] = host
+ captured["port"] = port
+
+ with patch("uvicorn.run", side_effect=fake_run), \
+ patch.object(m, "create_app", return_value=object()):
+ m.main()
+
+ assert captured["host"] == "127.0.0.1"
+ assert captured["port"] == 7117
+
+
+def test_main_falls_back_to_config_when_env_unset(monkeypatch):
+ monkeypatch.delenv("TAOS_HOST", raising=False)
+ monkeypatch.delenv("TAOS_PORT", raising=False)
+ from tinyagentos import __main__ as m
+
+ captured = {}
+
+ def fake_run(app, host, port, **kwargs):
+ captured["host"] = host
+ captured["port"] = port
+
+ with patch("uvicorn.run", side_effect=fake_run), \
+ patch.object(m, "create_app", return_value=object()):
+ m.main()
+
+ assert captured["host"] == "0.0.0.0"
+ assert captured["port"] == 6969
diff --git a/tests/test_routes_settings.py b/tests/test_routes_settings.py
index 9c0fdee0..581d723c 100644
--- a/tests/test_routes_settings.py
+++ b/tests/test_routes_settings.py
@@ -104,6 +104,16 @@ async def test_set_container_runtime(self, client):
assert resp.status_code == 200
assert resp.json()["status"] == "updated"
+ @pytest.mark.asyncio
+ async def test_set_apple_runtime(self, client):
+ resp = await client.put(
+ "/api/settings/container-runtime",
+ content='{"runtime": "apple"}',
+ headers={"content-type": "application/json"},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["status"] == "updated"
+
@pytest.mark.asyncio
async def test_set_invalid_runtime(self, client):
resp = await client.put(
diff --git a/tinyagentos/__main__.py b/tinyagentos/__main__.py
new file mode 100644
index 00000000..07d3902d
--- /dev/null
+++ b/tinyagentos/__main__.py
@@ -0,0 +1,64 @@
+"""Module entry: ``python -m tinyagentos``.
+
+Honours ``TAOS_HOST`` / ``TAOS_PORT`` env vars (used by the Mac launcher
+to bind to a private 127.0.0.1 port) and falls back to ``data/config.yaml``
+when they are unset (preserves the existing console-script behaviour).
+"""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+from tinyagentos.app import PROJECT_DIR, create_app, load_config
+
+
+def main() -> None:
+ import uvicorn
+
+ env_host = os.environ.get("TAOS_HOST")
+ env_port = os.environ.get("TAOS_PORT")
+ env_data_dir = os.environ.get("TAOS_DATA_DIR")
+
+ data_dir = Path(env_data_dir) if env_data_dir else None
+ if data_dir is not None:
+ _seed_data_dir(data_dir)
+
+ config_path = (data_dir or (PROJECT_DIR / "data")) / "config.yaml"
+
+ if env_host or env_port:
+ host = env_host or "127.0.0.1"
+ port = int(env_port) if env_port else 6969
+ else:
+ config = load_config(config_path)
+ host = config.server.get("host", "0.0.0.0")
+ port = config.server.get("port", 6969)
+
+ app = create_app(data_dir=data_dir)
+ uvicorn.run(app, host=host, port=port)
+
+
+def _seed_data_dir(target: Path) -> None:
+ """Copy bundled data/ skeleton into target on first run.
+
+ Existing files are preserved; only missing ones get copied. This lets the
+ embedded server boot in ~/Library/Application Support/taOS without the
+ user supplying a config.yaml.
+ """
+ import shutil
+
+ target.mkdir(parents=True, exist_ok=True)
+ source = PROJECT_DIR / "data"
+ if not source.exists():
+ return
+ for entry in source.rglob("*"):
+ rel = entry.relative_to(source)
+ dest = target / rel
+ if entry.is_dir():
+ dest.mkdir(parents=True, exist_ok=True)
+ elif not dest.exists():
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(entry, dest)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tinyagentos/app.py b/tinyagentos/app.py
index 0f3e9685..bb89f517 100644
--- a/tinyagentos/app.py
+++ b/tinyagentos/app.py
@@ -591,7 +591,10 @@ async def _reload_llm_proxy_on_catalog_change() -> None:
runtime = getattr(config, "container_runtime", "auto")
if runtime == "auto":
runtime = detect_runtime()
- if runtime == "lxc":
+ if runtime == "apple":
+ from tinyagentos.containers.apple_backend import AppleContainerBackend
+ set_backend(AppleContainerBackend())
+ elif runtime == "lxc":
set_backend(LXCBackend())
elif runtime in ("docker", "podman"):
set_backend(DockerBackend(binary=runtime))
@@ -790,7 +793,10 @@ async def _reload_llm_proxy_on_catalog_change() -> None:
_runtime = getattr(config, "container_runtime", "auto")
if _runtime == "auto":
_runtime = detect_runtime()
- if _runtime == "lxc":
+ if _runtime == "apple":
+ from tinyagentos.containers.apple_backend import AppleContainerBackend
+ set_backend(AppleContainerBackend())
+ elif _runtime == "lxc":
set_backend(LXCBackend())
elif _runtime in ("docker", "podman"):
set_backend(DockerBackend(binary=_runtime))
diff --git a/tinyagentos/containers/apple_backend.py b/tinyagentos/containers/apple_backend.py
new file mode 100644
index 00000000..026444ea
--- /dev/null
+++ b/tinyagentos/containers/apple_backend.py
@@ -0,0 +1,229 @@
+"""Apple Containerization backend — shells out to apple/container CLI.
+
+The Mac .app launcher injects ``TAOS_CONTAINER_BIN`` pointing at the
+bundled CLI under ``Contents/Resources/bin/container``. On developer
+machines without the .app, falls back to ``container`` on ``PATH``.
+
+All ``subprocess`` calls go through ``asyncio.create_subprocess_exec``
+(no shell). Failure shape matches the other backends:
+``{success: bool, output: str, note?: str}``.
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import os
+
+from .backend import ContainerBackend, ContainerInfo, _parse_memory
+
+logger = logging.getLogger(__name__)
+
+
+class AppleContainerBackend(ContainerBackend):
+ def __init__(self) -> None:
+ self.binary = os.environ.get("TAOS_CONTAINER_BIN", "container")
+
+ async def _run(self, cmd: list[str], timeout: int = 120) -> tuple[int, str]:
+ """Run a command and return a normalised (returncode, output).
+
+ Callers expect a tuple even when the binary is missing or the command
+ times out, so both failure modes are mapped to non-zero exit codes
+ instead of propagating exceptions. ``asyncio.wait_for`` cancels the
+ coroutine but does not stop the child, so we terminate it explicitly
+ on timeout to avoid leaking processes.
+ """
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ *cmd,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.STDOUT,
+ )
+ except FileNotFoundError as exc:
+ return 127, f"{self.binary}: {exc}"
+
+ try:
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
+ except asyncio.TimeoutError:
+ proc.terminate()
+ try:
+ await asyncio.wait_for(proc.wait(), timeout=5)
+ except asyncio.TimeoutError:
+ proc.kill()
+ await proc.wait()
+ return 124, f"command timed out after {timeout}s: {' '.join(cmd)}"
+
+ return proc.returncode, stdout.decode(errors="replace") if stdout else ""
+
+ async def list_containers(self, prefix: str = "taos-agent-") -> list[ContainerInfo]:
+ """List all containers whose name starts with prefix."""
+ code, output = await self._run([self.binary, "ls", "-a", "--format", "json"])
+ if code != 0:
+ logger.error("apple container ls failed: %s", output)
+ return []
+ try:
+ items = json.loads(output) if output.strip() else []
+ except json.JSONDecodeError:
+ logger.error("apple container ls returned non-JSON: %s", output[:200])
+ return []
+
+ results: list[ContainerInfo] = []
+ for it in items:
+ name = it.get("name", "")
+ if not name.startswith(prefix):
+ continue
+ results.append(
+ ContainerInfo(
+ name=name,
+ status=it.get("status", "unknown"),
+ ip=it.get("ip"),
+ memory_mb=_parse_memory(str(it.get("memory", "0"))),
+ cpu_cores=int(it.get("cpus", 0) or 0),
+ )
+ )
+ return results
+
+ async def set_root_quota(self, name: str, size_gib: int) -> dict:
+ return {
+ "success": True,
+ "output": "",
+ "note": "apple container CLI exposes no rootfs quota; accounting-only",
+ }
+
+ async def create_container(
+ self,
+ name: str,
+ image: str = "docker.io/library/debian:bookworm",
+ memory_limit: str | None = None,
+ cpu_limit: int | None = None,
+ mounts: list[tuple[str, str]] | None = None,
+ env: dict[str, str] | None = None,
+ host_uid: int | None = None,
+ root_size_gib: int | None = None,
+ ) -> dict:
+ # Apple's container CLI does not currently expose host UID mapping for
+ # bind mounts. Reject the request explicitly so callers don't silently
+ # get root-owned files inside the container.
+ if host_uid is not None:
+ return {
+ "success": False,
+ "output": "",
+ "note": "apple container backend does not support host_uid mapping yet",
+ }
+ argv = [self.binary, "run", "-d", "--name", name]
+ if memory_limit:
+ # Convert "2GB"/"512MB" → "2g"/"512m" for Apple CLI
+ ml = memory_limit.strip().lower().replace("gb", "g").replace("mb", "m")
+ argv += ["--memory", ml]
+ if cpu_limit:
+ argv += ["--cpus", str(cpu_limit)]
+ for host_path, guest_path in mounts or []:
+ argv += ["-v", f"{host_path}:{guest_path}"]
+ for key, value in (env or {}).items():
+ argv += ["-e", f"{key}={value}"]
+ argv.append(image)
+
+ code, output = await self._run(argv)
+ if code != 0:
+ return {"success": False, "output": output}
+
+ if root_size_gib is not None:
+ quota_result = await self.set_root_quota(name, root_size_gib)
+ if isinstance(quota_result, dict) and not quota_result.get("success"):
+ logger.warning(
+ "set_root_quota for %s did not succeed: %s",
+ name,
+ quota_result.get("note") or quota_result.get("output"),
+ )
+
+ return {"success": True, "output": output.strip()}
+
+ async def exec_in_container(
+ self, name: str, cmd: list[str], timeout: int = 300
+ ) -> tuple[int, str]:
+ return await self._run([self.binary, "exec", name, *cmd], timeout=timeout)
+
+ async def push_file(
+ self, name: str, local_path: str, remote_path: str
+ ) -> tuple[int, str]:
+ return await self._run(
+ [self.binary, "cp", local_path, f"{name}:{remote_path}"]
+ )
+
+ async def start_container(self, name: str) -> dict:
+ code, output = await self._run([self.binary, "start", name])
+ return {"success": code == 0, "output": output}
+
+ async def stop_container(self, name: str, force: bool = False) -> dict:
+ verb = "kill" if force else "stop"
+ code, output = await self._run([self.binary, verb, name])
+ return {"success": code == 0, "output": output}
+
+ async def restart_container(self, name: str) -> dict:
+ code, output = await self._run([self.binary, "restart", name])
+ return {"success": code == 0, "output": output}
+
+ async def destroy_container(self, name: str) -> dict:
+ code, output = await self._run([self.binary, "rm", "-f", name])
+ return {"success": code == 0, "output": output}
+
+ async def get_container_logs(self, name: str, lines: int = 100) -> str:
+ code, output = await self._run(
+ [self.binary, "logs", "--tail", str(lines), name]
+ )
+ return output if code == 0 else f"Error getting logs: {output}"
+
+ async def rename_container(self, old_name: str, new_name: str) -> dict:
+ code, output = await self._run(
+ [self.binary, "rename", old_name, new_name]
+ )
+ return {"success": code == 0, "output": output}
+
+ async def add_proxy_device(
+ self, name: str, device_name: str, listen: str, connect: str,
+ bind_mode: str | None = None,
+ ) -> dict:
+ return {
+ "success": True,
+ "output": "",
+ "note": "apple containers reach host services via host.containers.internal",
+ }
+
+ async def snapshot_create(self, name: str, snapshot_name: str) -> dict:
+ code, output = await self._run(
+ [self.binary, "commit", name, f"taos/{snapshot_name}:latest"]
+ )
+ return {"success": code == 0, "output": output}
+
+ async def snapshot_restore(self, name: str, snapshot_name: str) -> dict:
+ return {
+ "success": False,
+ "output": "",
+ "note": "apple container snapshot restore not supported",
+ }
+
+ async def snapshot_list(self, name: str) -> dict:
+ code, output = await self._run(
+ [self.binary, "images", "ls", "--format", "json"]
+ )
+ if code != 0:
+ return {"success": False, "snapshots": [], "output": output}
+ try:
+ items = json.loads(output) if output.strip() else []
+ except json.JSONDecodeError:
+ return {"success": False, "snapshots": [], "output": output}
+
+ snapshots: list[str] = []
+ for it in items:
+ ref = it.get("reference", "")
+ if ref.startswith("taos/"):
+ tag = ref[len("taos/"):].split(":", 1)[0]
+ snapshots.append(tag)
+ return {"success": True, "snapshots": snapshots, "output": output}
+
+ async def set_env(self, name: str, key: str, value: str) -> dict:
+ return {
+ "success": False,
+ "output": "",
+ "note": "apple container env change requires recreate",
+ }
diff --git a/tinyagentos/containers/backend.py b/tinyagentos/containers/backend.py
index 79553283..778e5da4 100644
--- a/tinyagentos/containers/backend.py
+++ b/tinyagentos/containers/backend.py
@@ -2,7 +2,9 @@
from __future__ import annotations
import logging
+import os
import shutil
+import sys
from abc import ABC, abstractmethod
from dataclasses import dataclass
@@ -201,14 +203,16 @@ async def set_env(self, name: str, key: str, value: str) -> dict:
def detect_runtime() -> str:
"""Detect the available container runtime.
- Checks for incus, docker, podman in priority order.
- Returns 'lxc', 'docker', 'podman', or 'none'.
+ On macOS, the Mac launcher signals its bundled apple/container CLI by
+ setting ``TAOS_CONTAINER_BIN``. When present, the apple backend wins
+ over every other runtime (Mac .app is a self-contained product).
- Policy: LXC (incus) is preferred when multiple runtimes are present.
- Docker and podman are supported for app-store services but never take
- precedence over LXC for agent deployment.
+ Otherwise checks for incus, docker, podman in priority order.
+ Returns 'apple', 'lxc', 'docker', 'podman', or 'none'.
"""
available = []
+ if sys.platform == "darwin" and os.environ.get("TAOS_CONTAINER_BIN"):
+ available.append("apple")
if shutil.which("incus"):
available.append("lxc")
if shutil.which("docker"):
@@ -216,9 +220,9 @@ def detect_runtime() -> str:
if shutil.which("podman"):
available.append("podman")
- if "lxc" in available:
- # LXC is preferred: full-OS containers align with taOS's host-owns-state
- # model; Docker coexists for app-store services but never displaces LXC.
+ if "apple" in available:
+ selected = "apple"
+ elif "lxc" in available:
selected = "lxc"
elif available:
selected = available[0]
@@ -226,7 +230,7 @@ def detect_runtime() -> str:
selected = "none"
logger.info(
- "detect_runtime: selected=%s, available=%s, policy=lxc-preferred",
+ "detect_runtime: selected=%s, available=%s, policy=apple-on-mac>lxc>others",
selected,
available,
)
diff --git a/tinyagentos/requirements.lock b/tinyagentos/requirements.lock
new file mode 100644
index 00000000..74edb855
--- /dev/null
+++ b/tinyagentos/requirements.lock
@@ -0,0 +1,144 @@
+#
+# This file is autogenerated by pip-compile with Python 3.12
+# by the following command:
+#
+# pip-compile --output-file=/Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/tinyagentos/requirements.lock /Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/pyproject.toml
+#
+aiosqlite==0.22.1
+ # via tinyagentos (/Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/pyproject.toml)
+annotated-doc==0.0.4
+ # via
+ # fastapi
+ # typer
+annotated-types==0.7.0
+ # via pydantic
+anyio==4.13.0
+ # via
+ # httpx
+ # starlette
+ # watchfiles
+certifi==2026.4.22
+ # via
+ # httpcore
+ # httpx
+click==8.3.3
+ # via
+ # typer
+ # uvicorn
+fastapi==0.136.1
+ # via tinyagentos (/Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/pyproject.toml)
+filelock==3.29.0
+ # via huggingface-hub
+flatbuffers==25.12.19
+ # via onnxruntime
+fsspec==2026.3.0
+ # via huggingface-hub
+h11==0.16.0
+ # via
+ # httpcore
+ # uvicorn
+hf-xet==1.4.3
+ # via huggingface-hub
+httpcore==1.0.9
+ # via httpx
+httptools==0.7.1
+ # via uvicorn
+httpx==0.28.1
+ # via
+ # huggingface-hub
+ # tinyagentos (/Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/pyproject.toml)
+huggingface-hub==1.12.0
+ # via
+ # tokenizers
+ # transformers
+idna==3.13
+ # via
+ # anyio
+ # httpx
+jinja2==3.1.6
+ # via tinyagentos (/Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/pyproject.toml)
+libtorrent==2.0.11
+ # via tinyagentos (/Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/pyproject.toml)
+markdown-it-py==4.0.0
+ # via rich
+markupsafe==3.0.3
+ # via jinja2
+mdurl==0.1.2
+ # via markdown-it-py
+numpy==2.4.4
+ # via
+ # onnxruntime
+ # taosmd
+ # transformers
+onnxruntime==1.25.1
+ # via taosmd
+packaging==26.2
+ # via
+ # huggingface-hub
+ # onnxruntime
+ # transformers
+protobuf==7.34.1
+ # via onnxruntime
+psutil==7.2.2
+ # via tinyagentos (/Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/pyproject.toml)
+pydantic==2.13.3
+ # via fastapi
+pydantic-core==2.46.3
+ # via pydantic
+pygments==2.20.0
+ # via rich
+python-dotenv==1.2.2
+ # via uvicorn
+python-multipart==0.0.27
+ # via tinyagentos (/Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/pyproject.toml)
+pyyaml==6.0.3
+ # via
+ # huggingface-hub
+ # tinyagentos (/Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/pyproject.toml)
+ # transformers
+ # uvicorn
+regex==2026.4.4
+ # via transformers
+rich==15.0.0
+ # via typer
+safetensors==0.7.0
+ # via transformers
+shellingham==1.5.4
+ # via typer
+starlette==1.0.0
+ # via fastapi
+taosmd @ git+https://github.com/jaylfc/taosmd.git@master
+ # via tinyagentos (/Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/pyproject.toml)
+tokenizers==0.22.2
+ # via transformers
+tqdm==4.67.3
+ # via
+ # huggingface-hub
+ # transformers
+transformers==5.6.2
+ # via taosmd
+typer==0.25.0
+ # via
+ # huggingface-hub
+ # transformers
+typing-extensions==4.15.0
+ # via
+ # anyio
+ # fastapi
+ # huggingface-hub
+ # pydantic
+ # pydantic-core
+ # starlette
+ # typing-inspection
+typing-inspection==0.4.2
+ # via
+ # fastapi
+ # pydantic
+uvicorn[standard]==0.46.0
+ # via tinyagentos (/Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/pyproject.toml)
+uvloop==0.22.1
+ # via uvicorn
+watchfiles==1.1.1
+ # via uvicorn
+websockets==16.0
+ # via uvicorn
diff --git a/tinyagentos/routes/settings.py b/tinyagentos/routes/settings.py
index 1f2d2e23..478a6d7f 100644
--- a/tinyagentos/routes/settings.py
+++ b/tinyagentos/routes/settings.py
@@ -409,7 +409,7 @@ async def set_container_runtime(request: Request):
"""Set the container runtime preference."""
body = await request.json()
runtime = body.get("runtime", "auto")
- if runtime not in ("auto", "lxc", "docker", "podman"):
+ if runtime not in ("auto", "apple", "lxc", "docker", "podman"):
return JSONResponse({"error": f"Invalid runtime: {runtime}"}, status_code=400)
config = request.app.state.config
config.container_runtime = runtime
@@ -421,7 +421,10 @@ async def set_container_runtime(request: Request):
effective = runtime
if runtime == "auto":
effective = detect_runtime()
- if effective == "lxc":
+ if effective == "apple":
+ from tinyagentos.containers.apple_backend import AppleContainerBackend
+ set_backend(AppleContainerBackend())
+ elif effective == "lxc":
set_backend(LXCBackend())
elif effective in ("docker", "podman"):
set_backend(DockerBackend(binary=effective))