Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/api/api/cp.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ func (s *ApiService) CpHandler(w http.ResponseWriter, r *http.Request) {
return
}

if inst.MacOS != nil && !inst.GuestAgentEnabled() {
http.Error(w, `{"code":"unsupported","message":"file copy requires the shared macOS guest agent to be enabled"}`, http.StatusNotImplemented)
return
}

if inst.State != instances.StateRunning {
http.Error(w, fmt.Sprintf(`{"code":"invalid_state","message":"instance must be running (current state: %s)"}`, inst.State), http.StatusConflict)
return
Expand Down
12 changes: 8 additions & 4 deletions cmd/api/api/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ func (s *ApiService) ExecHandler(w http.ResponseWriter, r *http.Request) {
return
}

if inst.MacOS != nil {
http.Error(w, `{"code":"unsupported","message":"exec is not implemented for experimental macOS instances"}`, http.StatusNotImplemented)
if inst.MacOS != nil && !inst.GuestAgentEnabled() {
http.Error(w, `{"code":"unsupported","message":"exec is not implemented for macOS images without the shared guest agent enabled"}`, http.StatusNotImplemented)
return
}

Expand Down Expand Up @@ -128,6 +128,8 @@ func (s *ApiService) ExecHandler(w http.ResponseWriter, r *http.Request) {
tracer := otel.Tracer("hypeman/exec")
ctx, span := tracer.Start(ctx, "exec.session", trace.WithAttributes(execSpanAttributes(inst.Id, execReq.TTY)...))
defer span.End()
ctx, cancel := context.WithCancel(ctx)
defer cancel()

// Audit log: exec session started
log.InfoContext(ctx, "exec session started",
Expand All @@ -146,11 +148,10 @@ func (s *ApiService) ExecHandler(w http.ResponseWriter, r *http.Request) {
var resizeChan chan *guest.WindowSize
if execReq.TTY {
resizeChan = make(chan *guest.WindowSize, 10)
defer close(resizeChan)
}

// Create WebSocket read/writer wrapper that handles resize messages
wsConn := &wsReadWriter{ws: ws, ctx: ctx, resizeChan: resizeChan}
wsConn := &wsReadWriter{ws: ws, ctx: ctx, resizeChan: resizeChan, cancel: cancel}

dialer, err := s.InstanceManager.GetVsockDialer(ctx, inst.Id)
if err != nil {
Expand Down Expand Up @@ -221,6 +222,7 @@ type wsReadWriter struct {
reader io.Reader
mu sync.Mutex
resizeChan chan<- *guest.WindowSize // Channel to send resize events (nil if not TTY)
cancel context.CancelFunc // ends the exec session when the websocket side fails
}

func (w *wsReadWriter) Read(p []byte) (n int, err error) {
Expand All @@ -241,6 +243,7 @@ func (w *wsReadWriter) Read(p []byte) (n int, err error) {
// Read next WebSocket message
messageType, data, err := w.ws.ReadMessage()
if err != nil {
w.cancel()
return 0, err
}

Expand Down Expand Up @@ -273,6 +276,7 @@ func (w *wsReadWriter) Read(p []byte) (n int, err error) {

func (w *wsReadWriter) Write(p []byte) (n int, err error) {
if err := w.ws.WriteMessage(websocket.BinaryMessage, p); err != nil {
w.cancel()
return 0, err
}
return len(p), nil
Expand Down
45 changes: 45 additions & 0 deletions cmd/api/api/exec_disconnect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package api

import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/gorilla/websocket"
"github.com/stretchr/testify/require"
)

func TestExecWebsocketDisconnectCancelsSession(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
readDone := make(chan error, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
readDone <- err
return
}
defer conn.Close()
wrapped := &wsReadWriter{ws: conn, ctx: ctx, cancel: cancel}
_, err = wrapped.Read(make([]byte, 1))
readDone <- err
}))
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
require.NoError(t, err)
require.NoError(t, conn.Close())
select {
case <-ctx.Done():
case <-time.After(5 * time.Second):
t.Fatal("WebSocket disconnect did not cancel exec session")
}
select {
case err := <-readDone:
require.Error(t, err)
case <-time.After(5 * time.Second):
t.Fatal("WebSocket reader did not exit")
}
}
30 changes: 30 additions & 0 deletions cmd/api/api/macos_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,36 @@ func TestMacOSStatRejectedBeforeGuestDial(t *testing.T) {
require.Equal(t, "unsupported", unsupported.Code)
}

func TestMacOSGuestAgentAdmissionBeforeUpgrade(t *testing.T) {
for _, tc := range []struct {
name string
declared, skipped bool
status int
}{
{"unmanaged", false, false, http.StatusNotImplemented},
{"disabled", true, true, http.StatusNotImplemented},
{"enabled but stopped", true, false, http.StatusConflict},
} {
for _, operation := range []string{"exec", "cp"} {
t.Run(tc.name+"/"+operation, func(t *testing.T) {
s := &ApiService{}
inst := &instances.Instance{StoredMetadata: instances.StoredMetadata{
MacOS: &images.MacOSImage{GuestAgent: tc.declared}, SkipGuestAgent: tc.skipped,
}, State: instances.StateStopped}
ctx := mw.WithResolvedInstance(context.Background(), "test", inst)
r := httptest.NewRequest(http.MethodGet, "/instances/test/"+operation, nil).WithContext(ctx)
w := httptest.NewRecorder()
if operation == "exec" {
s.ExecHandler(w, r)
} else {
s.CpHandler(w, r)
}
require.Equal(t, tc.status, w.Code)
})
}
}
}

func TestMacOSSchemaDefersTemplateDefaults(t *testing.T) {
spec, err := oapi.GetSwagger()
require.NoError(t, err)
Expand Down
16 changes: 11 additions & 5 deletions docs/macos-experimental.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,23 +72,29 @@ attached. NAT uses the preserved MAC. The IP is observed from the host's VZ
DHCP leases, which may retain an old lease before the guest is ready.

**`Running` means the VMM is running, not that SSH, the desktop or Chrome is
ready.** Readiness is currently tested over SSH. Startup does not launch Chrome.
ready.** Unmanaged templates still need an external readiness check. Templates
provisioned with the shared Darwin GuestService can opt in with `guest_agent: true`
in their platform configuration; system readiness is observed separately through
`guest_agent_ready_at`. See [Darwin GuestService](macos-guest-agent.md). Startup
does not launch Chrome.
Guest identity, host keys, user secrets and MAC are preserved. Only one active
instance with a given Mac identifier is allowed. Do not run the source bundle
or an external clone concurrently. Production provisioning needs identity and
credential rekeying; this spike is not a multi-tenant image format.

`POST /instances/{id}/stop` shuts down the VMM; without a Darwin guest agent,
it does **not** guarantee an orderly guest OS/application shutdown. Use a human
`POST /instances/{id}/stop` attempts orderly GuestService shutdown for opted-in
images, then falls back to VMM shutdown if necessary. Without an enabled Darwin
guest agent it does **not** guarantee an orderly guest OS/application shutdown. Use a human
or an authorized guest shutdown workflow before destructive operations when
application consistency matters. Start cold-boots the instance's existing disk.
Delete removes instance storage, not the imported image. Deleting the imported
image does not affect existing instances: each owns independent copies and
starts without the template.

Unsupported instance operations reject requests: snapshot/fork/standby/restore,
updates, volumes, env/commands/credential brokering, Linux guest-agent exec and
vsock operations, health/restart/auto-standby policies, passthrough and shaping.
updates, volumes, startup env/commands/credential brokering,
health/restart/auto-standby policies, passthrough and shaping. Exec/file-copy over
vsock require an opted-in shared agent; unmanaged images reject those requests.
The shim's standalone save/restore proof does not make API snapshot semantics
safe; consistent disk+aux+state bundle handling remains separate work.

Expand Down
117 changes: 117 additions & 0 deletions docs/macos-guest-agent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Experimental Darwin GuestService

The existing guest-agent executable now builds for macOS and serves the same
`guest.GuestService` gRPC contract as Linux on vsock port **2222**. It reuses
exec, copy-to/from-guest, and stat implementations; this is not the browser
prototype's custom HTTP protocol or its ports.

## Build

Build on a macOS development machine with the macOS SDK and cgo enabled:

```sh
CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build \
-o guest-agent-darwin-arm64 ./lib/system/guest_agent
```

Darwin native AF_VSOCK requires cgo. A cgo-disabled Darwin executable builds but
refuses to listen with an explicit error. Linux keeps its existing Go vsock
listener and cgo-disabled build path. The Darwin listener accepts only host
CID 2, marks descriptors close-on-exec, and provides net.Conn deadlines for
gRPC. Do not start the executable on the development host to test guest services.

## Provisioning boundary

Install the binary **inside a stopped-template provisioning guest**, not on the
host. System operations need a root LaunchDaemon. Use a root-owned, non-writable
binary location and launchd configuration; provision permissions and logs
explicitly. The default readiness file is `/var/run/hypeman/guest-agent-ready`
(`HYPEMAN_AGENT_READY_FILE` can override it).

A listening system agent does not establish autologin, desktop readiness, TCC
permissions, or browser readiness. Root and user desktop agents need a reviewed
handoff and explicit session selection before desktop execution is supported.
No desktop agent installer or public session-selector extension is part of this
slice.

The host API and hypervisor still enforce authorization. The vsock host-CID
check is transport admission, not a replacement for instance authority checks.
As with the Linux guest agent, commands and file operations run as root with no
per-caller credential switch; the host API alone decides who may reach them.
An exec `timeout_seconds` ends the command; it does not bound a healthy stream.
Do not expose this privileged service through unauthenticated host forwarding.

## Image declaration and host integration

Set `"guest_agent": true` in the image's experimental macOS platform configuration
only after provisioning the root shared agent on vsock 2222. This field is omitted
by default, so existing local templates remain unmanaged. The normal instance
`skip_guest_agent` option can disable agent integration even for a declared image.

For declared/enabled instances, the normal exec and file-copy handlers use the
shared GuestService and stop attempts its Shutdown RPC before the existing
forced-stop fallback. An unavailable agent still produces a timeout/failure,
not an assertion that it is ready. Readiness probing records the existing
`guest_agent_ready_at` marker without requiring or inventing a Linux workload
start marker. `Running` remains VMM-running for macOS; it does not certify system,
desktop or browser readiness. This readiness timestamp is a boot observation,
not continuous agent health. Start clears the prior boot's readiness marker.

Undeclared/disabled images reject exec/copy before WebSocket upgrade and skip
agent shutdown/probes. The declaration is an image capability claim, not a
verified live guest handshake or a security credential. Runtime connectivity
and privileged provisioning still require the validation below.

## OS-specific operations

- **Shutdown:** root-only `/sbin/shutdown -h now`, not a signal to launchd/PID 1.
Signal 0 (default) and SIGTERM mean orderly shutdown; other signals are rejected.
Permission, cancellation, and command errors are reported. An RPC response is
not proof the VMM has exited: the host must wait for teardown.
- **Network identity reconfiguration:** returns gRPC `Unimplemented` on Darwin.
Current VZ NAT uses guest DHCP; no static-address, MAC-rekey, ingress, or network
policy parity is claimed.
- **GPU status:** Linux NVIDIA initialization reporting is not macOS graphics
readiness. A Darwin guest without that device reports the existing unknown
state.

## Validation and remaining integration

In-process gRPC tests exercise exec stdout/stderr/exit/env, disconnect
cancellation, and file copy/stat round-trips. Darwin-specific tests exercise
shutdown policy without executing a real shutdown, explicit network rejection,
and transport deadline errors using a local socket pair. These do not prove a
live guest AF_VSOCK handshake for this executable.

## Isolated live validation (2026-10-10)

A manually provisioned root LaunchDaemon in the isolated QA guest survives normal
API cold boots. Authenticated normal API exec reports UID0 in both TTY modes;
private root-owned copy-to/from round trips and command/descendant timeout cleanup
pass repeated race-instrumented client tests. The readiness probe uses
`/usr/bin/true` on Darwin (`/bin/true` remains the Linux command); a live regression
first failed with the Linux path, then passed after this correction. A normal GET
persisted `GuestAgentReadyAt` while leaving `ProgramStartedAt` absent.

Darwin can disconnect vsock while shutdown is in progress, before the RPC reply
arrives. The host therefore retains the configured grace period on Darwin even
when that reply is unavailable, instead of Linux's short failure fallback. Success
still requires the owned VMM process to exit. A live normal API stop confirmed
shutdown RPC acceptance, VMM exit and closed storage without forced fallback after
the correction. HTTP stop200 or `Stopped` alone is not a build/export receipt.

This is manual provisioning of one QA instance, not automatic image installation,
public macOS builds, output publication or reusable-image sanitation proof. The
immutable imported base remains unchanged and does not acquire this capability.

Remaining gates:

- Reusable image provisioning and guest-agent version compatibility.
- Strict build/export stop receipts and API recovery with the root deployment;
bounded readiness wait semantics.
- Root/desktop session authorization and image provisioning.
- Broader backpressure/large-output validation of bounded non-TTY streaming,
PTY/disconnect and descendant-process cleanup, transfer failure/size handling,
and privilege/logging security review.
- Linux test execution on an appropriate runner, and independent authenticated
review.
25 changes: 17 additions & 8 deletions lib/guest/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,8 @@ func retryableConnectionErrorType(err error) string {

// execIntoInstanceOnce executes command in instance via vsock using gRPC (single attempt).
func execIntoInstanceOnce(ctx context.Context, dialer hypervisor.VsockDialer, opts ExecOptions) (*ExitStatus, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
start := time.Now()
var bytesSent int64

Expand Down Expand Up @@ -515,14 +517,21 @@ func execIntoInstanceOnce(ctx context.Context, dialer hypervisor.VsockDialer, op
// Handle resize events in background (if channel provided)
if opts.ResizeChan != nil {
go func() {
for resize := range opts.ResizeChan {
streamMu.Lock()
stream.Send(&ExecRequest{
Request: &ExecRequest_Resize{
Resize: resize,
},
})
streamMu.Unlock()
for {
select {
case <-ctx.Done():
return
case resize, ok := <-opts.ResizeChan:
if !ok {
return
}
streamMu.Lock()
err := stream.Send(&ExecRequest{Request: &ExecRequest_Resize{Resize: resize}})
streamMu.Unlock()
if err != nil {
return
}
}
}
}()
}
Expand Down
3 changes: 3 additions & 0 deletions lib/images/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ type MacOSImage struct {
MAC string `json:"mac"`
CPUs uint `json:"cpus"`
Memory uint64 `json:"memory"`
// GuestAgent declares a provisioned system GuestService on vsock 2222.
// Readiness is probed separately; old templates remain unmanaged.
GuestAgent bool `json:"guest_agent,omitempty"`
}

// Validate checks the platform fields every macOS bundle must carry, whether it
Expand Down
13 changes: 13 additions & 0 deletions lib/instances/guest_agent_ready_command_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package instances

import (
"testing"

"github.com/kernel/hypeman/lib/images"
"github.com/stretchr/testify/require"
)

func TestGuestAgentReadyExecutableMatchesGuestOS(t *testing.T) {
require.Equal(t, "/bin/true", guestAgentReadyExecutable(&StoredMetadata{}))
require.Equal(t, "/usr/bin/true", guestAgentReadyExecutable(&StoredMetadata{MacOS: &images.MacOSImage{}}))
}
11 changes: 9 additions & 2 deletions lib/instances/macos.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func prepareMacOSCreate(req *CreateInstanceRequest, img *images.Image, caps hype
return fmt.Errorf("%w: incomplete macOS image", ErrImageNotReady)
}
if req.HotplugSize != 0 || req.OverlaySize != 0 || len(req.Volumes) != 0 || len(req.Devices) != 0 || req.GPU != nil || len(req.Env) != 0 || len(req.Entrypoint) != 0 || len(req.Cmd) != 0 || req.NetworkEgress != nil || len(req.Credentials) != 0 || req.AutoStandby != nil || req.HealthCheck != nil || req.RestartPolicy != nil || req.SnapshotPolicy != nil || req.DiskIOBps != 0 || req.NetworkBandwidthDownload != 0 || req.NetworkBandwidthUpload != 0 {
return fmt.Errorf("%w: experimental macOS supports local disk clone, CPU/RAM, tags, expiration and NAT only; Linux commands/env/volumes, overlays, agents, policies and I/O shaping are unsupported", ErrInvalidRequest)
return fmt.Errorf("%w: experimental macOS supports local disk clone, CPU/RAM, tags, expiration and NAT only; Linux startup commands/env/volumes, overlays, policies and I/O shaping are unsupported", ErrInvalidRequest)
}
size, vcpus := req.Size, req.Vcpus
if size == 0 {
Expand All @@ -43,7 +43,7 @@ func prepareMacOSCreate(req *CreateInstanceRequest, img *images.Image, caps hype
}
req.Size, req.Vcpus = size, vcpus
req.OverlaySize = *img.SizeBytes // Reserve the writable boot disk, not a Linux overlay.
req.SkipGuestAgent = true
req.SkipGuestAgent = req.SkipGuestAgent || !img.MacOS.GuestAgent
req.SkipKernelHeaders = true
return nil
}
Expand Down Expand Up @@ -100,6 +100,13 @@ func (m *manager) checkMacOSIdentityAvailable(ctx context.Context, stored *Store
return nil
}

// GuestAgentEnabled reports whether exec, copy, readiness and shutdown may use the
// shared guest agent. A macOS image must declare it; hand-written metadata that
// leaves SkipGuestAgent unset does not enable it.
func (s *StoredMetadata) GuestAgentEnabled() bool {
return !s.SkipGuestAgent && (s.MacOS == nil || s.MacOS.GuestAgent)
}

// rejectMacOS refuses operations the experimental macOS guest does not implement.
// Callers check it right after loading the record they already hold under the lock.
func (s *StoredMetadata) rejectMacOS(operation string) error {
Expand Down
Loading
Loading