Skip to content

feat: add macOS support via Virtualization.framework (vz)#82

Open
rgarcia wants to merge 33 commits intomainfrom
feat/macos-vz-support
Open

feat: add macOS support via Virtualization.framework (vz)#82
rgarcia wants to merge 33 commits intomainfrom
feat/macos-vz-support

Conversation

@rgarcia
Copy link
Contributor

@rgarcia rgarcia commented Feb 4, 2026

Summary

Adds macOS support to hypeman using Apple's Virtualization.framework via the Code-Hex/vz library.

Key changes:

  • Platform abstraction: Split Linux-specific code (resources, devices, network, vmm, ingress) into _linux.go files with stub _darwin.go counterparts
  • vz hypervisor: New lib/hypervisor/vz/ package implementing Hypervisor and VMStarter interfaces
  • vz-shim subprocess: New cmd/vz-shim/ binary that hosts VMs in a separate process, allowing VMs to survive hypeman restarts (mirrors cloud-hypervisor architecture)
  • vz vsock proxy: Uses Cloud Hypervisor-compatible text protocol (CONNECT {port}\nOK {port}\n) over Unix socket
  • ClientFactory pattern: New hypervisor.NewClient() for uniform hypervisor client creation across all VMM types
  • Guest system updates: hvc0 console support, cross-compiled binary embedding for darwin builds
  • OCI image handling: Pull linux/arm64 images regardless of host platform

Architecture (vz-shim subprocess model):

┌─────────────────────────────────────────────────────────────┐
│                        hypeman                               │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐  │
│  │ vz.Starter   │    │ vz.Client    │    │ VsockDialer  │  │
│  │ (spawn shim) │    │ (HTTP→shim)  │    │ (vsock proxy)│  │
│  └──────┬───────┘    └──────┬───────┘    └──────┬───────┘  │
└─────────┼───────────────────┼───────────────────┼──────────┘
          │                   │                   │
          │ spawn             │ vz.sock           │ vz.vsock
          ▼                   ▼                   ▼
┌─────────────────────────────────────────────────────────────┐
│                       vz-shim (PID survives restart)        │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐  │
│  │ HTTP Server  │    │ Vsock Proxy  │    │ vz.VM        │  │
│  │ (control API)│    │ (CH protocol)│    │ (actual VM)  │  │
│  └──────────────┘    └──────────────┘    └──────────────┘  │
└─────────────────────────────────────────────────────────────┘

Platform comparison:

Aspect Linux (CH) Linux (QEMU) macOS (vz)
Process model External process External process vz-shim subprocess
Networking Bridge + TAP Bridge + TAP Built-in NAT (192.168.64.0/24)
Vsock Unix socket (text protocol) AF_VSOCK (kernel) Unix socket (text protocol)
Persistence Survives restart Survives restart Survives restart
Snapshot Yes Yes No (Linux guests not supported)

Snapshot limitation

Virtualization.framework does not support save/restore for Linux guest VMs - only macOS guests work. This is an undocumented Apple limitation confirmed by other projects:

The snapshot infrastructure is implemented in vz-shim for potential future macOS guest support, but the capability is correctly disabled for Linux guests.

vz-shim API:

The shim exposes a Cloud Hypervisor-compatible HTTP API on Unix socket:

  • GET /api/v1/vm.info - VM state and configuration
  • PUT /api/v1/vm.pause - Pause VM
  • PUT /api/v1/vm.resume - Resume VM
  • PUT /api/v1/vm.shutdown - Graceful shutdown
  • PUT /api/v1/vm.power-button - ACPI power button
  • PUT /api/v1/vm.snapshot - Save VM state (infrastructure for macOS guests)
  • GET /api/v1/vmm.ping - Health check
  • PUT /api/v1/vmm.shutdown - Terminate shim

Vsock proxy uses same text-based handshake as Cloud Hypervisor:

  • Client sends: CONNECT {port}\n
  • Server responds: OK {port}\n
  • Then bidirectional data flow

CI Considerations

Current CI uses self-hosted Linux runners with KVM. For macOS:

  • GitHub-hosted macOS runners (macos-14) support Apple Silicon but may lack virtualization entitlements
  • Self-hosted macOS runners would be needed for full VM testing
  • Recommendation: Add a build-only job on macos-14 to verify compilation, defer VM tests to self-hosted

Example addition to test.yml:

  build-macos:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v6
        with:
          go-version: '1.25'
      - name: Build (macOS)
        run: make build-darwin

Test plan

  • hypeman resources - verified resource detection
  • hypeman pull - verified linux/arm64 image pull
  • hypeman run - verified VM creation and boot
  • hypeman exec - verified command execution in VM
  • hypeman ps - verified instance listing
  • hypeman build - verified Dockerfile→VM image build
  • hypeman ingress - verified external access to VM services
  • hypeman rm - verified instance cleanup
  • VM persistence - verified VM survives hypeman restart
  • VM reconnection - verified hypeman reconnects to running VM after restart
  • Snapshot limitation - verified and documented (Apple framework limitation)
  • Linux regression testing (CI will cover)

