diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 258ec9bc6..452cc8209 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -768,6 +768,9 @@ jobs: openshell-x86_64-unknown-linux-musl.tar.gz \ openshell-aarch64-unknown-linux-musl.tar.gz \ openshell-aarch64-apple-darwin.tar.gz \ + openshell-driver-vm-x86_64-unknown-linux-gnu.tar.gz \ + openshell-driver-vm-aarch64-unknown-linux-gnu.tar.gz \ + openshell-driver-vm-aarch64-apple-darwin.tar.gz \ *.deb \ openshell-*.rpm \ *.whl > openshell-checksums-sha256.txt @@ -782,6 +785,15 @@ jobs: openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz > openshell-sandbox-checksums-sha256.txt cat openshell-sandbox-checksums-sha256.txt + - name: Generate Homebrew formula + run: | + set -euo pipefail + python3 tasks/scripts/release.py generate-homebrew-formula \ + --release-tag dev \ + --release-dir release \ + --output release/openshell.rb + cat release/openshell.rb + - name: Attest VM driver artifacts uses: actions/attest@v4 with: @@ -879,6 +891,7 @@ jobs: release/openshell-driver-vm-aarch64-unknown-linux-gnu.tar.gz release/openshell-driver-vm-aarch64-apple-darwin.tar.gz release/*.whl + release/openshell.rb release/openshell-checksums-sha256.txt release/openshell-gateway-checksums-sha256.txt release/openshell-sandbox-checksums-sha256.txt diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 0e9fab9bc..5001c6aea 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -772,6 +772,9 @@ jobs: openshell-x86_64-unknown-linux-musl.tar.gz \ openshell-aarch64-unknown-linux-musl.tar.gz \ openshell-aarch64-apple-darwin.tar.gz \ + openshell-driver-vm-x86_64-unknown-linux-gnu.tar.gz \ + openshell-driver-vm-aarch64-unknown-linux-gnu.tar.gz \ + openshell-driver-vm-aarch64-apple-darwin.tar.gz \ openshell_*.deb \ openshell-*.rpm \ *.whl > openshell-checksums-sha256.txt @@ -786,6 +789,15 @@ jobs: openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz > openshell-sandbox-checksums-sha256.txt cat openshell-sandbox-checksums-sha256.txt + - name: Generate Homebrew formula + run: | + set -euo pipefail + python3 tasks/scripts/release.py generate-homebrew-formula \ + --release-tag "${RELEASE_TAG}" \ + --release-dir release \ + --output release/openshell.rb + cat release/openshell.rb + - name: Attest VM driver artifacts uses: actions/attest@v4 with: @@ -847,6 +859,7 @@ jobs: release/openshell-driver-vm-aarch64-unknown-linux-gnu.tar.gz release/openshell-driver-vm-aarch64-apple-darwin.tar.gz release/*.whl + release/openshell.rb release/openshell-checksums-sha256.txt release/openshell-gateway-checksums-sha256.txt release/openshell-sandbox-checksums-sha256.txt diff --git a/architecture/custom-vm-runtime.md b/architecture/custom-vm-runtime.md index 9f723d8d7..40fd29dcb 100644 --- a/architecture/custom-vm-runtime.md +++ b/architecture/custom-vm-runtime.md @@ -323,10 +323,11 @@ run `cargo build --release -p openshell-driver-vm`. The macOS driver is cross-compiled via osxcross (no macOS runner needed for the binary build — only for the kernel build). -macOS driver binaries produced via osxcross are not codesigned. Local -development builds are signed automatically by `tasks/scripts/gateway-vm.sh` -(registered as `mise run gateway:vm`). Release tarball users on macOS must -ad-hoc sign `openshell-driver-vm` before running VM sandboxes. +macOS driver binaries produced via osxcross are not codesigned. Development +builds are signed automatically by `tasks/scripts/gateway-vm.sh` +(registered as `mise run gateway:vm`) and by the generated Homebrew formula +when `install-dev.sh` installs the selected release on Apple Silicon macOS. A +packaged release needs signing in CI. ## Rollout Strategy diff --git a/crates/openshell-driver-vm/README.md b/crates/openshell-driver-vm/README.md index e71f926ed..d90d4b843 100644 --- a/crates/openshell-driver-vm/README.md +++ b/crates/openshell-driver-vm/README.md @@ -182,6 +182,12 @@ On Linux amd64 and arm64, `install-dev.sh` installs the Debian package from the selected `OPENSHELL_VERSION` release tag. That package includes `openshell-gateway` and `openshell-driver-vm`. +On Apple Silicon macOS, `install-dev.sh` installs the generated `openshell.rb` +formula from the selected release. Homebrew installs `openshell`, +`openshell-gateway`, and `openshell-driver-vm`, ad-hoc signs the driver with +the Hypervisor entitlement in `post_install`, and owns the `brew services` +gateway lifecycle. + ## Relationship to `openshell-vm` `openshell-vm` is a separate, legacy crate that runs the **whole OpenShell gateway inside a single VM**. It remains in the repository for later deprecation or removal, but is excluded from normal workspace builds and release paths. `openshell-driver-vm` is the active compute driver called by a host-resident gateway to spawn **per-sandbox VMs**. The driver vendors its own rootfs handling and runtime loader so `openshell-server` never has to link libkrun. @@ -189,4 +195,4 @@ selected `OPENSHELL_VERSION` release tag. That package includes ## TODOs - The gateway still configures the driver via CLI args; this will move to a gRPC bootstrap call so the driver interface is uniform across backends. See the `TODO(driver-abstraction)` notes in `crates/openshell-server/src/lib.rs` and `crates/openshell-server/src/compute/vm.rs`. -- macOS local builds are codesigned by `tasks/scripts/gateway-vm.sh`; release tarball users must ad-hoc sign `openshell-driver-vm` before running VM sandboxes. +- macOS local builds are codesigned by `tasks/scripts/gateway-vm.sh`; the generated Homebrew formula signs the release tarball driver for local installs. diff --git a/install-dev.sh b/install-dev.sh index fb98a841d..f53c99bed 100755 --- a/install-dev.sh +++ b/install-dev.sh @@ -2,11 +2,11 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Install an OpenShell Debian package from a GitHub release. +# Install the OpenShell development build from a GitHub release. # -# This script defaults to the rolling `dev` release and supports Debian -# packages on Linux amd64 and arm64 only. The package installs the CLI, -# gateway, and VM compute driver. +# Linux keeps the Debian package install path. Apple Silicon macOS installs the +# generated Homebrew formula from the selected release, so Homebrew owns the +# binary layout and launchd service lifecycle. # set -e @@ -15,11 +15,16 @@ REPO="NVIDIA/OpenShell" GITHUB_URL="https://github.com/${REPO}" RELEASE_TAG="${OPENSHELL_VERSION:-dev}" CHECKSUMS_NAME="openshell-checksums-sha256.txt" +LOCAL_GATEWAY_PORT="17670" info() { printf '%s: %s\n' "$APP_NAME" "$*" >&2 } +warn() { + printf '%s: warning: %s\n' "$APP_NAME" "$*" >&2 +} + error() { printf '%s: error: %s\n' "$APP_NAME" "$*" >&2 exit 1 @@ -27,7 +32,7 @@ error() { usage() { cat </dev/null | awk '{ print $2 }')" + if [ -n "$_home" ]; then + echo "$_home" + return 0 + fi + fi + if [ "$(id -un)" = "$_user" ]; then echo "${HOME:-}" return 0 fi + if [ "$(uname -s)" = "Darwin" ]; then + echo "/Users/${_user}" + return 0 + fi + echo "/home/${_user}" } as_target_user() { + if [ "${PLATFORM:-}" = "darwin" ]; then + if [ "$(id -u)" -eq "$TARGET_UID" ]; then + env HOME="$TARGET_HOME" "$@" + elif has_cmd sudo; then + sudo -u "$TARGET_USER" env HOME="$TARGET_HOME" "$@" + else + error "cannot run commands as ${TARGET_USER}; install sudo or run as ${TARGET_USER}" + fi + return + fi + _bus="unix:path=${TARGET_RUNTIME_DIR}/bus" if [ "$(id -u)" -eq "$TARGET_UID" ]; then env HOME="$TARGET_HOME" XDG_RUNTIME_DIR="$TARGET_RUNTIME_DIR" DBUS_SESSION_BUS_ADDRESS="$_bus" "$@" @@ -139,14 +170,43 @@ as_target_user() { fi } -check_platform() { - if [ "$(uname -s)" != "Linux" ]; then - error "unsupported OS: $(uname -s); dev Debian packages require Linux" - fi +detect_platform() { + case "$(uname -s)" in + Linux) + echo "linux" + ;; + Darwin) + echo "darwin" + ;; + *) + error "unsupported OS: $(uname -s); dev builds support Linux and macOS" + ;; + esac +} +check_linux_platform() { require_cmd dpkg } +check_macos_platform() { + _arch="$(uname -m)" + + case "$_arch" in + arm64|aarch64) + ;; + x86_64|amd64) + error "Intel macOS is not supported because no x86_64-apple-darwin dev assets are published" + ;; + *) + error "no macOS dev build is published for architecture: ${_arch}" + ;; + esac + + if ! as_target_user brew --version >/dev/null 2>&1; then + error "Homebrew is required for macOS dev installs; install it from https://brew.sh" + fi +} + get_deb_arch() { _arch="$(dpkg --print-architecture)" @@ -233,6 +293,7 @@ remove_local_gateway_registration() { # The install-dev gateway is a user service. Replace the CLI registration # directly instead of asking `gateway destroy` to tear down Docker resources. + # shellcheck disable=SC2016 as_target_user sh -c ' config_dir=$1 rm -rf "${config_dir}/gateways/local" @@ -244,7 +305,9 @@ remove_local_gateway_registration() { } register_local_gateway() { - if _add_output="$(as_target_user openshell gateway add http://127.0.0.1:17670 --local --name local 2>&1)"; then + _register_bin="${OPENSHELL_REGISTER_BIN:-openshell}" + + if _add_output="$(as_target_user "$_register_bin" gateway add "http://127.0.0.1:${LOCAL_GATEWAY_PORT}" --local --name local 2>&1)"; then [ -z "$_add_output" ] || printf '%s\n' "$_add_output" >&2 return 0 else @@ -255,7 +318,7 @@ register_local_gateway() { *"already exists"*) info "local gateway already exists; removing and re-adding it..." remove_local_gateway_registration - as_target_user openshell gateway add http://127.0.0.1:17670 --local --name local + as_target_user "$_register_bin" gateway add "http://127.0.0.1:${LOCAL_GATEWAY_PORT}" --local --name local ;; *) printf '%s\n' "$_add_output" >&2 @@ -264,27 +327,9 @@ register_local_gateway() { esac } -main() { - while [ "$#" -gt 0 ]; do - case "$1" in - --help) - usage - exit 0 - ;; - *) - error "unknown option: $1" - ;; - esac - shift - done - - require_cmd curl - check_platform +install_linux_deb() { + check_linux_platform - TARGET_USER="$(target_user)" - TARGET_UID="$(id -u "$TARGET_USER" 2>/dev/null || true)" - [ -n "$TARGET_UID" ] || error "cannot resolve uid for ${TARGET_USER}" - TARGET_HOME="$(user_home "$TARGET_USER")" if [ "$(id -u)" -eq "$TARGET_UID" ] && [ -n "${XDG_RUNTIME_DIR:-}" ]; then TARGET_RUNTIME_DIR="$XDG_RUNTIME_DIR" else @@ -313,7 +358,7 @@ main() { info "selected ${_deb_file}" info "downloading ${_deb_file}..." - download_release_asset "$_deb_file" "$_deb_path" || { + download_release_asset "$RELEASE_TAG" "$_deb_file" "$_deb_path" || { error "failed to download ${_deb_url}" } chmod 0644 "$_deb_path" @@ -327,4 +372,78 @@ main() { start_user_gateway } +install_macos_homebrew() { + check_macos_platform + + _tmpdir="$(mktemp -d)" + chmod 0755 "$_tmpdir" + trap 'rm -rf "$_tmpdir"' EXIT + + _formula_file="${_tmpdir}/openshell.rb" + _formula_url="${GITHUB_URL}/releases/download/${RELEASE_TAG}/openshell.rb" + + info "downloading Homebrew formula from ${_formula_url}..." + download_release_asset "$RELEASE_TAG" "openshell.rb" "$_formula_file" || { + error "failed to download ${_formula_url}; the selected release may not include a Homebrew formula" + } + + if as_target_user brew list --formula openshell >/dev/null 2>&1; then + info "reinstalling OpenShell with Homebrew..." + as_target_user brew reinstall --formula "$_formula_file" + else + info "installing OpenShell with Homebrew..." + as_target_user brew install --formula "$_formula_file" + fi + + info "restarting OpenShell Homebrew service..." + if ! as_target_user brew services restart openshell; then + warn "could not restart the OpenShell Homebrew service" + info "restart it later with: brew services restart openshell" + info "then register it with: openshell gateway add http://127.0.0.1:${LOCAL_GATEWAY_PORT} --local --name local" + return 0 + fi + + _brew_prefix="$(as_target_user brew --prefix 2>/dev/null || true)" + if [ -n "$_brew_prefix" ] && [ -x "${_brew_prefix}/bin/openshell" ]; then + OPENSHELL_REGISTER_BIN="${_brew_prefix}/bin/openshell" + fi + + info "registering local gateway as ${TARGET_USER}..." + register_local_gateway +} + +main() { + if [ "$#" -gt 0 ]; then + case "$1" in + --help) + usage + exit 0 + ;; + *) + error "unknown option: $1" + ;; + esac + fi + + require_cmd curl + PLATFORM="$(detect_platform)" + + TARGET_USER="$(target_user)" + TARGET_UID="$(id -u "$TARGET_USER" 2>/dev/null || true)" + [ -n "$TARGET_UID" ] || error "cannot resolve uid for ${TARGET_USER}" + TARGET_HOME="$(user_home "$TARGET_USER")" + + case "$PLATFORM" in + linux) + install_linux_deb + ;; + darwin) + install_macos_homebrew + ;; + *) + error "unsupported platform: ${PLATFORM}" + ;; + esac +} + main "$@" diff --git a/python/openshell/release_formula_test.py b/python/openshell/release_formula_test.py new file mode 100644 index 000000000..414cbe199 --- /dev/null +++ b/python/openshell/release_formula_test.py @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +def test_generate_homebrew_formula_uses_tagged_macos_driver_asset( + tmp_path: Path, +) -> None: + release_dir = tmp_path / "release" + release_dir.mkdir() + (release_dir / "openshell-checksums-sha256.txt").write_text( + "\n".join( + [ + "a" * 64 + " openshell-aarch64-apple-darwin.tar.gz", + "b" * 64 + " openshell-driver-vm-aarch64-apple-darwin.tar.gz", + ] + ) + + "\n", + encoding="utf-8", + ) + (release_dir / "openshell-gateway-checksums-sha256.txt").write_text( + "d" * 64 + " openshell-gateway-aarch64-apple-darwin.tar.gz\n", + encoding="utf-8", + ) + + repo_root = Path(__file__).resolve().parents[2] + output = tmp_path / "openshell.rb" + subprocess.run( + [ + sys.executable, + str(repo_root / "tasks/scripts/release.py"), + "generate-homebrew-formula", + "--release-tag", + "v0.0.10", + "--release-dir", + str(release_dir), + "--output", + str(output), + ], + check=True, + ) + + formula = output.read_text(encoding="utf-8") + assert ( + "https://github.com/NVIDIA/OpenShell/releases/download/" + "v0.0.10/openshell-driver-vm-aarch64-apple-darwin.tar.gz" + ) in formula + assert 'sha256 "' + "b" * 64 + '"' in formula + assert 'OPENSHELL_DRIVER_DIR: "#{opt_libexec}"' in formula + assert "brew services restart openshell" in formula diff --git a/tasks/scripts/release.py b/tasks/scripts/release.py index 0fb229022..d1bfc2b2c 100644 --- a/tasks/scripts/release.py +++ b/tasks/scripts/release.py @@ -29,6 +29,16 @@ class Versions: git_distance: int +HOMEBREW_TARGET = "aarch64-apple-darwin" +HOMEBREW_CLI_ASSET = f"openshell-{HOMEBREW_TARGET}.tar.gz" +HOMEBREW_GATEWAY_ASSET = f"openshell-gateway-{HOMEBREW_TARGET}.tar.gz" +HOMEBREW_DRIVER_VM_ASSET = f"openshell-driver-vm-{HOMEBREW_TARGET}.tar.gz" +GITHUB_RELEASE_DOWNLOADS = "https://github.com/NVIDIA/OpenShell/releases/download" +LOCAL_GATEWAY_PORT = 17670 +_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$") +_RELEASE_TAG_RE = re.compile(r"^[A-Za-z0-9._-]+$") + + def _repo_root() -> Path: return Path(__file__).resolve().parents[2] @@ -166,6 +176,181 @@ def get_version(format: str) -> None: _print_env(versions) +def _parse_sha256_file(path: Path) -> dict[str, str]: + checksums: dict[str, str] = {} + for line_number, line in enumerate( + path.read_text(encoding="utf-8").splitlines(), 1 + ): + line = line.strip() + if not line: + continue + + parts = line.split() + if len(parts) < 2: + raise ValueError(f"{path}:{line_number}: malformed checksum line") + + digest = parts[0].lower() + if not _SHA256_RE.fullmatch(digest): + raise ValueError(f"{path}:{line_number}: invalid SHA-256 digest") + + filename = parts[1].lstrip("*") + checksums[filename] = digest + + return checksums + + +def _required_checksum( + checksums: dict[str, str], + filename: str, + checksum_path: Path, +) -> str: + try: + return checksums[filename] + except KeyError as exc: + raise ValueError(f"{checksum_path}: missing checksum for {filename}") from exc + + +def _asset_url(release_tag: str, filename: str) -> str: + return f"{GITHUB_RELEASE_DOWNLOADS}/{release_tag}/{filename}" + + +def render_homebrew_formula( + *, + release_tag: str, + cli_sha256: str, + gateway_sha256: str, + driver_vm_sha256: str, +) -> str: + if not _RELEASE_TAG_RE.fullmatch(release_tag): + raise ValueError(f"release tag contains unsupported characters: {release_tag}") + + version = release_tag.removeprefix("v") + return f"""# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Generated by tasks/scripts/release.py. Do not edit by hand. + +class Openshell < Formula + desc "Safe, private runtime for autonomous AI agents" + homepage "https://github.com/NVIDIA/OpenShell" + url "{_asset_url(release_tag, HOMEBREW_CLI_ASSET)}" + sha256 "{cli_sha256}" + version "{version}" + license "Apache-2.0" + + depends_on macos: :big_sur + depends_on arch: :arm64 + + resource "openshell-gateway" do + url "{_asset_url(release_tag, HOMEBREW_GATEWAY_ASSET)}" + sha256 "{gateway_sha256}" + end + + resource "openshell-driver-vm" do + url "{_asset_url(release_tag, HOMEBREW_DRIVER_VM_ASSET)}" + sha256 "{driver_vm_sha256}" + end + + def install + odie "OpenShell Homebrew formula currently supports macOS only" unless OS.mac? + + bin.install "openshell" + + resource("openshell-gateway").stage do + bin.install "openshell-gateway" + end + + resource("openshell-driver-vm").stage do + libexec.install "openshell-driver-vm" + end + end + + def post_install + (var/"openshell/gateway").mkpath + (var/"openshell/vm-driver").mkpath + (var/"log/openshell").mkpath + + entitlements = var/"openshell/openshell-driver-vm.entitlements.plist" + entitlements.write <<~XML + + + + + com.apple.security.hypervisor + + + + XML + + system "/usr/bin/codesign", "--entitlements", entitlements, "--force", "-s", "-", libexec/"openshell-driver-vm" + end + + service do + run opt_bin/"openshell-gateway" + environment_variables( + OPENSHELL_BIND_ADDRESS: "127.0.0.1", + OPENSHELL_SERVER_PORT: "{LOCAL_GATEWAY_PORT}", + OPENSHELL_DISABLE_TLS: "true", + OPENSHELL_DISABLE_GATEWAY_AUTH: "true", + OPENSHELL_DB_URL: "sqlite:#{{var}}/openshell/gateway/openshell.db", + OPENSHELL_DRIVERS: "vm", + OPENSHELL_GRPC_ENDPOINT: "http://127.0.0.1:{LOCAL_GATEWAY_PORT}", + OPENSHELL_SSH_GATEWAY_HOST: "127.0.0.1", + OPENSHELL_SSH_GATEWAY_PORT: "{LOCAL_GATEWAY_PORT}", + OPENSHELL_VM_DRIVER_STATE_DIR: "#{{var}}/openshell/vm-driver", + OPENSHELL_DRIVER_DIR: "#{{opt_libexec}}", + ) + keep_alive successful_exit: false + log_path var/"log/openshell/openshell-gateway.out.log" + error_log_path var/"log/openshell/openshell-gateway.err.log" + end + + def caveats + <<~EOS + Start or restart the local gateway with: + brew services restart openshell + + Register it with the OpenShell CLI: + openshell gateway add http://127.0.0.1:{LOCAL_GATEWAY_PORT} --local --name local + EOS + end + + test do + assert_match "openshell ", shell_output("#{{bin}}/openshell --version") + end +end +""" + + +def generate_homebrew_formula( + *, + release_tag: str, + release_dir: Path, + output: Path, +) -> None: + checksums_path = release_dir / "openshell-checksums-sha256.txt" + gateway_checksums_path = release_dir / "openshell-gateway-checksums-sha256.txt" + checksums = _parse_sha256_file(checksums_path) + gateway_checksums = _parse_sha256_file(gateway_checksums_path) + + formula = render_homebrew_formula( + release_tag=release_tag, + cli_sha256=_required_checksum(checksums, HOMEBREW_CLI_ASSET, checksums_path), + gateway_sha256=_required_checksum( + gateway_checksums, + HOMEBREW_GATEWAY_ASSET, + gateway_checksums_path, + ), + driver_vm_sha256=_required_checksum( + checksums, + HOMEBREW_DRIVER_VM_ASSET, + checksums_path, + ), + ) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(formula, encoding="utf-8") + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="OpenClaw release tooling.") sub = parser.add_subparsers(dest="command", required=True) @@ -193,6 +378,28 @@ def build_parser() -> argparse.ArgumentParser: "--json", action="store_true", help="Print all versions as JSON." ) + formula_parser = sub.add_parser( + "generate-homebrew-formula", + help="Generate the per-release Homebrew formula asset.", + ) + formula_parser.add_argument( + "--release-tag", + required=True, + help="GitHub release tag that owns the formula assets.", + ) + formula_parser.add_argument( + "--release-dir", + type=Path, + required=True, + help="Directory containing release artifacts and checksum files.", + ) + formula_parser.add_argument( + "--output", + type=Path, + required=True, + help="Path to write the generated Formula Ruby file.", + ) + return parser @@ -217,6 +424,12 @@ def main() -> None: get_version("json") else: get_version("all") + elif args.command == "generate-homebrew-formula": + generate_homebrew_formula( + release_tag=args.release_tag, + release_dir=args.release_dir, + output=args.output, + ) if __name__ == "__main__":