diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a0470b..45e00b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,8 @@ jobs: run: swift --version - name: Build run: swift build + - name: Packaging smoke (unsigned; release signing is tag-only) + run: scripts/test-packaging.sh .build/debug/oab-instance-mcp - name: Test # OsascriptToolTests actually executes /usr/bin/osascript. Basic # scripts don't need TCC grants, but headless-runner behavior is diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f07c0f7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,176 @@ +name: Release macOS installer + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Existing v* tag to release (for retry only)" + required: true + type: string + +# Release is the only workflow allowed to write repository contents. It never +# runs PR-controlled code with secrets: push tags and manual dispatch only. +permissions: + contents: write + +concurrency: + group: instance-mcp-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: Universal app + signed/notarized pkg + runs-on: macos-15 + environment: release + env: + EXPECTED_TEAM_ID: 6LPQNY95AQ + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + APP_CERT_P12_B64: ${{ secrets.MACOS_APP_CERT_P12_BASE64 }} + APP_CERT_PASSWORD: ${{ secrets.MACOS_APP_CERT_PASSWORD }} + APP_SIGN_IDENTITY: ${{ secrets.MACOS_APP_SIGN_IDENTITY }} + INSTALLER_CERT_P12_B64: ${{ secrets.MACOS_INSTALLER_CERT_P12_BASE64 }} + INSTALLER_CERT_PASSWORD: ${{ secrets.MACOS_INSTALLER_CERT_PASSWORD }} + INSTALLER_SIGN_IDENTITY: ${{ secrets.MACOS_INSTALLER_SIGN_IDENTITY }} + NOTARY_KEY_P8_B64: ${{ secrets.APPLE_NOTARY_KEY_P8_BASE64 }} + NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ env.RELEASE_TAG }} + fetch-depth: 0 + + - name: Validate tag and release secrets + shell: bash + run: | + set -euo pipefail + case "$RELEASE_TAG" in v[0-9]*.[0-9]*.[0-9]*) ;; *) echo "tag must be vMAJOR.MINOR.PATCH" >&2; exit 64;; esac + for var in APP_CERT_P12_B64 APP_CERT_PASSWORD APP_SIGN_IDENTITY \ + INSTALLER_CERT_P12_B64 INSTALLER_CERT_PASSWORD INSTALLER_SIGN_IDENTITY \ + NOTARY_KEY_P8_B64 NOTARY_KEY_ID NOTARY_ISSUER_ID; do + [ -n "${!var:-}" ] || { echo "missing release secret: $var" >&2; exit 78; } + done + case "$APP_SIGN_IDENTITY" in "Developer ID Application:"*) ;; *) echo "MACOS_APP_SIGN_IDENTITY must be Developer ID Application" >&2; exit 78;; esac + case "$INSTALLER_SIGN_IDENTITY" in "Developer ID Installer:"*) ;; *) echo "MACOS_INSTALLER_SIGN_IDENTITY must be Developer ID Installer" >&2; exit 78;; esac + VERSION=${RELEASE_TAG#v} + grep -q "let version = \"$VERSION\"" Sources/oab-instance-mcp/main.swift || { + echo "tag $RELEASE_TAG does not match the binary version source" >&2; exit 65; } + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "DIST=$RUNNER_TEMP/dist" >> "$GITHUB_ENV" + + - name: Test + run: swift test --skip OsascriptToolTests + + - name: Build universal release binary + shell: bash + run: | + set -euo pipefail + mkdir -p "$DIST" + swift build -c release --arch arm64 --scratch-path "$RUNNER_TEMP/build-arm64" + swift build -c release --arch x86_64 --scratch-path "$RUNNER_TEMP/build-x86_64" + /usr/bin/lipo -create \ + "$RUNNER_TEMP/build-arm64/release/oab-instance-mcp" \ + "$RUNNER_TEMP/build-x86_64/release/oab-instance-mcp" \ + -output "$DIST/oab-instance-mcp" + chmod 755 "$DIST/oab-instance-mcp" + /usr/bin/lipo -info "$DIST/oab-instance-mcp" | grep -q 'x86_64 arm64\|arm64 x86_64' + [ "$($DIST/oab-instance-mcp --version)" = "$VERSION" ] + scripts/assemble-app.sh "$DIST/oab-instance-mcp" "$DIST/oab-instance-mcp.app" "$VERSION" + + - name: Import Developer ID certificates + shell: bash + run: | + set -euo pipefail + KEYCHAIN="$RUNNER_TEMP/release-signing.keychain-db" + KEYCHAIN_PASSWORD=$(/usr/bin/openssl rand -hex 24) + echo "KEYCHAIN=$KEYCHAIN" >> "$GITHUB_ENV" + echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> "$GITHUB_ENV" + # Preserve the search list even though this job is pinned to a GitHub- + # hosted runner; restoring it keeps the workflow safe if moved later. + /usr/bin/security list-keychains -d user | \ + /usr/bin/sed -E 's/^[[:space:]]*"(.*)"$/\1/' > "$RUNNER_TEMP/original-keychains.txt" + /usr/bin/security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + /usr/bin/security set-keychain-settings -lut 21600 "$KEYCHAIN" + /usr/bin/security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + printf '%s' "$APP_CERT_P12_B64" | /usr/bin/base64 -D > "$RUNNER_TEMP/app.p12" + printf '%s' "$INSTALLER_CERT_P12_B64" | /usr/bin/base64 -D > "$RUNNER_TEMP/installer.p12" + /usr/bin/security import "$RUNNER_TEMP/app.p12" -k "$KEYCHAIN" -P "$APP_CERT_PASSWORD" -T /usr/bin/codesign + /usr/bin/security import "$RUNNER_TEMP/installer.p12" -k "$KEYCHAIN" -P "$INSTALLER_CERT_PASSWORD" -T /usr/bin/pkgbuild + /usr/bin/security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null + ORIGINAL=() + while IFS= read -r item; do [ -z "$item" ] || ORIGINAL+=("$item"); done < "$RUNNER_TEMP/original-keychains.txt" + /usr/bin/security list-keychains -d user -s "$KEYCHAIN" "${ORIGINAL[@]}" + /usr/bin/security find-identity -v "$KEYCHAIN" + + - name: Sign and notarize app + shell: bash + run: | + set -euo pipefail + APP="$DIST/oab-instance-mcp.app" + /usr/bin/codesign --force --deep --options runtime --timestamp \ + --keychain "$KEYCHAIN" --sign "$APP_SIGN_IDENTITY" "$APP" + /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP" + TEAM=$(/usr/bin/codesign -dvv "$APP" 2>&1 | /usr/bin/sed -n 's/^TeamIdentifier=//p') + [ "$TEAM" = "$EXPECTED_TEAM_ID" ] || { echo "wrong app team: $TEAM" >&2; exit 65; } + /usr/bin/ditto -c -k --keepParent "$APP" "$RUNNER_TEMP/app-for-notary.zip" + printf '%s' "$NOTARY_KEY_P8_B64" | /usr/bin/base64 -D > "$RUNNER_TEMP/AuthKey.p8" + xcrun notarytool submit "$RUNNER_TEMP/app-for-notary.zip" \ + --key "$RUNNER_TEMP/AuthKey.p8" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER_ID" --wait + xcrun stapler staple "$APP" + xcrun stapler validate "$APP" + rm -f "$DIST/oab-instance-mcp-$VERSION-universal.app.zip" + /usr/bin/ditto -c -k --sequesterRsrc --keepParent "$APP" \ + "$DIST/oab-instance-mcp-$VERSION-universal.app.zip" + + - name: Build, sign, and notarize installer pkg + shell: bash + run: | + set -euo pipefail + PKG="$DIST/oab-instance-mcp-$VERSION-universal.pkg" + EXPECT_TEAM="$EXPECTED_TEAM_ID" PKG_SIGN_IDENTITY="$INSTALLER_SIGN_IDENTITY" \ + PKG_KEYCHAIN="$KEYCHAIN" \ + scripts/package-pkg.sh "$DIST/oab-instance-mcp.app" "$PKG" "$VERSION" + /usr/sbin/pkgutil --check-signature "$PKG" | grep -q 'Developer ID Installer' + xcrun notarytool submit "$PKG" \ + --key "$RUNNER_TEMP/AuthKey.p8" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER_ID" --wait + xcrun stapler staple "$PKG" + xcrun stapler validate "$PKG" + /usr/sbin/spctl -a -vv --type install "$PKG" + scripts/verify-release.sh \ + "$DIST/oab-instance-mcp-$VERSION-universal.app.zip" "$PKG" "$EXPECTED_TEAM_ID" + + - name: Checksums and release + shell: bash + run: | + set -euo pipefail + cd "$DIST" + /usr/bin/shasum -a 256 \ + "oab-instance-mcp-$VERSION-universal.app.zip" \ + "oab-instance-mcp-$VERSION-universal.pkg" > SHA256SUMS + gh release view "$RELEASE_TAG" >/dev/null 2>&1 || \ + gh release create "$RELEASE_TAG" --verify-tag --generate-notes \ + --title "oab-instance-mcp $VERSION" + gh release upload "$RELEASE_TAG" --clobber \ + "oab-instance-mcp-$VERSION-universal.app.zip" \ + "oab-instance-mcp-$VERSION-universal.pkg" SHA256SUMS + + - name: Clean signing material + if: always() + shell: bash + run: | + rm -f "$RUNNER_TEMP/app.p12" "$RUNNER_TEMP/installer.p12" \ + "$RUNNER_TEMP/AuthKey.p8" "$RUNNER_TEMP/app-for-notary.zip" + if [ -f "$RUNNER_TEMP/original-keychains.txt" ]; then + ORIGINAL=() + while IFS= read -r item; do [ -z "$item" ] || ORIGINAL+=("$item"); done < "$RUNNER_TEMP/original-keychains.txt" + if [ "${#ORIGINAL[@]}" -gt 0 ]; then + /usr/bin/security list-keychains -d user -s "${ORIGINAL[@]}" || true + fi + rm -f "$RUNNER_TEMP/original-keychains.txt" + fi + if [ -n "${KEYCHAIN:-}" ] && [ -f "$KEYCHAIN" ]; then + /usr/bin/security delete-keychain "$KEYCHAIN" || true + fi diff --git a/README.md b/README.md index f499c25..364000f 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,32 @@ Verified 2026-09-26 end to end on macmini against the openab-pty runtime (PR #38 `sys_info screenshot mouse key osascript instance_status`, `exec` refused, `sys_info` answered, `DELETE /attach/{id}` detached. +## Download and install + +Tagged releases publish a universal, Developer-ID-signed and Apple-notarized installer: + +1. Download `oab-instance-mcp-VERSION-universal.pkg` from + [GitHub Releases](https://github.com/openabdev/instance-mcp/releases). +2. Sign into Tailscale and keep a desktop user logged in. +3. Double-click the package. It auto-detects your Tailscale login/name, preserves or creates the + bearer token, installs the LaunchAgent, detects the Playwright upstream, and configures + `tailscale serve :8444`. +4. Once, enable Full Disk Access, Screen & System Audio Recording, and Accessibility for + **oab-instance-mcp** in System Settings → Privacy & Security. Future releases keep the same + Developer ID + bundle id, so these grants survive updates. +5. Use the menu bar item to copy the MCP URL and bearer token into OpenAB Connect/Remote. + +The `.app.zip` beside the package is an advanced/manual artifact. After unzipping: + +```sh +/path/to/oab-instance-mcp.app/Contents/Resources/install-prebuilt.sh \ + /path/to/oab-instance-mcp.app --allow-login auto +``` + +The installer never re-signs the app: doing so would change the identity TCC grants are bound to. +See [`docs/releasing.md`](docs/releasing.md) for artifacts, signing/notarization, required secrets, +local packaging smoke, and the current first-release signing blocker. + ## TCC grants survive re-deploys only if the signature does Screen Recording, Accessibility and Full Disk Access are keyed on the **code-signing identity + diff --git a/Tests/InstanceMCPCoreTests/InstanceMCPCoreTests.swift b/Tests/InstanceMCPCoreTests/InstanceMCPCoreTests.swift index 20848ab..627b009 100644 --- a/Tests/InstanceMCPCoreTests/InstanceMCPCoreTests.swift +++ b/Tests/InstanceMCPCoreTests/InstanceMCPCoreTests.swift @@ -1,4 +1,5 @@ import ApplicationServices +import Foundation import XCTest @testable import InstanceMCPCore diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..649a129 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,127 @@ +# Releasing oab-instance-mcp for macOS + +A release is a **universal (arm64 + x86_64), Developer-ID-signed and Apple-notarized** app plus a +signed/notarized installer package. The package is the normal download; the app zip is for advanced +users and inspection. + +## Artifacts + +A `vMAJOR.MINOR.PATCH` tag publishes: + +| Artifact | Use | +|---|---| +| `oab-instance-mcp-VERSION-universal.pkg` | Recommended. Double-click; installs/configures the app for the logged-in desktop user | +| `oab-instance-mcp-VERSION-universal.app.zip` | Pre-signed app, no package receipt. Contains `Contents/Resources/install-prebuilt.sh` for manual installation | +| `SHA256SUMS` | SHA-256 of both artifacts | + +The installer: + +1. verifies bundle id, code signature and team `6LPQNY95AQ` **before** stopping the running service; +2. detects the current Tailscale login and MagicDNS name from structured `tailscale status --json`; +3. installs to `~/.local/oab-instance-mcp/oab-instance-mcp.app` without re-signing it; +4. creates a bearer token once at `~/.config/oab-instance-mcp/token`, mode 600, and preserves it on updates; +5. writes/starts the Aqua-user LaunchAgent `dev.openab.instance-mcp`; +6. adds the Playwright upstream when `dev.openab.instance-mcp.pw-mcp` is installed; and +7. configures `tailscale serve --https=8444` to loopback port 8795. + +A logged-in GUI user and a logged-in Tailscale app are prerequisites. Package scripts run as root, +but immediately enter the console user's GUI bootstrap namespace and drop to that user; no token, +LaunchAgent or config is written to root's home. + +## One-time TCC grant + +After the first Developer-ID release install, enable **oab-instance-mcp** once under System Settings → +Privacy & Security: + +- Full Disk Access +- Screen & System Audio Recording +- Accessibility + +The first switch from the current Apple Development signature to Developer ID may require that one +re-grant. Every later release carries the same bundle id and Developer ID team, so the grant remains. +Neither the installer nor CI ever ad-hoc re-signs a release app. `install-prebuilt.sh` refuses a wrong +or missing TeamIdentifier before replacing the running app. + +## Required GitHub environment and secrets + +The workflow uses the `release` environment. Create it with required-reviewer protection if the repo +plan supports that, then add: + +| Secret | Value | +|---|---| +| `MACOS_APP_CERT_P12_BASE64` | Base64 of the **Developer ID Application** certificate + private key `.p12` | +| `MACOS_APP_CERT_PASSWORD` | `.p12` export password | +| `MACOS_APP_SIGN_IDENTITY` | Exact common name, e.g. `Developer ID Application: Name (6LPQNY95AQ)` | +| `MACOS_INSTALLER_CERT_P12_BASE64` | Base64 of the **Developer ID Installer** certificate + private key `.p12` | +| `MACOS_INSTALLER_CERT_PASSWORD` | `.p12` export password | +| `MACOS_INSTALLER_SIGN_IDENTITY` | Exact common name, e.g. `Developer ID Installer: Name (6LPQNY95AQ)` | +| `APPLE_NOTARY_KEY_P8_BASE64` | Base64 of an App Store Connect API `.p8` key allowed to notarize | +| `APPLE_NOTARY_KEY_ID` | API key id | +| `APPLE_NOTARY_ISSUER_ID` | API issuer id | + +**Use team `6LPQNY95AQ` only.** The machine still contains a deprecated team +`UM92U863A8` Developer ID Application certificate; it must never sign these releases. As of +2026-09-27 there is no Developer ID Application or Installer certificate for `6LPQNY95AQ` on the +build machines and the repository has no Actions secrets, so the first signed tag is intentionally +blocked until those assets are created and installed as secrets. + +The tag workflow validates that both identity names are Developer ID identities, verifies the app's +TeamIdentifier is exactly `6LPQNY95AQ`, submits/staples both app and pkg, and runs signature/Gatekeeper +checks before creating the GitHub Release. Signing material lives in an ephemeral keychain and is +deleted in an `always()` cleanup step. + +## Cut a release + +1. Update `let version = "…"` in `Sources/oab-instance-mcp/main.swift` and merge with green CI. +2. Ensure the matching active-team release secrets above exist. +3. Tag the exact main commit and push: + + ```sh + git tag v0.7.0 + git push origin v0.7.0 + ``` + +4. The `Release macOS installer` workflow builds/tests, signs, notarizes and publishes. A manual + dispatch can retry an existing tag; it is not a way to release an untagged commit. +5. Download both artifacts and verify before installing: + + ```sh + scripts/verify-release.sh \ + oab-instance-mcp-0.7.0-universal.app.zip \ + oab-instance-mcp-0.7.0-universal.pkg + shasum -a 256 -c SHA256SUMS + ``` + +6. Install the `.pkg` on a clean/test Mac, verify `sys_info`, and make one reverse-attach call + before announcing it. + +Never move or recreate a tag after an artifact has been published. + +## Local/no-secret smoke + +CI runs this on every PR: + +```sh +swift build +scripts/test-packaging.sh .build/debug/oab-instance-mcp +``` + +It assembles the app, proves unsigned input is rejected by the production installer, exercises the +explicit unsigned test seam in a temporary home, verifies token persistence/LaunchAgent arguments, +builds an unsigned flat pkg, expands it, and checks its payload and postinstall scripts. It never +launches an agent or changes the user's Tailscale serve config. + +To build a universal unsigned artifact manually on a Mac: + +```sh +swift build -c release --arch arm64 --scratch-path /tmp/imcp-arm64 +swift build -c release --arch x86_64 --scratch-path /tmp/imcp-x86_64 +lipo -create /tmp/imcp-arm64/release/oab-instance-mcp \ + /tmp/imcp-x86_64/release/oab-instance-mcp \ + -output /tmp/oab-instance-mcp +chmod +x /tmp/oab-instance-mcp +scripts/assemble-app.sh /tmp/oab-instance-mcp /tmp/oab-instance-mcp.app 0.6.0 +ALLOW_UNSIGNED=1 scripts/package-pkg.sh /tmp/oab-instance-mcp.app /tmp/oab-instance-mcp.pkg 0.6.0 +``` + +Unsigned artifacts are testing inputs only; do not install or publish them. diff --git a/scripts/assemble-app.sh b/scripts/assemble-app.sh new file mode 100755 index 0000000..88decd6 --- /dev/null +++ b/scripts/assemble-app.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# Assemble the minimal oab-instance-mcp.app bundle around a prebuilt binary. +# +# This script does NOT sign. Local deploy.sh signs with Apple Development; the +# release workflow signs with Developer ID Application. Keeping assembly separate +# makes the bytes, bundle id and entitlements identical in both paths — important +# because TCC grants are keyed on the signed bundle identity. +# +# Usage: assemble-app.sh [version] +set -euo pipefail + +BIN="${1:?usage: assemble-app.sh [version]}" +OUT="${2:?usage: assemble-app.sh [version]}" +VERSION="${3:-}" +BUNDLE_ID="${BUNDLE_ID:-dev.openab.instance-mcp}" + +[ -f "$BIN" ] && [ -x "$BIN" ] || { + echo "assemble-app: binary is missing or not executable: $BIN" >&2 + exit 66 +} +case "$OUT" in + *.app) ;; + *) echo "assemble-app: output must end in .app: $OUT" >&2; exit 64 ;; +esac + +if [ -z "$VERSION" ]; then + VERSION=$("$BIN" --version 2>/dev/null) || { + echo "assemble-app: pass a version when the binary cannot run on this host" >&2 + exit 65 + } +fi +# CFBundleShortVersionString: one to three dot-separated non-negative integers. +case "$VERSION" in + ''|*[!0-9.]*) echo "assemble-app: invalid version: $VERSION" >&2; exit 64 ;; +esac +IFS=. read -r -a VERSION_PARTS <<<"$VERSION" +[ "${#VERSION_PARTS[@]}" -ge 1 ] && [ "${#VERSION_PARTS[@]}" -le 3 ] || { + echo "assemble-app: version must have 1–3 numeric components: $VERSION" >&2 + exit 64 +} +for part in "${VERSION_PARTS[@]}"; do + [ -n "$part" ] || { echo "assemble-app: empty version component: $VERSION" >&2; exit 64; } +done + +PARENT=$(dirname "$OUT") +NAME=$(basename "$OUT") +mkdir -p "$PARENT" +TMP=$(mktemp -d "$PARENT/.${NAME}.assemble.XXXXXX") +trap 'rm -rf "$TMP"' EXIT +APP="$TMP/$NAME" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +/usr/bin/ditto "$BIN" "$APP/Contents/MacOS/oab-instance-mcp" +chmod 755 "$APP/Contents/MacOS/oab-instance-mcp" +# Makes the advanced .app.zip artifact self-contained: after unzipping, run +# app/Contents/Resources/install-prebuilt.sh app +# The signed/notarized .pkg remains the normal one-click install path. +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +cp "$SCRIPT_DIR/install-prebuilt.sh" "$APP/Contents/Resources/install-prebuilt.sh" +chmod 755 "$APP/Contents/Resources/install-prebuilt.sh" + +cat >"$APP/Contents/Info.plist" < + + + CFBundleIdentifier$BUNDLE_ID + CFBundleNameoab-instance-mcp + CFBundleDisplayNameOpenAB Instance MCP + CFBundleExecutableoab-instance-mcp + CFBundlePackageTypeAPPL + CFBundleShortVersionString$VERSION + CFBundleVersion$VERSION + LSMinimumSystemVersion14.0 + LSUIElement + + NSAppTransportSecurity + NSAllowsArbitraryLoads + + NSHumanReadableCopyrightOpenAB + +EOF + +/usr/bin/plutil -lint "$APP/Contents/Info.plist" >/dev/null +[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Contents/Info.plist")" = "$BUNDLE_ID" ] +[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")" = "$VERSION" ] + +rm -rf "$OUT" +mv "$APP" "$OUT" +echo "assembled: $OUT ($VERSION, $BUNDLE_ID)" diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 6051c13..dd5655c 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,168 +1,57 @@ #!/bin/bash -# Deploy oab-instance-mcp on this Mac (run ON the target, e.g. macmini). -# scripts/deploy.sh -# Team-signed only: installing an ad-hoc bundle drops the human's TCC grants, so it is -# refused unless ALLOW_ADHOC=1. Expected team defaults to 6LPQNY95AQ (EXPECT_TEAM=... to change). +# Build-tree deploy for developers. Assembles and signs a temporary app, then +# hands it to install-prebuilt.sh — the same installer used by release packages. # -# env: KEYCHAIN= keychain holding the identity (default: login keychain) -# KEYCHAIN_PASSWORD_FILE= if set, unlock $KEYCHAIN first (needed over non-interactive -# SSH: the login keychain answers errSecInternalComponent) -# - wraps the release binary in a minimal .app so TCC grants bind to a stable bundle id -# - signs it (Apple Development is enough for a local LaunchAgent; no notarization needed) -# - installs a LaunchAgent in gui/ and `tailscale serve --https=8444` +# Usage: scripts/deploy.sh +# +# Team-signed only: installing an ad-hoc bundle drops the human's TCC grants, so +# it is refused unless ALLOW_ADHOC=1. Expected team defaults to 6LPQNY95AQ +# (EXPECT_TEAM=... to change). +# +# Optional keychain env (needed when a dedicated signing keychain is used): +# KEYCHAIN= +# KEYCHAIN_PASSWORD_FILE= set -euo pipefail -LOGIN="${1:?usage: deploy.sh }" -IDENTITY="${2:?usage: deploy.sh (SHA-1 or name of an Apple Development cert)}" -PORT=8795; HTTPS_PORT=8444 -LABEL=dev.openab.instance-mcp -BUNDLE_ID=dev.openab.instance-mcp -BASE="$HOME/.local/oab-instance-mcp" -APP="$BASE/oab-instance-mcp.app" -BIN="$(cd "$(dirname "$0")/.." && pwd)/.build/release/oab-instance-mcp" -TS=/Applications/Tailscale.app/Contents/MacOS/Tailscale -VERSION="$("$BIN" --version)" -DNSNAME="$("$TS" status --self --peers=false --json 2>/dev/null | python3 -c 'import json,sys;print(json.load(sys.stdin)["Self"]["DNSName"].rstrip("."))')" -[ -x "$BIN" ] || { echo "build first: swift build -c release"; exit 1; } -NEWEST_SRC=$(find "$(dirname "$0")/../Sources" -name '*.swift' -newer "$BIN" | head -1) -[ -z "$NEWEST_SRC" ] || { echo "binary older than $NEWEST_SRC — rebuild first"; exit 1; } -mkdir -p "$BASE" "$HOME/Library/Logs/oab-instance-mcp" +LOGIN="${1:?usage: deploy.sh }" +IDENTITY="${2:?usage: deploy.sh }" +ROOT=$(cd "$(dirname "$0")/.." && pwd) +BIN="$ROOT/.build/release/oab-instance-mcp" +EXPECT_TEAM="${EXPECT_TEAM:-6LPQNY95AQ}" -# One-shot migration from the pre-0.4.0 name (oab-mac-agent / oab-mc-agent / -# dev.openab.mac-agent): retire the old LaunchAgent and carry the bearer token over so -# clients keep working. TCC grants do NOT carry over — they are keyed on the bundle id. -OLD_LABEL=dev.openab.mac-agent -if launchctl print "gui/$(id -u)/$OLD_LABEL" >/dev/null 2>&1; then - echo "retiring old LaunchAgent $OLD_LABEL" - launchctl bootout "gui/$(id -u)/$OLD_LABEL" || true - for _ in $(seq 1 40); do launchctl print "gui/$(id -u)/$OLD_LABEL" >/dev/null 2>&1 || break; sleep 0.25; done -fi -rm -f "$HOME/Library/LaunchAgents/$OLD_LABEL.plist" -if [ -s "$HOME/.config/oab-mac-agent/token" ] && [ ! -s "$HOME/.config/oab-instance-mcp/token" ]; then - mkdir -p "$HOME/.config/oab-instance-mcp" - mv "$HOME/.config/oab-mac-agent/token" "$HOME/.config/oab-instance-mcp/token" - echo "carried bearer token over from ~/.config/oab-mac-agent/token" -fi +[ -x "$BIN" ] || { echo "build first: swift build -c release" >&2; exit 1; } +NEWEST_SRC=$(find "$ROOT/Sources" -name '*.swift' -newer "$BIN" | head -1) +[ -z "$NEWEST_SRC" ] || { echo "binary older than $NEWEST_SRC — rebuild first" >&2; exit 1; } +VERSION=$("$BIN" --version) +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +APP="$TMP/oab-instance-mcp.app" -# Bearer token: a second factor AND-combined with --allow-login, so a leaked tailnet -# credential alone cannot reach the agent. Generated once and reused across re-deploys -# (stable like the TCC grants); rotate by deleting the file and re-deploying. -TOKEN_FILE="$HOME/.config/oab-instance-mcp/token" -if [ ! -s "$TOKEN_FILE" ]; then - mkdir -p "$(dirname "$TOKEN_FILE")" - ( umask 077; openssl rand -hex 32 > "$TOKEN_FILE" ) - chmod 600 "$TOKEN_FILE" - echo "generated new bearer token at $TOKEN_FILE" -else - echo "reusing existing bearer token at $TOKEN_FILE" -fi +"$ROOT/scripts/assemble-app.sh" "$BIN" "$APP" "$VERSION" -echo "--- bundle $APP ($VERSION) ---" -rm -rf "$APP" -mkdir -p "$APP/Contents/MacOS" -cp "$BIN" "$APP/Contents/MacOS/oab-instance-mcp" -cat >"$APP/Contents/Info.plist" < - - - CFBundleIdentifier$BUNDLE_ID - CFBundleNameoab-instance-mcp - CFBundleExecutableoab-instance-mcp - CFBundlePackageTypeAPPL - CFBundleShortVersionString$VERSION - CFBundleVersion$VERSION - LSMinimumSystemVersion14.0 - LSUIElement - - NSAppTransportSecurityNSAllowsArbitraryLoads - NSHumanReadableCopyrightOpenAB - -EOF -# Over non-interactive SSH the login keychain refuses codesign (errSecInternalComponent); -# keep the identity in a dedicated keychain and point KEYCHAIN / KEYCHAIN_PASSWORD_FILE at it. KC_ARGS=() if [ -n "${KEYCHAIN:-}" ]; then - [ -z "${KEYCHAIN_PASSWORD_FILE:-}" ] || security unlock-keychain -p "$(cat "$KEYCHAIN_PASSWORD_FILE")" "$KEYCHAIN" + if [ -n "${KEYCHAIN_PASSWORD_FILE:-}" ]; then + security unlock-keychain -p "$(cat "$KEYCHAIN_PASSWORD_FILE")" "$KEYCHAIN" + fi KC_ARGS=(--keychain "$KEYCHAIN") fi -codesign --force --options runtime --timestamp=none ${KC_ARGS[@]+"${KC_ARGS[@]}"} --sign "$IDENTITY" --identifier "$BUNDLE_ID" "$APP" -codesign --verify --deep --strict "$APP" && echo "signed: $(codesign -dv "$APP" 2>&1 | grep -E '^(Authority=Apple Dev|TeamIdentifier)' | tr '\n' ' ')" +codesign --force --options runtime --timestamp=none \ + ${KC_ARGS[@]+"${KC_ARGS[@]}"} --sign "$IDENTITY" --identifier dev.openab.instance-mcp "$APP" +codesign --verify --deep --strict "$APP" -# TCC (Full Disk Access, Screen Recording, Accessibility) is keyed on the code-signing -# identity + bundle id. An ad-hoc signature has no stable identity, so macOS treats each -# ad-hoc build as a NEW app and silently drops every grant the user made — the Screens pane -# then freezes and screenshot returns "screen_recording=false". That happened repeatedly -# (see openab-pty#37 / instance-mcp#10) whenever a fast SSH deploy fell back to `--sign -`. -# Refuse to install anything but a Team-signed bundle, so a grant the human made ONCE is -# never quietly invalidated by a later deploy. Override only when you knowingly want an -# unsigned local build (and accept re-granting): ALLOW_ADHOC=1. TEAM=$(codesign -dvv "$APP" 2>&1 | sed -n 's/^TeamIdentifier=//p') -EXPECT_TEAM="${EXPECT_TEAM:-6LPQNY95AQ}" if [ "${ALLOW_ADHOC:-0}" != "1" ]; then - if [ -z "$TEAM" ] || [ "$TEAM" = "not set" ]; then - echo "refusing to install an ad-hoc-signed bundle: it would drop your TCC grants" >&2 - echo " (Full Disk Access / Screen Recording / Accessibility are keyed on the signing identity)." >&2 - echo " Sign with the Apple Development identity from a console session, or set ALLOW_ADHOC=1 to override." >&2 - exit 1 - fi - if [ "$TEAM" != "$EXPECT_TEAM" ]; then - echo "refusing: signed by team $TEAM, expected $EXPECT_TEAM — a different team is a different app to TCC." >&2 - echo " Set EXPECT_TEAM=$TEAM if this is intentional." >&2 - exit 1 - fi -fi - -# Re-serve the Playwright MCP (poc/pw-mcp) as browser_* tools when it is installed, -# so a lent sandbox session can read pages as text instead of screenshots (#10). -UPSTREAM_ARGS="" -if launchctl print "gui/$(id -u)/dev.openab.instance-mcp.pw-mcp" >/dev/null 2>&1; then - UPSTREAM_ARGS=' --upstreambrowser=http://127.0.0.1:8794/mcp' - echo "pw-mcp LaunchAgent present: re-serving it as browser_* tools" + if [ -z "$TEAM" ] || [ "$TEAM" = "not set" ]; then + echo "refusing to install an ad-hoc-signed bundle: it would drop your TCC grants" >&2 + exit 1 + fi + if [ "$TEAM" != "$EXPECT_TEAM" ]; then + echo "refusing: signed by team $TEAM, expected $EXPECT_TEAM — a different team is a different app to TCC" >&2 + exit 1 + fi fi +echo "signed: TeamIdentifier=${TEAM:-not set}" -echo "--- LaunchAgent $LABEL ---" -PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" -cat >"$PLIST" < - - - Label$LABEL - ProgramArguments - $APP/Contents/MacOS/oab-instance-mcp - --port$PORT - --allow-login$LOGIN - --token-file$TOKEN_FILE - --menu-bar - --public-urlhttps://$DNSNAME:$HTTPS_PORT/mcp -$UPSTREAM_ARGS - - RunAtLoad - KeepAlive - ProcessTypeInteractive - StandardOutPath$HOME/Library/Logs/oab-instance-mcp/agent.log - StandardErrorPath$HOME/Library/Logs/oab-instance-mcp/agent.log - -EOF -plutil -lint "$PLIST" -UID_=$(id -u) -if launchctl print "gui/$UID_/$LABEL" >/dev/null 2>&1; then - launchctl bootout "gui/$UID_/$LABEL" || true - # bootout returns before the job is gone; bootstrap races it and fails with EEXIST. - for _ in $(seq 1 40); do launchctl print "gui/$UID_/$LABEL" >/dev/null 2>&1 || break; sleep 0.25; done -fi -launchctl bootstrap "gui/$UID_" "$PLIST" -for _ in $(seq 1 20); do curl -s -m 1 "http://127.0.0.1:$PORT/healthz" >/dev/null && break; sleep 0.25; done -launchctl print "gui/$UID_/$LABEL" | grep -E 'state|pid =' -tail -3 "$HOME/Library/Logs/oab-instance-mcp/agent.log" - -echo "--- tailscale serve :$HTTPS_PORT → :$PORT ---" -"$TS" serve --bg --https="$HTTPS_PORT" "http://127.0.0.1:$PORT" >/dev/null -"$TS" serve status | grep -A1 ":$HTTPS_PORT" - -echo -echo "MCP URL: https://$DNSNAME:$HTTPS_PORT/mcp" -echo "Bearer token (set this in the client too): $(cat "$TOKEN_FILE")" -echo "Screen Recording: System Settings → Privacy & Security → Screen & System Audio Recording → enable oab-instance-mcp, then: launchctl kickstart -k gui/$UID_/$LABEL" +EXPECT_TEAM="$EXPECT_TEAM" ALLOW_UNSIGNED="${ALLOW_ADHOC:-0}" \ + "$ROOT/scripts/install-prebuilt.sh" "$APP" --allow-login "$LOGIN" diff --git a/scripts/install-prebuilt.sh b/scripts/install-prebuilt.sh new file mode 100755 index 0000000..ca113a8 --- /dev/null +++ b/scripts/install-prebuilt.sh @@ -0,0 +1,221 @@ +#!/bin/bash +# Install a PREBUILT, already-signed oab-instance-mcp.app for the logged-in user. +# +# This script never signs or modifies the bundle. That is load-bearing: an ad-hoc +# re-sign changes the app's designated requirement and silently drops the human's +# Full Disk Access / Screen Recording / Accessibility grants. CI release artifacts +# arrive Developer-ID signed; local deploy.sh signs first, then calls this script. +# +# Usage: install-prebuilt.sh [--allow-login ] +# +# Test seams (not used by the package): INSTALL_HOME, ALLOW_UNSIGNED=1, +# SKIP_LAUNCH=1, SKIP_TAILSCALE=1. +set -euo pipefail + +SOURCE="${1:?usage: install-prebuilt.sh [--allow-login ]}" +shift +LOGIN=auto +PORT="${PORT:-8795}" +HTTPS_PORT="${HTTPS_PORT:-8444}" +while [ "$#" -gt 0 ]; do + case "$1" in + --allow-login) [ "$#" -ge 2 ] || { echo "missing --allow-login value" >&2; exit 64; }; LOGIN=$2; shift 2 ;; + --port) [ "$#" -ge 2 ] || exit 64; PORT=$2; shift 2 ;; + --https-port) [ "$#" -ge 2 ] || exit 64; HTTPS_PORT=$2; shift 2 ;; + -h|--help) sed -n '2,14p' "$0"; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 64 ;; + esac +done + +LABEL=dev.openab.instance-mcp +OLD_LABEL=dev.openab.mac-agent +BUNDLE_ID=dev.openab.instance-mcp +EXPECT_TEAM="${EXPECT_TEAM:-6LPQNY95AQ}" +HOME_DIR="${INSTALL_HOME:-$HOME}" +BASE="$HOME_DIR/.local/oab-instance-mcp" +APP="$BASE/oab-instance-mcp.app" +TOKEN_FILE="$HOME_DIR/.config/oab-instance-mcp/token" +PLIST="$HOME_DIR/Library/LaunchAgents/$LABEL.plist" +LOG_DIR="$HOME_DIR/Library/Logs/oab-instance-mcp" +UID_=$(id -u) + +case "$SOURCE" in *.app) ;; *) echo "installer: source must be an .app bundle" >&2; exit 64 ;; esac +[ -x "$SOURCE/Contents/MacOS/oab-instance-mcp" ] || { + echo "installer: app has no executable: $SOURCE" >&2; exit 66 +} +[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$SOURCE/Contents/Info.plist" 2>/dev/null)" = "$BUNDLE_ID" ] || { + echo "installer: unexpected or missing bundle id (wanted $BUNDLE_ID)" >&2; exit 65 +} + +# Validate all prerequisites before stopping the running service or touching disk. +if [ "${ALLOW_UNSIGNED:-0}" != "1" ]; then + /usr/bin/codesign --verify --deep --strict "$SOURCE" || { + echo "installer: invalid code signature" >&2; exit 65 + } + TEAM=$(/usr/bin/codesign -dvv "$SOURCE" 2>&1 | /usr/bin/sed -n 's/^TeamIdentifier=//p') + [ "$TEAM" = "$EXPECT_TEAM" ] || { + echo "installer: refusing team '${TEAM:-none}', expected $EXPECT_TEAM" >&2 + echo "A different/ad-hoc signer is a different app to TCC and would drop existing grants." >&2 + exit 65 + } +fi + +TS="${TAILSCALE_CLI:-}" +if [ "${SKIP_TAILSCALE:-0}" != "1" ]; then + if [ -z "$TS" ]; then + if [ -x /Applications/Tailscale.app/Contents/MacOS/Tailscale ]; then + TS=/Applications/Tailscale.app/Contents/MacOS/Tailscale + elif command -v tailscale >/dev/null 2>&1; then + TS=$(command -v tailscale) + else + echo "installer: Tailscale is required (install and log in first)" >&2 + exit 69 + fi + fi + STATUS=$(mktemp) + trap 'rm -f "$STATUS"' EXIT + "$TS" status --self --peers=false --json >"$STATUS" || { + echo "installer: cannot read Tailscale status (is it logged in?)" >&2; exit 69 + } + DNSNAME=$(/usr/bin/plutil -extract Self.DNSName raw -o - "$STATUS" 2>/dev/null | /usr/bin/sed 's/\.$//' || true) + case "$DNSNAME" in + ''|*'Could not extract'*) echo "installer: Tailscale self DNSName is empty or missing" >&2; exit 69 ;; + esac + if [ "$LOGIN" = auto ]; then + USER_ID=$(/usr/bin/plutil -extract Self.UserID raw -o - "$STATUS" 2>/dev/null || true) + case "$USER_ID" in + ''|*[!0-9]*) + echo "installer: Tailscale self UserID is empty or missing" >&2 + exit 69 + ;; + esac + LOGIN=$(/usr/bin/plutil -extract "User.$USER_ID.LoginName" raw -o - "$STATUS" 2>/dev/null || true) + fi + rm -f "$STATUS" + trap - EXIT +else + DNSNAME="${TEST_DNSNAME:-localhost}" + [ "$LOGIN" != auto ] || LOGIN="${TEST_LOGIN:-test@example.invalid}" +fi + case "$LOGIN" in + ''|*'Could not extract'*) echo "installer: could not determine the Tailscale login" >&2; exit 69 ;; + esac + +VERSION=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$SOURCE/Contents/Info.plist") +NEW_REQ=$(/usr/bin/codesign -d -r- "$SOURCE" 2>&1 | /usr/bin/sed -n 's/^designated => //p' || true) +OLD_REQ="" +if [ -d "$APP" ]; then + OLD_REQ=$(/usr/bin/codesign -d -r- "$APP" 2>&1 | /usr/bin/sed -n 's/^designated => //p' || true) +fi + +mkdir -p "$BASE" "$LOG_DIR" "$(dirname "$TOKEN_FILE")" "$(dirname "$PLIST")" + +# One-shot migration from the pre-0.4.0 name, before generation so the old token +# wins and existing clients keep working. +if [ -s "$HOME_DIR/.config/oab-mac-agent/token" ] && [ ! -s "$TOKEN_FILE" ]; then + mv "$HOME_DIR/.config/oab-mac-agent/token" "$TOKEN_FILE" +fi + +# Generate once and preserve across every update. No release artifact or package +# ever contains a user bearer token. An atomic mkdir lock prevents two concurrent +# Installer.app attempts from racing and rotating the token clients already hold. +( + LOCK="$TOKEN_FILE.lock" + acquired=0 + for _ in $(/usr/bin/seq 1 50); do + if mkdir "$LOCK" 2>/dev/null; then acquired=1; break; fi + sleep 0.1 + done + [ "$acquired" = 1 ] || { echo "installer: timed out waiting for token lock" >&2; exit 70; } + TMP_TOKEN="$LOCK/token" + trap 'rm -rf "$LOCK"' EXIT + if [ ! -s "$TOKEN_FILE" ]; then + ( umask 077; /usr/bin/openssl rand -hex 32 >"$TMP_TOKEN" ) + chmod 600 "$TMP_TOKEN" + mv "$TMP_TOKEN" "$TOKEN_FILE" + fi +) +chmod 600 "$TOKEN_FILE" + +if [ "${SKIP_LAUNCH:-0}" != "1" ]; then + /bin/launchctl bootout "gui/$UID_/$LABEL" >/dev/null 2>&1 || true + /bin/launchctl bootout "gui/$UID_/$OLD_LABEL" >/dev/null 2>&1 || true + for _ in $(/usr/bin/seq 1 40); do + /bin/launchctl print "gui/$UID_/$LABEL" >/dev/null 2>&1 || break + sleep 0.25 + done + if /bin/launchctl print "gui/$UID_/$LABEL" >/dev/null 2>&1; then + echo "installer: the old LaunchAgent did not stop after 10 seconds; app was not replaced" >&2 + echo "Try: launchctl bootout gui/$UID_/$LABEL, then run the installer again." >&2 + exit 70 + fi +fi +rm -f "$HOME_DIR/Library/LaunchAgents/$OLD_LABEL.plist" + +# Copy to a sibling and verify there, then replace atomically. `ditto` preserves +# the Developer ID signature and notarization ticket; we never run codesign here. +STAGED="$BASE/.oab-instance-mcp.app.installing.$$" +BACKUP="$BASE/.oab-instance-mcp.app.previous.$$" +rm -rf "$STAGED" "$BACKUP" +/usr/bin/ditto "$SOURCE" "$STAGED" +if [ "${ALLOW_UNSIGNED:-0}" != "1" ]; then + /usr/bin/codesign --verify --deep --strict "$STAGED" +fi +if [ -e "$APP" ]; then mv "$APP" "$BACKUP"; fi +mv "$STAGED" "$APP" +rm -rf "$BACKUP" + +# Build the plist with PlistBuddy rather than interpolated XML: paths/logins with +# XML metacharacters remain data, not markup. +rm -f "$PLIST" +/usr/bin/plutil -create xml1 "$PLIST" +PB=/usr/libexec/PlistBuddy +"$PB" -c "Add :Label string $LABEL" "$PLIST" +"$PB" -c 'Add :ProgramArguments array' "$PLIST" +ARGS=( + "$APP/Contents/MacOS/oab-instance-mcp" + --port "$PORT" + --allow-login "$LOGIN" + --token-file "$TOKEN_FILE" + --menu-bar + --public-url "https://$DNSNAME:$HTTPS_PORT/mcp" +) +if [ "${SKIP_LAUNCH:-0}" != "1" ] && /bin/launchctl print "gui/$UID_/dev.openab.instance-mcp.pw-mcp" >/dev/null 2>&1; then + ARGS+=(--upstream browser=http://127.0.0.1:8794/mcp) +elif [ "${TEST_WITH_UPSTREAM:-0}" = "1" ]; then + ARGS+=(--upstream browser=http://127.0.0.1:8794/mcp) +fi +for i in "${!ARGS[@]}"; do "$PB" -c "Add :ProgramArguments:$i string ${ARGS[$i]}" "$PLIST"; done +"$PB" -c 'Add :RunAtLoad bool true' "$PLIST" +"$PB" -c 'Add :KeepAlive bool true' "$PLIST" +"$PB" -c 'Add :ProcessType string Interactive' "$PLIST" +"$PB" -c "Add :StandardOutPath string $LOG_DIR/agent.log" "$PLIST" +"$PB" -c "Add :StandardErrorPath string $LOG_DIR/agent.log" "$PLIST" +/usr/bin/plutil -lint "$PLIST" >/dev/null + +if [ "${SKIP_LAUNCH:-0}" != "1" ]; then + /bin/launchctl bootstrap "gui/$UID_" "$PLIST" + for _ in $(/usr/bin/seq 1 20); do + /usr/bin/curl -s -m 1 "http://127.0.0.1:$PORT/healthz" >/dev/null && break + sleep 0.25 + done + /usr/bin/curl -fsS -m 2 "http://127.0.0.1:$PORT/healthz" >/dev/null || { + echo "installer: LaunchAgent did not become healthy; see $LOG_DIR/agent.log" >&2 + exit 70 + } +fi +if [ "${SKIP_TAILSCALE:-0}" != "1" ]; then + "$TS" serve --bg --https="$HTTPS_PORT" "http://127.0.0.1:$PORT" >/dev/null +fi + +if [ -n "$OLD_REQ" ] && [ -n "$NEW_REQ" ] && [ "$OLD_REQ" != "$NEW_REQ" ]; then + echo "NOTE: the signing requirement changed; macOS may ask once to re-grant TCC permissions." + echo "Future Developer-ID releases keep this requirement stable." +fi + +echo "installed oab-instance-mcp $VERSION" +echo " app: $APP" +echo " login: $LOGIN" +echo " MCP: https://$DNSNAME:$HTTPS_PORT/mcp" +echo " token: $TOKEN_FILE (copy it from the menu bar; not printed)" +echo " one-time: enable Full Disk Access, Screen Recording and Accessibility in System Settings" diff --git a/scripts/package-pkg.sh b/scripts/package-pkg.sh new file mode 100755 index 0000000..31c854d --- /dev/null +++ b/scripts/package-pkg.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Build a macOS installer package around an ALREADY-SIGNED app. +# +# Usage: package-pkg.sh [version] +# Env: PKG_SIGN_IDENTITY="Developer ID Installer: ..." (optional for smoke; +# required for release) +# EXPECT_TEAM=6LPQNY95AQ +# ALLOW_UNSIGNED=1 (tests only) +set -euo pipefail + +APP="${1:?usage: package-pkg.sh [version]}" +OUT="${2:?usage: package-pkg.sh [version]}" +VERSION="${3:-}" +ROOT=$(cd "$(dirname "$0")/.." && pwd) +EXPECT_TEAM="${EXPECT_TEAM:-6LPQNY95AQ}" +IDENTIFIER="${PKG_IDENTIFIER:-dev.openab.instance-mcp.installer}" + +[ -d "$APP" ] || { echo "package-pkg: app not found: $APP" >&2; exit 66; } +case "$OUT" in *.pkg) ;; *) echo "package-pkg: output must end in .pkg" >&2; exit 64 ;; esac +if [ -z "$VERSION" ]; then + VERSION=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist") +fi +case "$VERSION" in ''|*[!0-9.]*) echo "package-pkg: invalid version: $VERSION" >&2; exit 64 ;; esac + +if [ "${ALLOW_UNSIGNED:-0}" != "1" ]; then + /usr/bin/codesign --verify --deep --strict "$APP" + TEAM=$(/usr/bin/codesign -dvv "$APP" 2>&1 | /usr/bin/sed -n 's/^TeamIdentifier=//p') + [ "$TEAM" = "$EXPECT_TEAM" ] || { + echo "package-pkg: app team '${TEAM:-none}', expected $EXPECT_TEAM" >&2; exit 65 + } +fi + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +PAYLOAD="$TMP/payload" +SCRIPTS="$TMP/scripts" +mkdir -p "$PAYLOAD/Library/Application Support/OpenAB/instance-mcp" "$SCRIPTS" "$(dirname "$OUT")" +/usr/bin/ditto "$APP" "$PAYLOAD/Library/Application Support/OpenAB/instance-mcp/oab-instance-mcp.app" +cp "$ROOT/scripts/install-prebuilt.sh" "$SCRIPTS/install-prebuilt.sh" +cp "$ROOT/scripts/pkg/postinstall" "$SCRIPTS/postinstall" +chmod 755 "$SCRIPTS/install-prebuilt.sh" "$SCRIPTS/postinstall" + +ARGS=( + --root "$PAYLOAD" + --scripts "$SCRIPTS" + --identifier "$IDENTIFIER" + --version "$VERSION" + --install-location / +) +if [ -n "${PKG_SIGN_IDENTITY:-}" ]; then + ARGS+=(--sign "$PKG_SIGN_IDENTITY") + [ -z "${PKG_KEYCHAIN:-}" ] || ARGS+=(--keychain "$PKG_KEYCHAIN") +elif [ "${ALLOW_UNSIGNED:-0}" != "1" ]; then + echo "package-pkg: PKG_SIGN_IDENTITY is required (Developer ID Installer)" >&2 + exit 65 +fi +rm -f "$OUT" +/usr/bin/pkgbuild "${ARGS[@]}" "$OUT" + +/usr/sbin/pkgutil --payload-files "$OUT" | /usr/bin/grep -q \ + '^\./Library/Application Support/OpenAB/instance-mcp/oab-instance-mcp.app/Contents/MacOS/oab-instance-mcp$' +if [ -n "${PKG_SIGN_IDENTITY:-}" ]; then + /usr/sbin/pkgutil --check-signature "$OUT" | /usr/bin/grep -q 'Developer ID Installer' +fi +echo "packaged: $OUT ($VERSION)" diff --git a/scripts/pkg/postinstall b/scripts/pkg/postinstall new file mode 100755 index 0000000..afce03d --- /dev/null +++ b/scripts/pkg/postinstall @@ -0,0 +1,29 @@ +#!/bin/bash +# Installer.app runs package scripts as root. Install/configure for the human who +# is actually logged into the Aqua session, not root — screenshot/input tools and +# the menu bar require that session, and the token belongs in that user's home. +set -euo pipefail + +CONSOLE_USER=$(/usr/bin/stat -f '%Su' /dev/console) +case "$CONSOLE_USER" in + ''|root|loginwindow|_mbsetupuser) + echo "oab-instance-mcp: no logged-in desktop user; sign in, then run the package again" >&2 + exit 1 + ;; +esac +UID_=$(/usr/bin/id -u "$CONSOLE_USER") +HOME_DIR=$(/usr/bin/dscl . -read "/Users/$CONSOLE_USER" NFSHomeDirectory | \ + /usr/bin/sed 's/^NFSHomeDirectory: //') +[ -d "$HOME_DIR" ] || { echo "oab-instance-mcp: home not found for $CONSOLE_USER" >&2; exit 1; } + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +SOURCE="/Library/Application Support/OpenAB/instance-mcp/oab-instance-mcp.app" +HELPER="$SCRIPT_DIR/install-prebuilt.sh" +[ -d "$SOURCE" ] || { echo "oab-instance-mcp: package payload app is missing" >&2; exit 1; } +[ -x "$HELPER" ] || { echo "oab-instance-mcp: package installer helper is missing" >&2; exit 1; } + +# `asuser` places launchctl/Tailscale in the GUI bootstrap namespace; sudo drops +# filesystem/process identity. The helper itself never needs root. +exec /bin/launchctl asuser "$UID_" /usr/bin/sudo -H -u "$CONSOLE_USER" \ + /usr/bin/env HOME="$HOME_DIR" USER="$CONSOLE_USER" LOGNAME="$CONSOLE_USER" \ + "$HELPER" "$SOURCE" --allow-login auto diff --git a/scripts/test-packaging.sh b/scripts/test-packaging.sh new file mode 100755 index 0000000..0480c50 --- /dev/null +++ b/scripts/test-packaging.sh @@ -0,0 +1,105 @@ +#!/bin/bash +# No-secret packaging smoke for CI and local development. +# Usage: test-packaging.sh +set -euo pipefail + +BIN="${1:?usage: test-packaging.sh }" +ROOT=$(cd "$(dirname "$0")/.." && pwd) +VERSION=$("$BIN" --version) +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +APP="$TMP/oab-instance-mcp.app" +HOME1="$TMP/home" +PKG="$TMP/oab-instance-mcp.pkg" + +mkdir -p "$HOME1" +"$ROOT/scripts/assemble-app.sh" "$BIN" "$APP" "$VERSION" +[ -x "$APP/Contents/MacOS/oab-instance-mcp" ] +[ -x "$APP/Contents/Resources/install-prebuilt.sh" ] +[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Contents/Info.plist")" = dev.openab.instance-mcp ] +[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")" = "$VERSION" ] +[ "$(/usr/libexec/PlistBuddy -c 'Print :NSAppTransportSecurity:NSAllowsArbitraryLoads' "$APP/Contents/Info.plist")" = true ] + +# The production installer must reject an unsigned app. Its test seams exercise +# everything after that gate without changing the live user or launchd namespace. +if INSTALL_HOME="$HOME1" SKIP_LAUNCH=1 SKIP_TAILSCALE=1 \ + "$ROOT/scripts/install-prebuilt.sh" "$APP" --allow-login test@example.invalid >/dev/null 2>&1; then + echo "packaging test: unsigned app passed the production signature gate" >&2 + exit 1 +fi + +ALLOW_UNSIGNED=1 INSTALL_HOME="$HOME1" SKIP_LAUNCH=1 SKIP_TAILSCALE=1 TEST_WITH_UPSTREAM=1 \ + "$ROOT/scripts/install-prebuilt.sh" "$APP" --allow-login test@example.invalid >/dev/null +INSTALLED="$HOME1/.local/oab-instance-mcp/oab-instance-mcp.app" +PLIST="$HOME1/Library/LaunchAgents/dev.openab.instance-mcp.plist" +TOKEN="$HOME1/.config/oab-instance-mcp/token" +[ -d "$INSTALLED" ] && [ -f "$PLIST" ] && [ -s "$TOKEN" ] +cmp "$APP/Contents/MacOS/oab-instance-mcp" "$INSTALLED/Contents/MacOS/oab-instance-mcp" +[ "$(stat -f '%Lp' "$TOKEN")" = 600 ] +[ "$(wc -c <"$TOKEN" | tr -d ' ')" = 65 ] # 64 hex + newline +/usr/bin/plutil -lint "$PLIST" >/dev/null +/usr/libexec/PlistBuddy -c 'Print :ProgramArguments' "$PLIST" | grep -q -- '--allow-login' +/usr/libexec/PlistBuddy -c 'Print :ProgramArguments' "$PLIST" | grep -q 'test@example.invalid' +/usr/libexec/PlistBuddy -c 'Print :ProgramArguments' "$PLIST" | grep -q 'browser=http://127.0.0.1:8794/mcp' + +# An update preserves the bearer token. +TOKEN_BEFORE=$(cat "$TOKEN") +ALLOW_UNSIGNED=1 INSTALL_HOME="$HOME1" SKIP_LAUNCH=1 SKIP_TAILSCALE=1 \ + "$ROOT/scripts/install-prebuilt.sh" "$APP" --allow-login test@example.invalid >/dev/null +[ "$(cat "$TOKEN")" = "$TOKEN_BEFORE" ] + +# Exercise the real structured Tailscale identity path with an anonymized fixture. +# The fake also records `serve`, so this does not touch the runner's tailnet. +HOME2="$TMP/home-auto" +FAKE_TS="$TMP/tailscale" +TS_LOG="$TMP/tailscale.log" +mkdir -p "$HOME2" +cat >"$FAKE_TS" <<'SH' +#!/bin/bash +case "$1" in + status) + if [ "${FAKE_TS_BAD:-0}" = 1 ]; then + echo '{"Self":{"DNSName":"fixture.tail.example."},"User":{}}' + else + echo '{"Self":{"DNSName":"fixture.tail.example.","UserID":12345},"User":{"12345":{"ID":12345,"LoginName":"fixture@example.invalid"}}}' + fi + ;; + serve) printf '%s\n' "$*" >>"$TEST_TS_LOG" ;; + *) exit 64 ;; +esac +SH +chmod 755 "$FAKE_TS" +ALLOW_UNSIGNED=1 INSTALL_HOME="$HOME2" SKIP_LAUNCH=1 \ + TAILSCALE_CLI="$FAKE_TS" TEST_TS_LOG="$TS_LOG" \ + "$ROOT/scripts/install-prebuilt.sh" "$APP" >/dev/null +AUTO_PLIST="$HOME2/Library/LaunchAgents/dev.openab.instance-mcp.plist" +/usr/libexec/PlistBuddy -c 'Print :ProgramArguments' "$AUTO_PLIST" | grep -q 'fixture@example.invalid' +/usr/libexec/PlistBuddy -c 'Print :ProgramArguments' "$AUTO_PLIST" | grep -q 'https://fixture.tail.example:8444/mcp' +grep -q 'serve --bg --https=8444 http://127.0.0.1:8795' "$TS_LOG" +if ALLOW_UNSIGNED=1 INSTALL_HOME="$TMP/home-bad-ts" SKIP_LAUNCH=1 \ + TAILSCALE_CLI="$FAKE_TS" TEST_TS_LOG="$TS_LOG" FAKE_TS_BAD=1 \ + "$ROOT/scripts/install-prebuilt.sh" "$APP" >"$TMP/bad-ts.out" 2>&1; then + echo "packaging test: malformed Tailscale fixture was accepted" >&2 + exit 1 +fi +grep -q 'Tailscale self UserID is empty or missing' "$TMP/bad-ts.out" + +# Build and inspect the unsigned package shape. A real release passes both sign +# identities and notarizes; CI cannot access those secrets on a PR. +ALLOW_UNSIGNED=1 "$ROOT/scripts/package-pkg.sh" "$APP" "$PKG" "$VERSION" >/dev/null +/usr/sbin/pkgutil --payload-files "$PKG" | grep -q \ + '^\./Library/Application Support/OpenAB/instance-mcp/oab-instance-mcp.app/Contents/MacOS/oab-instance-mcp$' +EXPANDED="$TMP/expanded" +/usr/sbin/pkgutil --expand "$PKG" "$EXPANDED" +[ -x "$EXPANDED/Scripts/postinstall" ] +[ -x "$EXPANDED/Scripts/install-prebuilt.sh" ] +/bin/bash -n "$EXPANDED/Scripts/postinstall" +/bin/bash -n "$EXPANDED/Scripts/install-prebuilt.sh" + +# Signing is mandatory outside the explicit smoke seam. +if "$ROOT/scripts/package-pkg.sh" "$APP" "$TMP/should-not-exist.pkg" "$VERSION" >/dev/null 2>&1; then + echo "packaging test: unsigned package built without ALLOW_UNSIGNED=1" >&2 + exit 1 +fi + +echo "packaging smoke: OK ($VERSION)" diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh new file mode 100755 index 0000000..e05aa6b --- /dev/null +++ b/scripts/verify-release.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Verify downloaded release artifacts locally. +# Usage: verify-release.sh [expected-team] +set -euo pipefail + +ZIP="${1:?usage: verify-release.sh [expected-team]}" +PKG="${2:?usage: verify-release.sh [expected-team]}" +TEAM_EXPECTED="${3:-6LPQNY95AQ}" +[ -f "$ZIP" ] && [ -f "$PKG" ] || { echo "verify-release: artifact missing" >&2; exit 66; } +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +/usr/bin/ditto -x -k "$ZIP" "$TMP" +APP=$(find "$TMP" -maxdepth 2 -type d -name 'oab-instance-mcp.app' -print -quit) +[ -n "$APP" ] || { echo "verify-release: app missing from zip" >&2; exit 65; } + +/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP" +TEAM=$(/usr/bin/codesign -dvv "$APP" 2>&1 | /usr/bin/sed -n 's/^TeamIdentifier=//p') +[ "$TEAM" = "$TEAM_EXPECTED" ] || { echo "verify-release: app team $TEAM, wanted $TEAM_EXPECTED" >&2; exit 65; } +/usr/bin/lipo -info "$APP/Contents/MacOS/oab-instance-mcp" | /usr/bin/grep -Eq 'x86_64 arm64|arm64 x86_64' +xcrun stapler validate "$APP" +/usr/sbin/spctl -a -vv --type execute "$APP" + +SIG=$(/usr/sbin/pkgutil --check-signature "$PKG") +printf '%s\n' "$SIG" | /usr/bin/grep -q 'Developer ID Installer' +printf '%s\n' "$SIG" | /usr/bin/grep -q "$TEAM_EXPECTED" +xcrun stapler validate "$PKG" +/usr/sbin/spctl -a -vv --type install "$PKG" +/usr/sbin/pkgutil --payload-files "$PKG" | /usr/bin/grep -q \ + '^\./Library/Application Support/OpenAB/instance-mcp/oab-instance-mcp.app/Contents/MacOS/oab-instance-mcp$' + +echo "release verification: OK (team $TEAM_EXPECTED, universal, signed, notarized, stapled)"