Note

High Risk
Adds a new hypervisor backend and macOS-specific build/runtime paths (codesigning, shim process, networking/vsock), which affects core VM lifecycle and build execution across platforms.

Overview
Adds experimental macOS (Apple Silicon) support by introducing a new vz hypervisor backend using Virtualization.framework, implemented as a vz-shim subprocess with a Cloud Hypervisor–compatible control API and vsock proxy.

Refactors hypervisor integration to be more pluggable (hypervisor.NewClient + client factories, new instances.Manager.GetVsockDialer) and routes exec/cp/stat and build-manager vsock connections through the instance manager to support multiple hypervisors.

Updates platform-specific behavior across the stack: macOS stubs for Linux-only networking/GPU passthrough, Linux-only embedded Caddy binaries, Linux-targeted OCI image pulling regardless of host OS, sector-aligned ext4 disk sizing, and macOS-focused build/test/dev tooling (codesigning targets, .air.darwin.toml, .env.darwin.example, macOS CI jobs, and docs).

Written by Cursor Bugbot for commit 67a3923. This will update automatically on new commits. Configure here.

rgarcia and others added 8 commits February 2, 2026 17:09
Move Linux-specific resource detection (CPU, memory, disk, network) and
device management (discovery, mdev, vfio) into _linux.go files. Add stub
_darwin.go files that return empty/unsupported results for macOS.

This is a pure refactoring with no functional changes on Linux. Prepares
the codebase for macOS support where these Linux-specific features
(cgroups, sysfs, VFIO) are not available.

Co-authored-by: Cursor <cursoragent@cursor.com>
Move Linux bridge/TAP networking into bridge_linux.go. Add bridge_darwin.go
stub since macOS vz uses built-in NAT networking. Extract shared IP allocation
logic to ip.go.

Move VMM binary detection (cloud-hypervisor, qemu paths) into binaries_linux.go.
Add binaries_darwin.go that returns empty paths since vz is in-process.

No functional changes on Linux.

Co-authored-by: Cursor <cursoragent@cursor.com>
Move ingress binary embedding into platform-specific files. Update build
tags on architecture-specific files to also include OS constraint.

Replace checkKVMAccess() with platform-agnostic checkHypervisorAccess():
- Linux: checks /dev/kvm access (existing behavior)
- macOS: verifies ARM64 arch and Virtualization.framework availability

No functional changes on Linux.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ions

Add GetVsockDialer() to instance manager interface. This abstraction handles
the difference between:
- Linux: socket-based vsock (AF_VSOCK or Unix socket proxy)
- macOS vz: in-process vsock via VirtualMachine object

Update API handlers (exec, cp, instances) and build manager to use
GetVsockDialer() instead of directly creating vsock connections.

Add DialVsock() method to VsockDialer interface for explicit dialing.

Co-authored-by: Cursor <cursoragent@cursor.com>
Implement Hypervisor and VMStarter interfaces using github.com/Code-Hex/vz/v3
library for Apple's Virtualization.framework.

Key differences from Linux hypervisors:
- In-process: VMs run within hypeman process (no separate PID)
- NAT networking: Uses vz built-in NAT (192.168.64.0/24)
- Direct vsock: Connects via VirtualMachine object, not socket files
- Snapshot support: Available on macOS 14+ ARM64

Registers vz starter on macOS via init() in hypervisor_darwin.go.
Linux hypervisor_linux.go is a no-op placeholder.

Co-authored-by: Cursor <cursoragent@cursor.com>
Guest init changes:
- Add hvc0 serial console support (vz uses hvc0, not ttyS0)
- Prioritize /dev/hvc0 for console output in logger and mount

Binary embedding:
- Add darwin-specific embed files for cross-compiled linux/arm64 binaries
- Guest init and agent binaries are embedded when building on macOS

OCI image handling:
- Add vmPlatform() to return linux/arm64 for VM images regardless of host
- Fixes image pull on macOS which would otherwise request darwin/arm64

Instance lifecycle:
- Track active hypervisors for vz (needed for in-process VM references)
- Handle vz-specific cleanup in delete (no PID to kill)
- Support vz in instance queries

Co-authored-by: Cursor <cursoragent@cursor.com>
Build system:
- Add macOS targets to Makefile (build-darwin, run, sign)
- Add .air.darwin.toml for live reload on macOS
- Add vz.entitlements for Virtualization.framework code signing
- Add .env.darwin.example with macOS-specific configuration

Documentation:
- Update DEVELOPMENT.md with macOS setup instructions
- Update README.md to mention macOS support
- Update lib/hypervisor/README.md with vz implementation details
- Update lib/instances/README.md for multi-hypervisor support
- Update lib/network/README.md with platform comparison

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Changes the vz hypervisor from in-process to subprocess model, allowing
VMs to survive hypeman restarts. Mirrors the cloud-hypervisor architecture.

Key changes:
- Add cmd/vz-shim binary that hosts vz VMs in a subprocess
- Shim exposes HTTP API on Unix socket for VM control (matching CH pattern)
- Shim exposes vsock proxy on separate Unix socket using CH protocol
- Update vz starter to spawn shim subprocess instead of in-process VM
- Add vz.Client implementing Hypervisor interface via HTTP to shim
- Update VsockDialer to use Unix socket proxy instead of in-process VM
- Add hypervisor.ClientFactory for uniform hypervisor client creation
- Remove activeHypervisors tracking (no longer needed)
- Simplify vsock_darwin.go (vz now uses same socket pattern as other hypervisors)
- Update Makefile to build and sign vz-shim binary

Co-authored-by: Cursor <cursoragent@cursor.com>
…ation

Add snapshot save/restore infrastructure to vz-shim:
- Snapshot endpoint in shim server (vm.snapshot)
- RestoreVM implementation in starter (loads config from metadata.json)
- Snapshot method in client (adapts directory path to file path)

Document Virtualization.framework limitation:
- Linux guest VMs cannot be reliably saved/restored
- Only macOS guests support this functionality
- This is an undocumented Apple limitation confirmed by Tart and UTM projects
- References: Tart #1177, #796; UTM #6654

The infrastructure is in place for potential future macOS guest support
while correctly disabling snapshot capability for Linux guests.

Also improves MAC address handling and error logging in vm.go.

Co-authored-by: Cursor <cursoragent@cursor.com>
- Makefile: prepend e2fsprogs sbin to PATH for dev-darwin and run-darwin
  targets so mkfs.ext4 is found without requiring shell profile changes
- manager.go: don't cache failed image builds, clean up failed build
  directory to allow retries after fixing the underlying issue
@cursor
Copy link

cursor bot commented Feb 8, 2026

Bugbot Autofix prepared fixes for 3 of the 3 bugs found in the latest run.

  • ✅ Fixed: exec.CommandContext will kill shim on context cancellation
    • Replaced exec.CommandContext with exec.Command and added SysProcAttr{Setpgid: true} in both StartVM and RestoreVM to match the cloud-hypervisor pattern, preventing context cancellation from killing the shim subprocess.
  • ✅ Fixed: Identical platform files have no platform-specific logic
    • Merged the identical vsock_darwin.go and vsock_linux.go into a single vsock.go without build tags, since platform dispatch already happens inside hypervisor.NewVsockDialer.
  • ✅ Fixed: Replaced vsock method now dead code
    • Removed the unused dialBuilderVsock method and its associated bufferedConn type from builds/manager.go as they have no remaining callers.

Create PR

Or push these changes by commenting:

@cursor push 6f89d8c359
Preview (6f89d8c359)
diff --git a/lib/builds/manager.go b/lib/builds/manager.go
--- a/lib/builds/manager.go
+++ b/lib/builds/manager.go
@@ -608,62 +608,6 @@
 	}
 }
 
-// dialBuilderVsock connects to a builder VM's vsock socket using Cloud Hypervisor's handshake
-func (m *manager) dialBuilderVsock(vsockSocketPath string) (net.Conn, error) {
-	// Connect to the Cloud Hypervisor vsock Unix socket
-	conn, err := net.DialTimeout("unix", vsockSocketPath, 5*time.Second)
-	if err != nil {
-		return nil, fmt.Errorf("dial vsock socket %s: %w", vsockSocketPath, err)
-	}
-
-	// Set deadline for handshake
-	if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
-		conn.Close()
-		return nil, fmt.Errorf("set handshake deadline: %w", err)
-	}
-
-	// Perform Cloud Hypervisor vsock handshake
-	// Format: "CONNECT <port>\n" -> "OK <port>\n"
-	handshakeCmd := fmt.Sprintf("CONNECT %d\n", BuildAgentVsockPort)
-	if _, err := conn.Write([]byte(handshakeCmd)); err != nil {
-		conn.Close()
-		return nil, fmt.Errorf("send vsock handshake: %w", err)
-	}
-
-	// Read handshake response
-	reader := bufio.NewReader(conn)
-	response, err := reader.ReadString('\n')
-	if err != nil {
-		conn.Close()
-		return nil, fmt.Errorf("read vsock handshake response: %w", err)
-	}
-
-	// Clear deadline after successful handshake
-	if err := conn.SetDeadline(time.Time{}); err != nil {
-		conn.Close()
-		return nil, fmt.Errorf("clear deadline: %w", err)
-	}
-
-	response = strings.TrimSpace(response)
-	if !strings.HasPrefix(response, "OK ") {
-		conn.Close()
-		return nil, fmt.Errorf("vsock handshake failed: %s", response)
-	}
-
-	return &bufferedConn{Conn: conn, reader: reader}, nil
-}
-
-// bufferedConn wraps a net.Conn with a bufio.Reader to ensure any buffered
-// data from the handshake is properly drained before reading from the connection
-type bufferedConn struct {
-	net.Conn
-	reader *bufio.Reader
-}
-
-func (c *bufferedConn) Read(p []byte) (int, error) {
-	return c.reader.Read(p)
-}
-
 // updateStatus updates the build status
 func (m *manager) updateStatus(id string, status string, err error) {
 	meta, readErr := readMetadata(m.paths, id)

diff --git a/lib/hypervisor/vz/starter.go b/lib/hypervisor/vz/starter.go
--- a/lib/hypervisor/vz/starter.go
+++ b/lib/hypervisor/vz/starter.go
@@ -11,6 +11,7 @@
 	"os"
 	"os/exec"
 	"path/filepath"
+	"syscall"
 	"time"
 
 	"github.com/kernel/hypeman/lib/hypervisor"
@@ -134,10 +135,15 @@
 	}
 
 	// Spawn the shim process
-	cmd := exec.CommandContext(ctx, shimPath, "-config", string(configJSON))
+	// Use Command (not CommandContext) so process survives parent context cancellation
+	cmd := exec.Command(shimPath, "-config", string(configJSON))
 	cmd.Stdout = nil // Shim logs to file
 	cmd.Stderr = nil
 	cmd.Stdin = nil
+	// Detach from parent process group
+	cmd.SysProcAttr = &syscall.SysProcAttr{
+		Setpgid: true,
+	}
 
 	if err := cmd.Start(); err != nil {
 		return 0, nil, fmt.Errorf("start vz-shim: %w", err)
@@ -330,10 +336,15 @@
 	}
 
 	// Spawn the shim process
-	cmd := exec.CommandContext(ctx, shimPath, "-config", string(configJSON))
+	// Use Command (not CommandContext) so process survives parent context cancellation
+	cmd := exec.Command(shimPath, "-config", string(configJSON))
 	cmd.Stdout = nil
 	cmd.Stderr = nil
 	cmd.Stdin = nil
+	// Detach from parent process group
+	cmd.SysProcAttr = &syscall.SysProcAttr{
+		Setpgid: true,
+	}
 
 	if err := cmd.Start(); err != nil {
 		return 0, nil, fmt.Errorf("start vz-shim: %w", err)

diff --git a/lib/instances/vsock_linux.go b/lib/instances/vsock.go
--- a/lib/instances/vsock_linux.go
+++ b/lib/instances/vsock.go
@@ -1,5 +1,3 @@
-//go:build linux
-
 package instances
 
 import (

diff --git a/lib/instances/vsock_darwin.go b/lib/instances/vsock_darwin.go
deleted file mode 100644
--- a/lib/instances/vsock_darwin.go
+++ /dev/null
@@ -1,20 +1,0 @@
-//go:build darwin
-
-package instances
-
-import (
-	"context"
-
-	"github.com/kernel/hypeman/lib/hypervisor"
-)
-
-// GetVsockDialer returns a VsockDialer for the specified instance.
-// On macOS, all hypervisors (including vz via shim) use socket-based vsock.
-func (m *manager) GetVsockDialer(ctx context.Context, instanceID string) (hypervisor.VsockDialer, error) {
-	inst, err := m.GetInstance(ctx, instanceID)
-	if err != nil {
-		return nil, err
-	}
-
-	return hypervisor.NewVsockDialer(hypervisor.Type(inst.HypervisorType), inst.VsockSocket, inst.VsockCID)
-}
\ No newline at end of file

Add -registry-push flag to gen-jwt that adds repo_access claims to the
JWT, enabling push permissions for specific repositories. This is needed
to push the builder image to Hypeman's internal registry during local
development.

Also add documentation for the builder image setup workflow in
DEVELOPMENT.md, covering the full process from building the image to
configuring BUILDER_IMAGE.
Instead of collecting all logs and sending them at the end of a build,
stream log lines incrementally from the builder agent to the manager
via vsock "log" messages. The manager appends each log line to the
build log file immediately, enabling real-time log streaming to clients
via the SSE endpoint.

Changes:
- Add streamingLogWriter in builder agent with channel-based streaming
- Add markClosed() mechanism to prevent panic from writes after channel close
- Handle "log" message type in manager's waitForResult
- Remove redundant final log save since logs are now streamed incrementally
- Remove unused datasize import (CI failure)
- Fix network devices overwritten in loop: collect all devices then set once
- Use exec.Command instead of CommandContext so vz-shim survives context cancel
- Map unknown VM states to StateShutdown instead of StateRunning
- Add stale unix socket cleanup before net.Listen in vz-shim
- Clarify Intel Mac rejection is intentional (kernel panics, no nested virt)
- Merge identical vsock_darwin.go/vsock_linux.go into single vsock.go
- Remove dead dialBuilderVsock code and bufferedConn type

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cursor
Copy link

cursor bot commented Feb 8, 2026

Bugbot Autofix prepared fixes for 3 of the 3 bugs found in the latest run.

  • ✅ Fixed: Race condition: sending on closed channel causes panic
    • Moved the closedMu.RUnlock() to after the channel send so the read lock is held during both the closed check and the send, preventing markClosed()+close(logChan) from executing between them.
  • ✅ Fixed: Build logs dropped when streaming channel capacity exceeded
    • Restored writing the complete result.Logs to the log file after build completion using a new writeLog function that overwrites the incrementally-streamed (potentially incomplete) log file with the authoritative complete record.
  • ✅ Fixed: Unused exported symbols in darwin network stubs
    • Removed the four unused symbols (macOSNetworkConfig, GetMacOSNetworkConfig, IsMacOS, ErrRateLimitNotSupported) and the now-unused fmt import, confirmed no references exist elsewhere in the codebase.

Create PR

Or push these changes by commenting:

@cursor push d65c8be753
Preview (d65c8be753)
diff --git a/lib/builds/builder_agent/main.go b/lib/builds/builder_agent/main.go
--- a/lib/builds/builder_agent/main.go
+++ b/lib/builds/builder_agent/main.go
@@ -129,12 +129,13 @@
 	w.buffer.Write(p)
 	w.mu.Unlock()
 
-	// Check if channel is closed before attempting to send
+	// Hold read lock during both the closed check AND the channel send.
+	// This prevents a TOCTOU race where markClosed() + close(logChan) could
+	// execute between reading closed=false and the send, which would panic.
+	// markClosed() acquires a write lock, so it blocks while we hold the read lock,
+	// and close(logChan) only runs after markClosed() completes.
 	w.closedMu.RLock()
-	isClosed := w.closed
-	w.closedMu.RUnlock()
-
-	if !isClosed {
+	if !w.closed {
 		// Send to channel for streaming (non-blocking)
 		line := string(p)
 		select {
@@ -143,6 +144,7 @@
 			// Channel full, drop the log line for streaming but it's still in buffer
 		}
 	}
+	w.closedMu.RUnlock()
 
 	// Also write to stdout for local debugging
 	os.Stdout.Write(p)

diff --git a/lib/builds/manager.go b/lib/builds/manager.go
--- a/lib/builds/manager.go
+++ b/lib/builds/manager.go
@@ -331,9 +331,14 @@
 		return
 	}
 
-	// Note: Logs are now streamed via vsock "log" messages and written incrementally
-	// in waitForResult, so we no longer need to save them here.
-	// The result.Logs field is kept for backward compatibility but is redundant.
+	// Logs are streamed incrementally via vsock "log" messages in waitForResult,
+	// but the streaming channel may drop lines when full. Overwrite the log file
+	// with the complete logs from result.Logs to ensure no lines are lost.
+	if result.Logs != "" {
+		if err := writeLog(m.paths, id, []byte(result.Logs)); err != nil {
+			m.logger.Warn("failed to save build logs", "id", id, "error", err)
+		}
+	}
 
 	if !result.Success {
 		m.logger.Error("build failed", "id", id, "error", result.Error, "duration", duration)

diff --git a/lib/builds/storage.go b/lib/builds/storage.go
--- a/lib/builds/storage.go
+++ b/lib/builds/storage.go
@@ -171,6 +171,21 @@
 	return os.MkdirAll(logsDir, 0755)
 }
 
+// writeLog overwrites the build log file with the given data.
+// Used to write the complete log record after build completion.
+func writeLog(p *paths.Paths, id string, data []byte) error {
+	if err := ensureLogsDir(p, id); err != nil {
+		return err
+	}
+
+	logPath := p.BuildLog(id)
+	if err := os.WriteFile(logPath, data, 0644); err != nil {
+		return fmt.Errorf("write log: %w", err)
+	}
+
+	return nil
+}
+
 // appendLog appends log data to the build log file
 func appendLog(p *paths.Paths, id string, data []byte) error {
 	if err := ensureLogsDir(p, id); err != nil {

diff --git a/lib/network/bridge_darwin.go b/lib/network/bridge_darwin.go
--- a/lib/network/bridge_darwin.go
+++ b/lib/network/bridge_darwin.go
@@ -4,7 +4,6 @@
 
 import (
 	"context"
-	"fmt"
 
 	"github.com/kernel/hypeman/lib/logger"
 )
@@ -76,23 +75,3 @@
 //
 // The CreateAllocation and ReleaseAllocation methods in allocate.go
 // will need platform-specific handling for the TAP-related calls.
-
-// macOSNetworkConfig holds macOS-specific network configuration
-type macOSNetworkConfig struct {
-	UseNAT bool // Always true for macOS
-}
-
-// GetMacOSNetworkConfig returns the macOS network configuration
-func GetMacOSNetworkConfig() *macOSNetworkConfig {
-	return &macOSNetworkConfig{
-		UseNAT: true,
-	}
-}
-
-// IsMacOS returns true on macOS builds
-func IsMacOS() bool {
-	return true
-}
-
-// ErrRateLimitNotSupported indicates rate limiting is not supported on macOS
-var ErrRateLimitNotSupported = fmt.Errorf("network rate limiting is not supported on macOS")

rgarcia and others added 2 commits February 8, 2026 10:30
- Hold closedMu RLock through channel send in streamingLogWriter to
  prevent panic from sending on a closed channel
- Remove unused macOSNetworkConfig, GetMacOSNetworkConfig, IsMacOS,
  and ErrRateLimitNotSupported from bridge_darwin.go (vz uses
  framework-level NAT, rate limiting will never apply)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The tmp dir fallback constructed the same path as the initial check,
making it unreachable dead code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cursor
Copy link

cursor bot commented Feb 8, 2026

Bugbot Autofix prepared fixes for 1 of the 1 bugs found in the latest run.

  • ✅ Fixed: Redundant duplicate path check in findShimBinary
    • Changed filepath.Join(exeDir, "vz-shim") to filepath.Join(filepath.Dir(exeDir), "vz-shim") in the tmp-dir fallback so it checks the parent directory (e.g., ./vz-shim) instead of redundantly re-checking the same path (./tmp/vz-shim).

Create PR

Or push these changes by commenting:

@cursor push c33dcf6521
Preview (c33dcf6521)
diff --git a/lib/hypervisor/vz/starter.go b/lib/hypervisor/vz/starter.go
--- a/lib/hypervisor/vz/starter.go
+++ b/lib/hypervisor/vz/starter.go
@@ -175,10 +175,10 @@
 		if _, err := os.Stat(shimPath); err == nil {
 			return shimPath, nil
 		}
-		// Also check parent's tmp dir (for air hot-reload development)
-		// When running ./tmp/main, check ./tmp/vz-shim
+		// Also check the parent directory (for air hot-reload development)
+		// When running ./tmp/main, check ./vz-shim
 		if filepath.Base(exeDir) == "tmp" {
-			shimPath = filepath.Join(exeDir, "vz-shim")
+			shimPath = filepath.Join(filepath.Dir(exeDir), "vz-shim")
 			if _, err := os.Stat(shimPath); err == nil {
 				return shimPath, nil
 			}

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@github-actions
Copy link

github-actions bot commented Feb 8, 2026

✱ Stainless preview builds

This PR will update the hypeman SDKs with the following commit message.

feat: add macOS support via Virtualization.framework (vz)

Edit this comment to update it. It will appear in the SDK's changelogs.

hypeman-typescript studio · code · diff

Your SDK built successfully.
generate ⚠️build ✅lint ✅test ✅

npm install https://pkg.stainless.com/s/hypeman-typescript/e1832aeff25a12053f2117103ba2dd3b2378d06b/dist.tar.gz
hypeman-go studio · code · diff

Your SDK built successfully.
generate ⚠️lint ✅test ✅

go get github.com/stainless-sdks/hypeman-go@c751b7620339e2e71e6ae3ff31b56d56bf153ed7
hypeman-cli studio · conflict

⏳ These are partial results; builds are still running.


This comment is auto-generated by GitHub Actions and is automatically kept up to date as you push.
If you push custom code to the preview branch, re-run this workflow to update the comment.
Last updated: 2026-02-08 21:27:16 UTC

- install.sh: OS branching for macOS (launchd, codesign, Docker socket
  auto-detection, arm64 check, ~/Library paths)
- uninstall.sh: macOS support (launchctl, vz-shim cleanup)
- Makefile: platform-aware build/test targets (Darwin vs Linux)
- Build manager: auto-build builder image on startup via background
  goroutine with atomic readiness gate and DOCKER_SOCKET config
- CI: test-darwin job on self-hosted macOS ARM64 runner with per-run
  DATA_DIR isolation; e2e-install job for install/uninstall cycle
- e2e-install-test.sh: platform-agnostic install → verify → uninstall
- DEVELOPMENT.md: document DOCKER_SOCKET and builder auto-build

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…lock

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…discovery

- Add //go:build linux to vm_metrics and vmm test files
- Change t.Fatal to t.Skip for /dev/kvm checks across all test files
- Skip TestNetworkResource_Allocated on non-Linux (no rate limiting)
- test-darwin uses go list to discover only compilable packages
- Use separate concurrency groups per CI job

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cursor
Copy link

cursor bot commented Feb 8, 2026

Bugbot Autofix prepared fixes for 1 of the 1 bugs found in the latest run.

  • ✅ Fixed: Dead filepath.SplitList call on image name with colon
    • Removed the dead filepath.SplitList(imageName) call and its discarded _ = parts assignment, as the actual tag-stripping logic is correctly handled by the manual byte-level loop below.

Create PR

Or push these changes by commenting:

@cursor push 273e3e446a
Preview (273e3e446a)
diff --git a/lib/hypervisor/vz/starter.go b/lib/hypervisor/vz/starter.go
--- a/lib/hypervisor/vz/starter.go
+++ b/lib/hypervisor/vz/starter.go
@@ -393,7 +393,6 @@
 // containsImageName checks if a path contains the image name components.
 func containsImageName(path, imageName string) bool {
 	// Extract just the image name without tag (e.g., "alpine" from "alpine:3.20")
-	parts := filepath.SplitList(imageName)
 	name := imageName
 	if idx := len(name) - 1; idx >= 0 {
 		for i := len(name) - 1; i >= 0; i-- {
@@ -403,7 +402,6 @@
 			}
 		}
 	}
-	_ = parts
 	return filepath.Base(filepath.Dir(filepath.Dir(path))) == name ||
 		filepath.Base(filepath.Dir(path)) == name
 }

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cursor
Copy link

cursor bot commented Feb 8, 2026

Bugbot Autofix prepared fixes for 2 of the 2 bugs found in the latest run.

  • ✅ Fixed: ensureBuilderImage builds locally but never pushes to registry
    • Added logic after docker build to export the image via docker save, import it into the OCI layout cache using go-containerregistry, call ImportLocalImage to trigger disk conversion, and wait for the conversion to complete before signaling ready.
  • ✅ Fixed: Comment numbering mismatch in deriveState after refactoring
    • Removed the misleading '3.' prefix from the comment since steps 1 and 2 no longer exist after refactoring.

Create PR

Or push these changes by commenting:

@cursor push 279d227209
Preview (279d227209)
diff --git a/lib/builds/manager.go b/lib/builds/manager.go
--- a/lib/builds/manager.go
+++ b/lib/builds/manager.go
@@ -15,6 +15,9 @@
 	"sync/atomic"
 	"time"
 
+	"github.com/google/go-containerregistry/pkg/v1/empty"
+	"github.com/google/go-containerregistry/pkg/v1/layout"
+	"github.com/google/go-containerregistry/pkg/v1/tarball"
 	"github.com/nrednav/cuid2"
 	"github.com/kernel/hypeman/lib/images"
 	"github.com/kernel/hypeman/lib/instances"
@@ -232,7 +235,103 @@
 		return
 	}
 
-	m.logger.Info("builder image built successfully", "image", builderImage)
+	m.logger.Info("builder image built successfully, pushing to registry", "image", builderImage)
+
+	// Export image from Docker to a temporary tarball
+	tmpFile, err := os.CreateTemp("", "hypeman-builder-*.tar")
+	if err != nil {
+		m.logger.Warn("failed to create temp file for builder image export", "error", err)
+		return
+	}
+	tmpPath := tmpFile.Name()
+	tmpFile.Close()
+	defer os.Remove(tmpPath)
+
+	saveCmd := exec.CommandContext(ctx, "docker", "save", "-o", tmpPath, builderImage)
+	saveCmd.Env = append(os.Environ(), fmt.Sprintf("DOCKER_HOST=unix://%s", dockerSocket))
+	if saveOutput, saveErr := saveCmd.CombinedOutput(); saveErr != nil {
+		m.logger.Warn("failed to export builder image from Docker",
+			"error", saveErr,
+			"output", string(saveOutput))
+		return
+	}
+
+	// Load image from Docker save tarball
+	img, err := tarball.ImageFromPath(tmpPath, nil)
+	if err != nil {
+		m.logger.Warn("failed to load builder image from tarball", "error", err)
+		return
+	}
+
+	// Get image digest
+	digestHash, err := img.Digest()
+	if err != nil {
+		m.logger.Warn("failed to get builder image digest", "error", err)
+		return
+	}
+
+	// Write to OCI layout cache so the image manager can find it
+	cacheDir := m.paths.SystemOCICache()
+	if err := os.MkdirAll(cacheDir, 0755); err != nil {
+		m.logger.Warn("failed to create OCI cache directory", "error", err)
+		return
+	}
+
+	ociLayout, err := layout.FromPath(cacheDir)
+	if err != nil {
+		ociLayout, err = layout.Write(cacheDir, empty.Index)
+		if err != nil {
+			m.logger.Warn("failed to create OCI layout", "error", err)
+			return
+		}
+	}
+
+	if err := ociLayout.AppendImage(img, layout.WithAnnotations(map[string]string{
+		"org.opencontainers.image.ref.name": digestHash.Hex,
+	})); err != nil {
+		m.logger.Warn("failed to write builder image to OCI layout", "error", err)
+		return
+	}
+
+	// Import into image manager to trigger conversion to disk format
+	parts := strings.SplitN(builderImage, ":", 2)
+	imageName := parts[0]
+	imageTag := "latest"
+	if len(parts) == 2 {
+		imageTag = parts[1]
+	}
+	repo := fmt.Sprintf("%s/%s", registryHost, imageName)
+
+	if _, err := m.imageManager.ImportLocalImage(ctx, repo, imageTag, digestHash.String()); err != nil {
+		m.logger.Warn("failed to import builder image", "error", err)
+		return
+	}
+
+	m.logger.Info("builder image imported, waiting for conversion", "image", imageRef, "digest", digestHash.String())
+
+	// Wait for image conversion to complete before signaling ready
+	for attempt := 0; attempt < 120; attempt++ {
+		select {
+		case <-ctx.Done():
+			m.logger.Warn("context cancelled while waiting for builder image conversion")
+			return
+		default:
+		}
+
+		if imgInfo, err := m.imageManager.GetImage(ctx, imageRef); err == nil {
+			switch imgInfo.Status {
+			case images.StatusReady:
+				m.logger.Info("builder image ready", "image", imageRef)
+				return
+			case images.StatusFailed:
+				m.logger.Warn("builder image conversion failed", "image", imageRef)
+				return
+			}
+		}
+		time.Sleep(500 * time.Millisecond)
+	}
+
+	m.logger.Warn("timed out waiting for builder image conversion", "image", imageRef)
 }
 
 // CreateBuild starts a new build job

diff --git a/lib/instances/query.go b/lib/instances/query.go
--- a/lib/instances/query.go
+++ b/lib/instances/query.go
@@ -31,7 +31,7 @@
 		return stateResult{State: StateStopped}
 	}
 
-	// 3. Socket exists - query hypervisor for actual state
+	// Socket exists - query hypervisor for actual state
 	hv, err := m.getHypervisor(stored.SocketPath, stored.HypervisorType)
 	if err != nil {
 		// Failed to create client - this is unexpected if socket exists

rgarcia and others added 2 commits February 8, 2026 15:29
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is ON. A Cloud Agent has been kicked off to fix the reported issue.


// Wait for one direction to close
<-done
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vsock proxy exits after single direction closes

Medium Severity

The vsock proxy in handleVsockConnection waits for only one direction to close (<-done), then returns — closing both connections via defer. The second goroutine may still be copying data in the other direction. This can cause premature truncation of data still in flight (e.g., a response being written back after the client closes its write side). Waiting for both directions to finish would ensure complete data transfer.

Fix in Cursor Fix in Web

@cursor
Copy link

cursor bot commented Feb 8, 2026

Bugbot Autofix prepared fixes for 1 of the 1 bugs found in the latest run.

  • ✅ Fixed: Vsock proxy exits after single direction closes
    • Added a second <-done receive so the function waits for both copy goroutines to complete before closing connections, preventing premature data truncation.

Create PR

Or push these changes by commenting:

@cursor push 1d8b16b503
Preview (1d8b16b503)
diff --git a/cmd/vz-shim/server.go b/cmd/vz-shim/server.go
--- a/cmd/vz-shim/server.go
+++ b/cmd/vz-shim/server.go
@@ -300,8 +300,9 @@
 		done <- struct{}{}
 	}()
 
-	// Wait for one direction to close
+	// Wait for both directions to close
 	<-done
+	<-done
 }
 
 func copyData(dst, src net.Conn) {

rgarcia and others added 9 commits February 8, 2026 15:44
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The find_release_with_artifact function returns 1 when no darwin CLI
artifact exists, which under set -e kills the script before the
empty-check can handle it gracefully.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
launchd doesn't perform shell expansion, so ~ in DATA_DIR causes
"mkdir ~: read-only file system" when the service starts. Also fix
JWT_SECRET sed pattern to match the darwin template's default value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The uninstall script needs sudo on macOS when binaries were installed
to /usr/local/bin with elevated privileges. Also ensures the tilde
expansion fix is included.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The service requires Caddy for the ingress manager, and on macOS it's
not embedded - it must be in PATH.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
launchd doesn't inherit the user's PATH, so Homebrew-installed
binaries like caddy aren't found. Add standard Homebrew paths to
the plist's EnvironmentVariables.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The previous check used /v1/instances with a fake Bearer token which
would be rejected, and / which returns 404. Use the unauthenticated
/health endpoint instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The busybox image at queue position 0 can finish quickly, causing
alpine to transition from pending to pulling before the second
CreateImage call. Accept pulling as a valid idempotent status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant