diff --git a/cmd/api/api/api.go b/cmd/api/api/api.go
index 8a2c72db0..c52b861a7 100644
--- a/cmd/api/api/api.go
+++ b/cmd/api/api/api.go
@@ -16,10 +16,13 @@ import (
"github.com/kernel/hypeman/lib/resources"
"github.com/kernel/hypeman/lib/vm_metrics"
"github.com/kernel/hypeman/lib/volumes"
+ "sync"
)
// ApiService implements the oapi.StrictServerInterface
type ApiService struct {
+ desktopSlotsOnce sync.Once
+ desktopSlots chan struct{} // Bounds active desktop requests/upgraded sessions across instances.
Config *config.Config
ImageManager images.Manager
InstanceManager instances.Manager
diff --git a/cmd/api/api/cdp.go b/cmd/api/api/cdp.go
new file mode 100644
index 000000000..1a0c88d7a
--- /dev/null
+++ b/cmd/api/api/cdp.go
@@ -0,0 +1,142 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/kernel/hypeman/lib/desktop"
+ "github.com/kernel/hypeman/lib/hypervisor"
+ "github.com/kernel/hypeman/lib/instances"
+ mw "github.com/kernel/hypeman/lib/middleware"
+)
+
+// CDPHandler requires instance-write authentication and resolution in the router.
+func (s *ApiService) CDPHandler(w http.ResponseWriter, r *http.Request) {
+ s.serveDesktop(w, r, newDesktopTransport)
+}
+
+func newDesktopTransport(inst *instances.Instance) (*http.Transport, error) {
+ dialer, err := hypervisor.NewVsockDialer(hypervisor.Type(inst.HypervisorType), inst.VsockSocket, inst.VsockCID)
+ if err != nil {
+ return nil, err
+ }
+ return &http.Transport{DisableKeepAlives: true, ResponseHeaderTimeout: 20 * time.Second, DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return dialer.DialVsock(ctx, int(desktop.AgentPort))
+ }}, nil
+}
+
+func (s *ApiService) serveDesktop(w http.ResponseWriter, r *http.Request, transportFor func(*instances.Instance) (*http.Transport, error)) {
+ inst := mw.GetResolvedInstance[instances.Instance](r.Context())
+ if inst == nil {
+ http.Error(w, "instance not resolved", 500)
+ return
+ }
+ if inst.MacOS == nil || hypervisor.Type(inst.HypervisorType) != hypervisor.TypeVZ || inst.MacOS.DesktopAgentUID == 0 || inst.SkipGuestAgent {
+ http.Error(w, "instance does not declare an enabled macOS desktop agent", 501)
+ return
+ }
+ if inst.State != instances.StateRunning {
+ http.Error(w, "instance must be running", 409)
+ return
+ }
+ if s.Config == nil || s.Config.MacOSDesktopOrigin == "" {
+ http.Error(w, "desktop API origin not configured", 503)
+ return
+ }
+ origin, err := desktop.ParseOrigin(s.Config.MacOSDesktopOrigin)
+ if err != nil {
+ http.Error(w, "invalid desktop API origin", 503)
+ return
+ }
+ if origins := r.Header.Values("Origin"); len(origins) > 1 || len(origins) == 1 && !desktop.SameOrigin(origins[0], origin) {
+ http.Error(w, "cross-origin desktop access rejected", 403)
+ return
+ }
+ selector := chi.URLParam(r, "id")
+ if selector == "" {
+ selector = inst.Id
+ }
+ prefix := "/instances/" + selector + "/cdp"
+ if !strings.HasPrefix(r.URL.Path, prefix+"/") || desktop.ValidateBodylessRequest(r) != nil {
+ http.Error(w, "invalid desktop request", 400)
+ return
+ }
+ path := strings.TrimPrefix(r.URL.Path, prefix)
+ status := path == "/status" && r.Method == http.MethodGet
+ start := path == "/start" && r.Method == http.MethodPost
+ request := r.Clone(r.Context())
+ request.URL.Path = path
+ if !status && !start {
+ if err := desktop.ValidateCDPRequest(request); err != nil {
+ http.Error(w, "unsupported CDP request", 400)
+ return
+ }
+ }
+ s.desktopSlotsOnce.Do(func() { s.desktopSlots = make(chan struct{}, desktop.MaxSessions) })
+ select {
+ case s.desktopSlots <- struct{}{}:
+ defer func() { <-s.desktopSlots }()
+ default:
+ http.Error(w, "desktop session limit reached", http.StatusTooManyRequests)
+ return
+ }
+ transport, err := transportFor(inst)
+ if err != nil {
+ http.Error(w, "desktop transport unavailable", 503)
+ return
+ }
+ defer transport.CloseIdleConnections()
+ timeout := 3 * time.Second
+ if start {
+ timeout = 20 * time.Second
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), timeout)
+ state, err := desktop.Probe(ctx, transport, inst.MacOS.DesktopAgentUID, start)
+ cancel()
+ if err != nil {
+ code, message := probeFailure(err)
+ http.Error(w, message, code)
+ return
+ }
+ if status || start {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = json.NewEncoder(w).Encode(struct {
+ desktop.Status
+ DesktopReady bool `json:"desktop_ready"`
+ }{state, state.SessionReady()})
+ return
+ }
+ if !state.BrowserReady {
+ http.Error(w, "selected desktop session or managed browser not ready", 409)
+ return
+ }
+ scheme := "ws"
+ if origin.Scheme == "https" {
+ scheme = "wss"
+ }
+ base := scheme + "://" + origin.Host + "/instances/" + url.PathEscape(inst.Id) + "/cdp"
+ proxy, err := desktop.NewCDPProxy(transport, base)
+ if err != nil {
+ http.Error(w, "invalid instance CDP configuration", 503)
+ return
+ }
+ proxy.ServeHTTP(w, request)
+}
+
+// probeFailure maps a desktop probe error to a response. A guest 409 is a state
+// conflict (no GUI session, or a browser launch in progress), not an incompatible agent.
+func probeFailure(err error) (int, string) {
+ var agentErr *desktop.AgentStatusError
+ if errors.As(err, &agentErr) && agentErr.StatusCode == http.StatusConflict {
+ return http.StatusConflict, "desktop agent has no GUI session or a browser launch is in progress"
+ }
+ return http.StatusServiceUnavailable, "selected desktop agent unavailable or incompatible"
+}
diff --git a/cmd/api/api/cdp_test.go b/cmd/api/api/cdp_test.go
new file mode 100644
index 000000000..c9d3f8558
--- /dev/null
+++ b/cmd/api/api/cdp_test.go
@@ -0,0 +1,159 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/kernel/hypeman/cmd/api/config"
+ "github.com/kernel/hypeman/lib/desktop"
+ "github.com/kernel/hypeman/lib/images"
+ "github.com/kernel/hypeman/lib/instances"
+ mw "github.com/kernel/hypeman/lib/middleware"
+ "github.com/kernel/hypeman/lib/scopes"
+ "github.com/stretchr/testify/require"
+)
+
+type desktopInstances struct {
+ instances.Manager
+ inst *instances.Instance
+ lookups atomic.Int32
+}
+
+func (m *desktopInstances) GetInstance(_ context.Context, name string) (*instances.Instance, error) {
+ m.lookups.Add(1)
+ if name != "test" && name != "alias" {
+ return nil, instances.ErrNotFound
+ }
+ return m.inst, nil
+}
+func desktopInstance() *instances.Instance {
+ return &instances.Instance{StoredMetadata: instances.StoredMetadata{Id: "test", HypervisorType: "vz", MacOS: &images.MacOSImage{DesktopAgentUID: 501}}, State: instances.StateRunning}
+}
+func desktopToken(t *testing.T, permission string) string {
+ t.Helper()
+ s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": "test", "exp": time.Now().Add(time.Hour).Unix(), "permissions": []string{permission}}).SignedString([]byte("synthetic-test-secret"))
+ require.NoError(t, err)
+ return s
+}
+func desktopRouter(s *ApiService, factory func(*instances.Instance) (*http.Transport, error)) http.Handler {
+ r := chi.NewRouter()
+ sub := r.With(mw.JwtAuth("synthetic-test-secret"), scopes.RequireScope(scopes.InstanceWrite), mw.ResolveResource(s.NewResolvers(), ResolverErrorResponder))
+ h := func(w http.ResponseWriter, r *http.Request) { s.serveDesktop(w, r, factory) }
+ sub.Get("/instances/{id}/cdp/*", h)
+ sub.Post("/instances/{id}/cdp/start", h)
+ return r
+}
+
+func TestDesktopAdmissionBeforeDial(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ change func(*instances.Instance, *config.Config, *http.Request)
+ code int
+ }{
+ {"missing token", func(_ *instances.Instance, _ *config.Config, r *http.Request) { r.Header.Del("Authorization") }, 401},
+ {"read-only", func(_ *instances.Instance, _ *config.Config, r *http.Request) {
+ r.Header.Set("Authorization", "Bearer "+desktopToken(t, string(scopes.InstanceRead)))
+ }, 403},
+ {"undeclared", func(i *instances.Instance, _ *config.Config, _ *http.Request) { i.MacOS.DesktopAgentUID = 0 }, 501},
+ {"disabled", func(i *instances.Instance, _ *config.Config, _ *http.Request) { i.SkipGuestAgent = true }, 501},
+ {"stopped", func(i *instances.Instance, _ *config.Config, _ *http.Request) { i.State = instances.StateStopped }, 409},
+ {"unknown instance", func(_ *instances.Instance, _ *config.Config, r *http.Request) {
+ r.URL.Path = "/instances/other/cdp/json/version"
+ }, 404},
+ {"no origin config", func(_ *instances.Instance, c *config.Config, _ *http.Request) { c.MacOSDesktopOrigin = "" }, 503},
+ {"wrong origin", func(_ *instances.Instance, _ *config.Config, r *http.Request) {
+ r.Header.Set("Origin", "https://evil.example")
+ }, 403},
+ {"multiple origins", func(_ *instances.Instance, _ *config.Config, r *http.Request) {
+ r.Header.Add("Origin", "https://api.example")
+ r.Header.Add("Origin", "https://api.example")
+ }, 403},
+ {"unsafe discovery", func(_ *instances.Instance, _ *config.Config, r *http.Request) {
+ r.URL.Path = "/instances/test/cdp/json/new"
+ }, 400},
+ {"query", func(_ *instances.Instance, _ *config.Config, r *http.Request) { r.URL.RawQuery = "token=secret" }, 400},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ i := desktopInstance()
+ c := &config.Config{MacOSDesktopOrigin: "https://api.example"}
+ m := &desktopInstances{inst: i}
+ s := &ApiService{Config: c, InstanceManager: m}
+ r := httptest.NewRequest("GET", "http://spoof.example/instances/test/cdp/json/version", nil)
+ r.Header.Set("Authorization", "Bearer "+desktopToken(t, string(scopes.InstanceWrite)))
+ tc.change(i, c, r)
+ w := httptest.NewRecorder()
+ desktopRouter(s, func(*instances.Instance) (*http.Transport, error) { t.Fatal("dial before admission"); return nil, nil }).ServeHTTP(w, r)
+ require.Equal(t, tc.code, w.Code, w.Body.String())
+ if tc.code == 401 || tc.name == "read-only" {
+ require.Zero(t, m.lookups.Load())
+ }
+ })
+ }
+}
+
+func TestDesktopHandshakeAndDiscovery(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ uid uint32
+ ready bool
+ code int
+ }{{"ready", 501, true, 200}, {"wrong desktop user", 502, true, 503}, {"root desktop user", 0, true, 503}, {"browser not ready", 501, false, 409}} {
+ t.Run(tc.name, func(t *testing.T) {
+ var calls atomic.Int32
+ guest := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls.Add(1)
+ require.Empty(t, r.Header.Get("Authorization"))
+ require.Empty(t, r.Header.Get("Cookie"))
+ require.Empty(t, r.Header.Get("Forwarded"))
+ w.Header().Set("Content-Type", "application/json")
+ if r.URL.Path == "/status" {
+ _ = json.NewEncoder(w).Encode(desktop.Status{Version: 1, OS: "darwin", Architecture: "arm64", UID: tc.uid, ConsoleUID: tc.uid, GUISession: true, BrowserManaged: tc.ready, BrowserReady: tc.ready})
+ return
+ }
+ require.Equal(t, "/json/version", r.URL.Path)
+ _, _ = w.Write([]byte(`{"Browser":"Chrome/test","webSocketDebuggerUrl":"ws://127.0.0.1:9222/devtools/browser/test-id"}`))
+ }))
+ defer guest.Close()
+ m := &desktopInstances{inst: desktopInstance()}
+ s := &ApiService{Config: &config.Config{MacOSDesktopOrigin: "https://api.example"}, InstanceManager: m}
+ factory := func(*instances.Instance) (*http.Transport, error) {
+ return &http.Transport{DisableKeepAlives: true, DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return (&net.Dialer{}).DialContext(ctx, "tcp", strings.TrimPrefix(guest.URL, "http://"))
+ }}, nil
+ }
+ r := httptest.NewRequest("GET", "http://spoof.example/instances/alias/cdp/json/version", nil)
+ r.Header.Set("Authorization", "Bearer "+desktopToken(t, string(scopes.InstanceWrite)))
+ r.Header.Set("Cookie", "secret=value")
+ r.Header.Set("Forwarded", "host=evil.example")
+ r.Header.Set("Origin", "https://api.example")
+ w := httptest.NewRecorder()
+ desktopRouter(s, factory).ServeHTTP(w, r)
+ require.Equal(t, tc.code, w.Code, w.Body.String())
+ if tc.code == 200 {
+ require.Contains(t, w.Body.String(), "wss://api.example/instances/test/cdp/devtools/browser/test-id")
+ require.Equal(t, int32(2), calls.Load())
+ } else {
+ require.Equal(t, int32(1), calls.Load())
+ }
+ })
+ }
+}
+
+func TestProbeFailureKeepsGuestConflictStatus(t *testing.T) {
+ code, _ := probeFailure(&desktop.AgentStatusError{StatusCode: http.StatusConflict})
+ require.Equal(t, http.StatusConflict, code)
+ code, _ = probeFailure(&desktop.AgentStatusError{StatusCode: http.StatusInternalServerError})
+ require.Equal(t, http.StatusServiceUnavailable, code)
+ code, _ = probeFailure(errors.New("transport down"))
+ require.Equal(t, http.StatusServiceUnavailable, code)
+}
diff --git a/cmd/api/api/cdp_websocket_test.go b/cmd/api/api/cdp_websocket_test.go
new file mode 100644
index 000000000..d0cdcd3cc
--- /dev/null
+++ b/cmd/api/api/cdp_websocket_test.go
@@ -0,0 +1,90 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/kernel/hypeman/cmd/api/config"
+ "github.com/kernel/hypeman/lib/desktop"
+ "github.com/kernel/hypeman/lib/instances"
+ "github.com/kernel/hypeman/lib/scopes"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDesktopWebsocketReconnect(t *testing.T) {
+ guest := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" || r.Header.Get("Origin") != "" {
+ t.Error("guest received API authority")
+ http.Error(w, "unexpected headers", 400)
+ return
+ }
+ if r.URL.Path == "/status" {
+ _ = json.NewEncoder(w).Encode(desktop.Status{Version: 1, OS: "darwin", Architecture: "arm64", UID: 501, ConsoleUID: 501, GUISession: true, BrowserManaged: true, BrowserReady: true})
+ return
+ }
+ if r.URL.Path != "/devtools/browser/test-id" {
+ t.Error("unexpected browser route")
+ http.Error(w, "bad route", 400)
+ return
+ }
+ upgrader := websocket.Upgrader{}
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ defer conn.Close()
+ kind, data, err := conn.ReadMessage()
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if err := conn.WriteMessage(kind, data); err != nil {
+ t.Error(err)
+ }
+ }))
+ defer guest.Close()
+ s := &ApiService{Config: &config.Config{MacOSDesktopOrigin: "https://api.example"}, InstanceManager: &desktopInstances{inst: desktopInstance()}}
+ factory := func(*instances.Instance) (*http.Transport, error) {
+ return &http.Transport{DisableKeepAlives: true, DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return (&net.Dialer{}).DialContext(ctx, "tcp", strings.TrimPrefix(guest.URL, "http://"))
+ }}, nil
+ }
+ api := httptest.NewServer(desktopRouter(s, factory))
+ defer api.Close()
+ for i := 0; i < 2; i++ {
+ headers := http.Header{"Authorization": {"Bearer " + desktopToken(t, string(scopes.InstanceWrite))}, "Cookie": {"secret=value"}, "Origin": {"https://api.example"}}
+ c, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(api.URL, "http")+"/instances/test/cdp/devtools/browser/test-id", headers)
+ require.NoError(t, err)
+ require.NoError(t, c.WriteMessage(websocket.TextMessage, []byte(`{"id":1,"method":"Browser.getVersion"}`)))
+ _, data, err := c.ReadMessage()
+ require.NoError(t, err)
+ require.Contains(t, string(data), "Browser.getVersion")
+ require.NoError(t, c.Close())
+ require.Eventually(t, func() bool { return len(s.desktopSlots) == 0 }, 2*time.Second, 10*time.Millisecond)
+ }
+}
+
+func TestDesktopActiveSessionLimit(t *testing.T) {
+ s := &ApiService{Config: &config.Config{MacOSDesktopOrigin: "https://api.example"}, InstanceManager: &desktopInstances{inst: desktopInstance()}}
+ s.desktopSlotsOnce.Do(func() { s.desktopSlots = make(chan struct{}, desktop.MaxSessions) })
+ for i := 0; i < desktop.MaxSessions; i++ {
+ s.desktopSlots <- struct{}{}
+ }
+ r := httptest.NewRequest("GET", "http://api.example/instances/test/cdp/status", nil)
+ r.Header.Set("Authorization", "Bearer "+desktopToken(t, string(scopes.InstanceWrite)))
+ w := httptest.NewRecorder()
+ desktopRouter(s, func(*instances.Instance) (*http.Transport, error) {
+ t.Fatal("dial above session limit")
+ return nil, nil
+ }).ServeHTTP(w, r)
+ require.Equal(t, http.StatusTooManyRequests, w.Code)
+ require.Len(t, s.desktopSlots, desktop.MaxSessions)
+}
diff --git a/cmd/api/config/config.go b/cmd/api/config/config.go
index fe1fc74ee..8663065e9 100644
--- a/cmd/api/config/config.go
+++ b/cmd/api/config/config.go
@@ -10,6 +10,7 @@ import (
"time"
"github.com/c2h5oh/datasize"
+ "github.com/kernel/hypeman/lib/desktop"
"github.com/kernel/hypeman/lib/snapshot"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/env"
@@ -275,13 +276,14 @@ type GPUConfig struct {
// Config is the top-level Hypeman server configuration.
type Config struct {
- Port string `koanf:"port"`
- ListenAddress string `koanf:"listen_address"` // Empty preserves listening on all interfaces.
- DataDir string `koanf:"data_dir"`
- JwtSecret string `koanf:"jwt_secret"`
- Env string `koanf:"env"`
- Version string `koanf:"version"`
- MacOSOnly bool `koanf:"macos_only"` // Experimental: omit Linux boot downloads and reject Linux creates.
+ Port string `koanf:"port"`
+ ListenAddress string `koanf:"listen_address"` // Empty preserves listening on all interfaces.
+ DataDir string `koanf:"data_dir"`
+ JwtSecret string `koanf:"jwt_secret"`
+ Env string `koanf:"env"`
+ Version string `koanf:"version"`
+ MacOSDesktopOrigin string `koanf:"macos_desktop_origin"` // Trusted external API origin; empty disables desktop/CDP routes.
+ MacOSOnly bool `koanf:"macos_only"` // Experimental: omit Linux boot downloads and reject Linux creates.
Network NetworkConfig `koanf:"network"`
Caddy CaddyConfig `koanf:"caddy"`
@@ -590,6 +592,11 @@ func expandHomePath(path string) string {
// Validate checks configuration values for correctness.
// Returns an error if any configuration value is invalid.
func (c *Config) Validate() error {
+ if c.MacOSDesktopOrigin != "" {
+ if _, err := desktop.ParseOrigin(c.MacOSDesktopOrigin); err != nil {
+ return fmt.Errorf("macos_desktop_origin: %w", err)
+ }
+ }
if c.MacOSOnly && (runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" || c.Hypervisor.Default != "vz") {
return fmt.Errorf("macos_only requires vz on Apple silicon")
}
diff --git a/cmd/api/config/desktop_test.go b/cmd/api/config/desktop_test.go
new file mode 100644
index 000000000..8fdca024b
--- /dev/null
+++ b/cmd/api/config/desktop_test.go
@@ -0,0 +1,23 @@
+package config
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestDesktopOriginConfig(t *testing.T) {
+ for _, origin := range []string{"", "https://api.example", "http://127.0.0.1:4974/"} {
+ cfg := defaultConfig()
+ cfg.MacOSDesktopOrigin = origin
+ if err := cfg.Validate(); err != nil && strings.Contains(err.Error(), "macos_desktop_origin") {
+ t.Fatalf("valid origin rejected: %v", err)
+ }
+ }
+ for _, origin := range []string{"ws://api.example", "https://user:pass@api.example", "https://api.example/path", "https://api.example?token=x", "https://api.example#fragment"} {
+ cfg := defaultConfig()
+ cfg.MacOSDesktopOrigin = origin
+ if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "macos_desktop_origin") {
+ t.Fatalf("invalid origin accepted: %s (%v)", origin, err)
+ }
+ }
+}
diff --git a/cmd/api/main.go b/cmd/api/main.go
index e9d85f2fe..1fac786e6 100644
--- a/cmd/api/main.go
+++ b/cmd/api/main.go
@@ -461,6 +461,20 @@ func run() error {
mw.ResolveResource(app.ApiService.NewResolvers(), api.ResolverErrorResponder),
).Get("/instances/{id}/exec", app.ApiService.ExecHandler)
+ // Desktop/browser routes grant browser control, including discovery and status.
+ desktopRoutes := r.With(
+ middleware.RequestID,
+ middleware.RealIP,
+ middleware.Recoverer,
+ mw.InjectLogger(logger),
+ mw.AccessLogger(accessLogger),
+ mw.JwtAuth(app.Config.JwtSecret),
+ scopes.RequireScope(scopes.InstanceWrite),
+ mw.ResolveResource(app.ApiService.NewResolvers(), api.ResolverErrorResponder),
+ )
+ desktopRoutes.Get("/instances/{id}/cdp/*", app.ApiService.CDPHandler)
+ desktopRoutes.Post("/instances/{id}/cdp/start", app.ApiService.CDPHandler)
+
// Custom cp endpoint (outside OpenAPI spec, uses WebSocket)
r.With(
middleware.RequestID,
diff --git a/docs/examples/macos-desktop-agent.plist b/docs/examples/macos-desktop-agent.plist
new file mode 100644
index 000000000..3c82ef5e7
--- /dev/null
+++ b/docs/examples/macos-desktop-agent.plist
@@ -0,0 +1,18 @@
+
+
+
+
+ Labelsh.hypeman.desktop-agent
+ ProgramArguments
+
+ /Library/Application Support/Hypeman/guest-agent
+ --roledesktop
+
+ LimitLoadToSessionTypeAqua
+ RunAtLoad
+
+ AbandonProcessGroup
+ KeepAlive
+ ThrottleInterval30
+
+
diff --git a/docs/macos-desktop.md b/docs/macos-desktop.md
new file mode 100644
index 000000000..74b7daccf
--- /dev/null
+++ b/docs/macos-desktop.md
@@ -0,0 +1,144 @@
+# macOS desktop and managed browser (draft)
+
+The macOS runtime does not need a host viewer to keep its VMM running. VMM
+`Running`, system GuestService readiness, selected desktop-session readiness,
+managed browser readiness, and capture/input permission are separate. The spike's
+host Cocoa viewer is not a supported viewer API.
+
+This branch adds authenticated instance CDP routes, a bounded fixed-upstream proxy,
+and a versioned desktop-role service backed by a non-root Darwin Chrome launcher.
+It **does not install a desktop LaunchAgent automatically**. Existing images and
+the spike's old GUI agent do not satisfy the new handshake. No guest or host
+TCC permissions are installed automatically.
+
+## Explicit admission
+
+An administrator must configure `macos_desktop_origin` to the public API origin,
+for example `https://hypeman.example`. Empty disables the routes. Configuration
+rejects credentials, paths (other than `/`), fragments, queries, and non-HTTP schemes.
+Discovery never trusts incoming Host or forwarding headers.
+
+The machine-image metadata must declare `desktop_agent_uid` as the provisioned
+non-root user's UID. Zero disables desktop operations. This capability is separate
+from `guest_agent` (system GuestService on 2222), and `SkipGuestAgent` disables both. The example LaunchAgent keeps `AbandonProcessGroup` false so launchd reaps managed Chrome when the agent exits; otherwise a restarted agent would find port 9222 occupied and refuse to start (no adoption is supported).
+The desktop role uses fixed vsock port 2223, not a client-selected endpoint. Its
+native Darwin listener admits host-CID connections only. A declaration is a
+capability claim, not a credential or proof of provisioning.
+
+All routes require JWT `instance:write`, including discovery and status, and use
+the same instance resolution/authorization model as exec. Read-only tokens are
+rejected before resolution or guest connection. This does not add multi-tenant
+ownership semantics beyond the existing API's scoped-token model.
+
+Only running macOS/VZ guests with an enabled desktop capability are admitted.
+Present Origin headers must exactly match the configured scheme/authority;
+multiple, null, cross-origin, and malformed origins are rejected. Native clients
+may omit Origin but must still authenticate; cookies/query tokens are not accepted
+as authentication. Encoded selectors/paths are not supported on these routes; use
+the canonical instance ID (ordinary names/aliases resolve normally).
+
+## Management and readiness
+
+- `GET /instances/{id}/cdp/status`: live desktop handshake plus derived
+ `desktop_ready`; this is not a persisted boot marker or VMM/system-agent health.
+- `POST /instances/{id}/cdp/start`: explicit fixed-policy browser launch. No body,
+ query, command, arguments, browser binary, profile, or user selection is accepted.
+- `GET /instances/{id}/cdp/json[/list|/version|/protocol]`: CDP discovery.
+- `GET /instances/{id}/cdp/devtools/{browser|page}/{id}`: debugger WebSocket.
+
+The protocol-v1 handshake must report Darwin/arm64, the declared non-root UID,
+the active console UID and GUI-session availability. Desktop readiness requires
+matching console/agent UIDs and a GUI session. Browser readiness additionally
+requires the backend to establish ownership of the managed browser; discovering
+an open debugging port is insufficient. Inconsistent or incompatible handshakes
+are rejected. This does not claim an unlocked screen or TCC permission.
+
+`lib/desktop.NewService` defines the guest role's status, launch and CDP boundary.
+`DarwinBackend` checks `/dev/console` ownership and `launchctl managername` (`Aqua`),
+uses the fixed `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`
+binary and private `~/Library/Application Support/Hypeman/Chrome` profile, refuses
+occupied unmanaged debugging ports, serializes launches and observes child exit.
+Profile directories must belong to the selected user, have no symlink components,
+and use private permissions for the Hypeman subtree. Chrome inherits only HOME
+and a fixed PATH, not arbitrary agent environment/credentials. It is headful.
+
+Readiness requires `lsof` to identify the fixed IPv4 loopback listener on the
+agent's own launched Chrome PID, followed by bounded validated Chrome discovery.
+The browser is not killed when the launch request, CDP socket or viewer closes.
+After a browser crash, an explicit start can launch a replacement. Agent restart
+adoption of a surviving Chrome is deliberately unsupported: the new agent refuses
+to attach to that unmanaged listener rather than guessing process ownership. No system shutdown, arbitrary exec, file access or OS input is
+available through this role. The system GuestService remains separate.
+
+Host probes are bounded (3 seconds for status, 20 for explicit launch, at most
+8KiB of JSON). The guest service bounds status/launch work and serializes launches.
+Both sides limit active CDP sessions to 16; host admission also counts management
+requests. Connections release slots on disconnect, without a guest shutdown call.
+Real process ownership, session detection, startup and recovery remain live
+validation gates; these OS interactions have not been exercised by this PR's tests.
+
+## CDP transport boundary
+
+The host `NewCDPProxy` is the only discovery-validation/public-URL rewriting
+boundary. The host-only guest listener uses `NewCDPForwarder` to carry Chrome's
+fixed-upstream discovery unchanged. Both retain body/path/query/encoding admission,
+header isolation and no-redirect policy; guest session/browser ownership is still
+checked before forwarding. The guest forwarder is not a public authenticated proxy.
+
+`NewCDPProxy` supports GET discovery and browser/page debugger WebSockets. Its
+caller supplies a fixed transport, strips the instance route prefix, and supplies
+a trusted instance-scoped `ws`/`wss` base. Clients cannot select the upstream host,
+port, other HTTP paths, query or body. Advertised debugger URLs must point to the
+fixed browser loopback endpoint (`127.0.0.1:9222`), then are rewritten to the
+canonical instance ID even when accessed by an alias.
+
+Only discovery/WebSocket handshake headers are forwarded. API credentials,
+cookies, origin, URL userinfo, and application/forwarding headers are stripped.
+Redirects, invalid/oversized discovery (2MiB), guest cookies, and unproxied DevTools
+frontend links are rejected/removed. Hosting a frontend is not included.
+
+CDP grants full browser authority; the HTTP path allowlist is not a command
+sandbox or a limit on websites visited. Worker-specific debugger paths and
+`/json/new`/close/activate remain unsupported; browser-level CDP can manage targets.
+There is no transparent browser auto-launch on discovery or WebSocket connection.
+
+## Provisioning (isolated guest only)
+
+Build `./lib/system/guest_agent` for Darwin/arm64 with CGO enabled. Provision the
+binary at `/Library/Application Support/Hypeman/guest-agent` in the image and
+Chrome at the fixed application path above. Installation in this protected path
+requires separately authorized guest provisioning, not a host-root installer.
+
+The example `docs/examples/macos-desktop-agent.plist` belongs in the selected
+user's `~/Library/LaunchAgents/`, not `/Library/LaunchDaemons/`. Bootstrap it in
+`gui/` as that user after login; it runs the same binary with `--role desktop`.
+Do not configure UserName=root or launch it in the system domain. Default/no-role
+invocation remains the existing system GuestService on 2222. Desktop role is
+unsupported off Darwin or without native CGO vsock support, and rejects root or
+set-ID execution. Only mark `desktop_agent_uid` on a stopped matching image after
+provisioning and handshake validation. System-agent readiness must not be used as
+proof that the desktop role is provisioned.
+
+No install/bootstrap command has been run by these tests. This example does not
+provide automatic login, credential storage, TCC grants, rekeying or fleet rollout.
+
+## Validation status
+
+Synthetic tests cover role/version/UID validation, readiness separation, fixed
+launch admission, bounded probes, trusted-origin validation, real JWT/write-scope
+middleware and instance resolution, unsupported/stopped/disabled admission before
+dial, alias-safe discovery rewriting, credential isolation, malicious/oversized
+responses, redirects, active-session caps, actual WebSocket round trips, reconnect
+and slot release. Darwin-specific tests inspect fixed launch arguments/environment,
+listener-ownership parsing and private-profile/symlink policy without launching
+Chrome. Focused tests run with the race detector.
+
+OS desktop capture/input, automatic TCC grants and a supported viewer API are not
+implemented in this checkpoint; they remain in this draft's display scope.
+
+Not yet proven: live Darwin backend operation, LaunchAgent provisioning/console selection,
+native desktop listener/API vsock routing, browser process ownership and crash
+recovery, agent restart recovery, capture/input/TCC, viewer attachment/detachment.
+The new routes are disabled by default and this feature remains a draft until
+those gates pass in an explicitly authorized isolated guest. No live deployment
+or guest provisioning is implied by these local tests.
diff --git a/lib/desktop/backend_darwin.go b/lib/desktop/backend_darwin.go
new file mode 100644
index 000000000..fc3813764
--- /dev/null
+++ b/lib/desktop/backend_darwin.go
@@ -0,0 +1,219 @@
+//go:build darwin
+
+package desktop
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "os/exec"
+ "os/user"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+)
+
+const chromeBinary = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
+
+// DarwinBackend runs in the selected user's Aqua LaunchAgent, never as root.
+// It only controls a Chrome process it started; restart adoption is intentionally
+// unsupported rather than attaching to an unrelated loopback debugging server.
+type DarwinBackend struct {
+ uid uint32
+ home string
+ mu sync.Mutex
+ process *exec.Cmd
+ client *http.Client
+}
+
+func NewDarwinBackend() (*DarwinBackend, error) {
+ if os.Getuid() == 0 || os.Geteuid() != os.Getuid() || os.Getegid() != os.Getgid() || runtime.GOARCH != "arm64" {
+ return nil, fmt.Errorf("desktop role requires a non-root Darwin/arm64 user")
+ }
+ account, err := user.Current()
+ if err != nil {
+ return nil, err
+ }
+ if !filepath.IsAbs(account.HomeDir) {
+ return nil, fmt.Errorf("desktop user's home must be absolute")
+ }
+ return &DarwinBackend{uid: uint32(os.Getuid()), home: account.HomeDir, client: &http.Client{Timeout: 2 * time.Second, Transport: &http.Transport{Proxy: nil, DisableKeepAlives: true}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil
+}
+
+func (b *DarwinBackend) Status(ctx context.Context) (Status, error) {
+ s := Status{Version: ProtocolVersion, OS: "darwin", Architecture: runtime.GOARCH, UID: b.uid}
+ console, err := os.Stat("/dev/console")
+ if err != nil {
+ return s, err
+ }
+ stat, ok := console.Sys().(*syscall.Stat_t)
+ if !ok {
+ return s, fmt.Errorf("console ownership unavailable")
+ }
+ s.ConsoleUID = stat.Uid
+ gui, err := exec.CommandContext(ctx, "/bin/launchctl", "managername").Output()
+ s.GUISession = err == nil && strings.TrimSpace(string(gui)) == "Aqua"
+ b.mu.Lock()
+ process := b.process
+ b.mu.Unlock()
+ if process == nil || !s.SessionReady() {
+ return s, nil
+ }
+ // Starting a process and observing any open port is not proof of ownership.
+ // lsof is restricted to our own launched PID and the fixed loopback listener.
+ owners, err := exec.CommandContext(ctx, "/usr/sbin/lsof", "-nP", "-a", "-p", strconv.Itoa(process.Process.Pid), "-iTCP:9222", "-sTCP:LISTEN", "-Fn").Output()
+ if err != nil || !ownsBrowserListener(string(owners)) {
+ return s, nil
+ }
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:9222/json/version", nil)
+ if err != nil {
+ return s, err
+ }
+ response, err := b.client.Do(request)
+ if err != nil {
+ return s, nil
+ }
+ defer response.Body.Close()
+ data, err := io.ReadAll(io.LimitReader(response.Body, maxStatusBytes+1))
+ if err != nil || len(data) > maxStatusBytes || response.StatusCode != 200 {
+ return s, nil
+ }
+ var version struct {
+ Browser string `json:"Browser"`
+ Debugger string `json:"webSocketDebuggerUrl"`
+ }
+ if json.Unmarshal(data, &version) != nil || !strings.HasPrefix(version.Browser, "Chrome/") || len(version.Browser) > 256 {
+ return s, nil
+ }
+ address, err := url.Parse(version.Debugger)
+ if err != nil || address.Scheme != "ws" || address.Host != "127.0.0.1:9222" || address.User != nil || address.RawQuery != "" || address.ForceQuery || address.Fragment != "" || address.RawPath != "" || !strings.HasPrefix(address.Path, "/devtools/browser/") || !debuggerPath(address.Path) {
+ return s, nil
+ }
+ b.mu.Lock()
+ stillOwned := b.process == process
+ b.mu.Unlock()
+ if stillOwned {
+ s.BrowserManaged = true
+ s.BrowserReady = true
+ s.Browser = version.Browser
+ }
+ return s, nil
+}
+
+func ownsBrowserListener(output string) bool {
+ for _, line := range strings.Split(output, "\n") {
+ if line == "n127.0.0.1:9222" {
+ return true
+ }
+ }
+ return false
+}
+
+func (b *DarwinBackend) profilePath() string {
+ return filepath.Join(b.home, "Library", "Application Support", "Hypeman", "Chrome")
+}
+
+func (b *DarwinBackend) prepareProfile() error {
+ // Refuse symlinks along the selected home-relative profile path. Existing
+ // ordinary Library directories need not have private mode; our subtree must.
+ current := b.home
+ for _, part := range []string{"Library", "Application Support", "Hypeman", "Chrome"} {
+ current = filepath.Join(current, part)
+ if err := os.Mkdir(current, 0700); err != nil && !os.IsExist(err) {
+ return err
+ }
+ info, err := os.Lstat(current)
+ if err != nil {
+ return err
+ }
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ if !info.IsDir() || !ok || stat.Uid != b.uid {
+ return fmt.Errorf("profile path is not a user-owned directory")
+ }
+ if (part == "Hypeman" || part == "Chrome") && info.Mode().Perm()&0077 != 0 {
+ return fmt.Errorf("managed profile directory must be private")
+ }
+ }
+ return nil
+}
+
+func (b *DarwinBackend) chromeCommand() *exec.Cmd {
+ command := exec.Command(chromeBinary, "--user-data-dir="+b.profilePath(), "--remote-debugging-address=127.0.0.1", "--remote-debugging-port=9222", "--no-first-run", "--no-default-browser-check")
+ // Do not pass agent/service credentials through an inherited environment.
+ command.Env = []string{"HOME=" + b.home, "PATH=/usr/bin:/bin:/usr/sbin:/sbin"}
+ return command
+}
+
+func (b *DarwinBackend) StartBrowser(ctx context.Context) (Status, error) {
+ s, err := b.Status(ctx)
+ if err != nil {
+ return s, err
+ }
+ if !s.SessionReady() {
+ return s, fmt.Errorf("selected Aqua session is not active")
+ }
+ if s.BrowserReady {
+ return s, nil
+ }
+ b.mu.Lock()
+ if b.process == nil {
+ if err := ctx.Err(); err != nil {
+ b.mu.Unlock()
+ return s, err
+ }
+ listener, err := net.Listen("tcp", "127.0.0.1:9222")
+ if err != nil {
+ b.mu.Unlock()
+ return s, fmt.Errorf("refusing occupied unmanaged browser port")
+ }
+ _ = listener.Close()
+ if err := b.prepareProfile(); err != nil {
+ b.mu.Unlock()
+ return s, err
+ }
+ command := b.chromeCommand()
+ // The browser lifetime is not tied to this HTTP request, viewer or socket.
+ if err := command.Start(); err != nil {
+ b.mu.Unlock()
+ return s, err
+ }
+ b.process = command
+ go func() {
+ _ = command.Wait()
+ b.mu.Lock()
+ if b.process == command {
+ b.process = nil
+ }
+ b.mu.Unlock()
+ }()
+ }
+ b.mu.Unlock()
+ ticker := time.NewTicker(100 * time.Millisecond)
+ defer ticker.Stop()
+ for {
+ s, err = b.Status(ctx)
+ if err != nil || s.BrowserReady {
+ return s, err
+ }
+ b.mu.Lock()
+ exited := b.process == nil
+ b.mu.Unlock()
+ if exited {
+ return s, fmt.Errorf("managed Chrome exited before readiness")
+ }
+ select {
+ case <-ctx.Done():
+ return s, ctx.Err()
+ case <-ticker.C:
+ }
+ }
+}
diff --git a/lib/desktop/backend_darwin_test.go b/lib/desktop/backend_darwin_test.go
new file mode 100644
index 000000000..9e7d7ba39
--- /dev/null
+++ b/lib/desktop/backend_darwin_test.go
@@ -0,0 +1,68 @@
+//go:build darwin
+
+package desktop
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestDarwinBrowserLaunchCommand(t *testing.T) {
+ t.Setenv("AWS_SECRET_ACCESS_KEY", "synthetic-secret-not-to-inherit")
+ b := &DarwinBackend{home: "/Users/test", uid: 501}
+ cmd := b.chromeCommand()
+ expected := []string{chromeBinary, "--user-data-dir=/Users/test/Library/Application Support/Hypeman/Chrome", "--remote-debugging-address=127.0.0.1", "--remote-debugging-port=9222", "--no-first-run", "--no-default-browser-check"}
+ if !reflect.DeepEqual(cmd.Args, expected) {
+ t.Fatal("unexpected Chrome command")
+ }
+ if !reflect.DeepEqual(cmd.Env, []string{"HOME=/Users/test", "PATH=/usr/bin:/bin:/usr/sbin:/sbin"}) {
+ t.Fatal("unexpected browser environment")
+ }
+ for _, arg := range cmd.Args {
+ if strings.Contains(arg, "headless") {
+ t.Fatal("headless browser configured")
+ }
+ }
+}
+
+func TestDarwinBrowserListenerOwnership(t *testing.T) {
+ if !ownsBrowserListener("p123\nf22\nn127.0.0.1:9222\n") {
+ t.Fatal("owned listener rejected")
+ }
+ for _, output := range []string{"", "p123\nn*:9222\n", "p123\nn127.0.0.1:9223\n", "p123\nn127.0.0.1:9222evil\n"} {
+ if ownsBrowserListener(output) {
+ t.Fatal("unproven listener accepted")
+ }
+ }
+}
+
+func TestDarwinPrivateProfile(t *testing.T) {
+ for _, kind := range []string{"private", "symlink", "shared"} {
+ t.Run(kind, func(t *testing.T) {
+ b := &DarwinBackend{uid: uint32(os.Getuid()), home: t.TempDir()}
+ if err := b.prepareProfile(); err != nil {
+ t.Fatal(err)
+ }
+ if kind == "symlink" {
+ if err := os.Remove(b.profilePath()); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(t.TempDir(), b.profilePath()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if kind == "shared" {
+ if err := os.Chmod(filepath.Dir(b.profilePath()), 0755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ err := b.prepareProfile()
+ if (err == nil) != (kind == "private") {
+ t.Fatalf("profile policy: %v", err)
+ }
+ })
+ }
+}
diff --git a/lib/desktop/cdp.go b/lib/desktop/cdp.go
new file mode 100644
index 000000000..ebab77bd6
--- /dev/null
+++ b/lib/desktop/cdp.go
@@ -0,0 +1,187 @@
+// Package desktop contains transport-independent desktop guest interfaces.
+package desktop
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "strings"
+)
+
+const maxDiscoveryBytes = 2 << 20
+
+// NewCDPProxy forwards discovery and WebSocket traffic to a fixed browser endpoint.
+// The caller owns instance authorization, origin checks, guest capability admission,
+// and the transport (normally a connection to the selected desktop agent over vsock).
+// publicBase must be a trusted, instance-scoped ws/wss URL, not a request Host header.
+// Requests must have the instance route prefix removed before reaching this handler.
+func NewCDPProxy(transport http.RoundTripper, publicBase string) (http.Handler, error) {
+ base, err := url.Parse(publicBase)
+ if err != nil || base == nil || (base.Scheme != "ws" && base.Scheme != "wss") || base.Host == "" || base.User != nil || base.RawQuery != "" || base.ForceQuery || base.Fragment != "" || base.RawPath != "" || strings.HasSuffix(base.Path, "/") {
+ return nil, fmt.Errorf("invalid public CDP base")
+ }
+ return newBrowserProxy(transport, base)
+}
+
+// NewCDPForwarder serves the guest's fixed loopback browser without interpreting
+// discovery. The authenticated host proxy owns public URL validation/rewriting.
+func NewCDPForwarder(transport http.RoundTripper) (http.Handler, error) {
+ return newBrowserProxy(transport, nil)
+}
+
+func newBrowserProxy(transport http.RoundTripper, publicBase *url.URL) (http.Handler, error) {
+ if transport == nil {
+ return nil, fmt.Errorf("CDP transport required")
+ }
+ proxy := &httputil.ReverseProxy{
+ Transport: transport,
+ Rewrite: func(p *httputil.ProxyRequest) {
+ p.Out.URL.Scheme = "http"
+ p.Out.URL.Host = "127.0.0.1:9222"
+ p.Out.Host = "127.0.0.1:9222"
+ p.Out.URL.RawQuery = ""
+ p.Out.URL.User = nil
+ p.Out.URL.Fragment = ""
+ p.Out.URL.Opaque = ""
+ // Do not forward API credentials or arbitrary application headers to the guest.
+ headers := make(http.Header)
+ for _, key := range []string{"Accept", "Connection", "Upgrade", "Sec-WebSocket-Key", "Sec-WebSocket-Version", "Sec-WebSocket-Protocol", "Sec-WebSocket-Extensions"} {
+ for _, value := range p.Out.Header.Values(key) {
+ headers.Add(key, value)
+ }
+ }
+ p.Out.Header = headers
+ },
+ ModifyResponse: func(r *http.Response) error {
+ r.Header.Del("Set-Cookie")
+ if r.StatusCode == http.StatusSwitchingProtocols && !debuggerPath(r.Request.URL.Path) {
+ return fmt.Errorf("unexpected discovery upgrade")
+ }
+ if r.StatusCode >= 300 && r.StatusCode < 400 {
+ return fmt.Errorf("CDP redirects unsupported")
+ }
+ if publicBase != nil && r.StatusCode == http.StatusOK && discoveryPath(r.Request.URL.Path) {
+ return rewriteDiscovery(r, publicBase.String())
+ }
+ return nil
+ },
+ ErrorHandler: func(w http.ResponseWriter, _ *http.Request, _ error) {
+ http.Error(w, "browser unavailable or invalid discovery response", http.StatusBadGateway)
+ },
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if err := ValidateCDPRequest(r); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ proxy.ServeHTTP(w, r)
+ }), nil
+}
+
+// ValidateCDPRequest allows API admission to run before dialing any guest service.
+func ValidateCDPRequest(r *http.Request) error {
+ if ValidateBodylessRequest(r) != nil || r.Method != http.MethodGet || !(discoveryPath(r.URL.Path) || debuggerPath(r.URL.Path)) {
+ return fmt.Errorf("unsupported CDP request")
+ }
+ if debuggerPath(r.URL.Path) && !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
+ return fmt.Errorf("WebSocket upgrade required")
+ }
+ return nil
+}
+
+// ValidateBodylessRequest rejects alternate URL encodings, query parameters and
+// request bodies for both desktop control and CDP routes.
+func ValidateBodylessRequest(r *http.Request) error {
+ if r.ContentLength != 0 || len(r.TransferEncoding) != 0 || r.URL.RawQuery != "" || r.URL.ForceQuery || r.URL.RawPath != "" {
+ return fmt.Errorf("invalid desktop request")
+ }
+ return nil
+}
+
+func discoveryPath(path string) bool {
+ switch path {
+ case "/json", "/json/list", "/json/version", "/json/protocol":
+ return true
+ }
+ return false
+}
+
+func debuggerPath(path string) bool {
+ parts := strings.Split(path, "/")
+ if len(parts) != 4 || parts[0] != "" || parts[1] != "devtools" || (parts[2] != "browser" && parts[2] != "page") || parts[3] == "" {
+ return false
+ }
+ for _, c := range parts[3] {
+ if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') {
+ return false
+ }
+ }
+ return true
+}
+
+func rewriteDiscovery(r *http.Response, base string) error {
+ defer r.Body.Close()
+ data, err := io.ReadAll(io.LimitReader(r.Body, maxDiscoveryBytes+1))
+ if err != nil {
+ return err
+ }
+ if len(data) > maxDiscoveryBytes {
+ return fmt.Errorf("CDP discovery too large")
+ }
+ var value any
+ if err := json.Unmarshal(data, &value); err != nil {
+ return err
+ }
+ rewrite := func(v any) error {
+ obj, ok := v.(map[string]any)
+ if !ok {
+ return fmt.Errorf("invalid CDP discovery object")
+ }
+ // These frontend links can embed an unproxied debugger address. Clients use the
+ // rewritten webSocketDebuggerUrl; hosting a DevTools frontend is separate.
+ delete(obj, "devtoolsFrontendUrl")
+ delete(obj, "devtoolsFrontendUrlCompat")
+ raw, exists := obj["webSocketDebuggerUrl"]
+ if !exists {
+ return nil
+ }
+ address, ok := raw.(string)
+ if !ok {
+ return fmt.Errorf("invalid debugger URL")
+ }
+ u, err := url.Parse(address)
+ if err != nil || u.Scheme != "ws" || u.Host != "127.0.0.1:9222" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" || !debuggerPath(u.Path) {
+ return fmt.Errorf("unexpected debugger URL")
+ }
+ obj["webSocketDebuggerUrl"] = base + u.Path
+ return nil
+ }
+ switch v := value.(type) {
+ case []any:
+ for _, obj := range v {
+ if err := rewrite(obj); err != nil {
+ return err
+ }
+ }
+ default:
+ if err := rewrite(v); err != nil {
+ return err
+ }
+ }
+ data, err = json.Marshal(value)
+ if err != nil {
+ return err
+ }
+ r.Body = io.NopCloser(bytes.NewReader(data))
+ r.ContentLength = int64(len(data))
+ r.Header.Del("Content-Length")
+ r.Header.Del("Content-Encoding")
+ r.Header.Del("ETag")
+ r.Header.Set("Content-Type", "application/json")
+ r.Header.Set("Cache-Control", "no-store")
+ return nil
+}
diff --git a/lib/desktop/cdp_forwarder_test.go b/lib/desktop/cdp_forwarder_test.go
new file mode 100644
index 000000000..24bc471f1
--- /dev/null
+++ b/lib/desktop/cdp_forwarder_test.go
@@ -0,0 +1,83 @@
+package desktop
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestCDPForwarderLeavesDiscoveryToHostBoundary(t *testing.T) {
+ for _, tc := range []struct {
+ name, payload string
+ status int
+ }{
+ {"valid", ` {"webSocketDebuggerUrl":"ws://127.0.0.1:9222/devtools/browser/id","devtoolsFrontendUrl":"unsafe"} `, 200},
+ {"foreign-host", `{"webSocketDebuggerUrl":"ws://evil.example/devtools/browser/id"}`, 502},
+ {"bad-json", `not json`, 502},
+ {"oversized", strings.Repeat(" ", maxDiscoveryBytes+1), 502},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ browser := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ for _, key := range []string{"Authorization", "Cookie", "Origin", "X-API-Key", "X-Forwarded-For"} {
+ if r.Header.Get(key) != "" {
+ t.Errorf("forwarded %s", key)
+ }
+ }
+ w.Header().Set("Set-Cookie", "private=yes")
+ io.WriteString(w, tc.payload)
+ }))
+ defer browser.Close()
+ forwarder, err := NewCDPForwarder(testTransport(t, browser))
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The guest forwards bytes, not a second JSON transform.
+ direct := httptest.NewRecorder()
+ forwarder.ServeHTTP(direct, httptest.NewRequest("GET", "http://guest/json/version", nil))
+ if direct.Code != 200 || direct.Body.String() != tc.payload || direct.Header().Get("Set-Cookie") != "" {
+ t.Fatal("guest modified discovery or leaked cookies")
+ }
+ guest := httptest.NewServer(forwarder)
+ defer guest.Close()
+ host, err := NewCDPProxy(testTransport(t, guest), "wss://api.example/instances/id/cdp")
+ if err != nil {
+ t.Fatal(err)
+ }
+ request := httptest.NewRequest("GET", "http://api.example/json/version", nil)
+ request.Header.Set("Authorization", "private")
+ request.Header.Set("Cookie", "private=yes")
+ response := httptest.NewRecorder()
+ host.ServeHTTP(response, request)
+ if response.Code != tc.status {
+ t.Fatalf("host status=%d want %d", response.Code, tc.status)
+ }
+ if tc.status == 200 && (!strings.Contains(response.Body.String(), "wss://api.example/instances/id/cdp/devtools/browser/id") || strings.Contains(response.Body.String(), "unsafe")) {
+ t.Fatal("host failed discovery transformation")
+ }
+ })
+ }
+}
+
+func TestCDPForwarderRejectsRequestsAndRedirects(t *testing.T) {
+ browser := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Location", "http://evil.example")
+ w.WriteHeader(302)
+ }))
+ defer browser.Close()
+ handler, err := NewCDPForwarder(testTransport(t, browser))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, tc := range []struct {
+ method, path string
+ status int
+ }{{"GET", "/json/new", 400}, {"POST", "/json/version", 400}, {"GET", "/json/version?url=evil", 400}, {"GET", "/json/version", 502}} {
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, httptest.NewRequest(tc.method, "http://guest"+tc.path, nil))
+ if response.Code != tc.status {
+ t.Fatalf("%s %s=%d", tc.method, tc.path, response.Code)
+ }
+ }
+}
diff --git a/lib/desktop/cdp_test.go b/lib/desktop/cdp_test.go
new file mode 100644
index 000000000..b97e0ff1e
--- /dev/null
+++ b/lib/desktop/cdp_test.go
@@ -0,0 +1,219 @@
+package desktop
+
+import (
+ "context"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gorilla/websocket"
+)
+
+func TestCDPDiscoveryAndHeaderIsolation(t *testing.T) {
+ for _, payload := range []string{
+ `{"webSocketDebuggerUrl":"ws://127.0.0.1:9222/devtools/browser/abc-123","devtoolsFrontendUrl":"unsafe"}`,
+ `[{"webSocketDebuggerUrl":"ws://127.0.0.1:9222/devtools/page/ABC123"}]`,
+ } {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Host != "127.0.0.1:9222" || r.URL.Path != "/json/version" {
+ t.Errorf("unexpected upstream: %s %s", r.Host, r.URL)
+ }
+ for _, key := range []string{"Authorization", "Cookie", "Origin", "Proxy-Authorization", "X-API-Key", "X-Forwarded-Host", "X-Forwarded-For"} {
+ if r.Header.Get(key) != "" {
+ t.Errorf("credential/header forwarded: %s", key)
+ }
+ }
+ w.Header().Set("Set-Cookie", "guest-secret=yes")
+ _, _ = io.WriteString(w, payload)
+ }))
+ transport := testTransport(t, upstream)
+ handler, err := NewCDPProxy(transport, "wss://api.example/instances/id/cdp")
+ if err != nil {
+ t.Fatal(err)
+ }
+ req := httptest.NewRequest("GET", "http://private:secret@attacker.example/json/version", nil)
+ for _, key := range []string{"Authorization", "Cookie", "Origin", "Proxy-Authorization", "X-API-Key", "X-Forwarded-Host", "X-Forwarded-For"} {
+ req.Header.Set(key, "secret")
+ }
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+ if w.Code != 200 || !strings.Contains(w.Body.String(), "wss://api.example/instances/id/cdp/devtools/") {
+ t.Fatalf("discovery: %d %s", w.Code, w.Body)
+ }
+ if strings.Contains(w.Body.String(), "unsafe") || w.Header().Get("Set-Cookie") != "" || w.Header().Get("Cache-Control") != "no-store" {
+ t.Fatal("unsafe discovery response")
+ }
+ upstream.Close()
+ }
+}
+
+func testTransport(t *testing.T, upstream *httptest.Server) *http.Transport {
+ t.Helper()
+ tr := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return (&net.Dialer{}).DialContext(ctx, "tcp", upstream.Listener.Addr().String())
+ }}
+ t.Cleanup(tr.CloseIdleConnections)
+ return tr
+}
+
+func TestCDPRejectsUnsupportedRequestsBeforeDial(t *testing.T) {
+ tr := &http.Transport{DialContext: func(context.Context, string, string) (net.Conn, error) {
+ t.Fatal("unsupported request reached upstream")
+ return nil, nil
+ }}
+ h, err := NewCDPProxy(tr, "ws://api.example/instances/id/cdp")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, tc := range []struct{ method, path string }{
+ {"POST", "/json/version"}, {"PUT", "/json/new"}, {"GET", "/json/new"}, {"GET", "/json/close/id"}, {"GET", "/status"}, {"GET", "/json/version?url=http://evil"}, {"GET", "/json%2fversion"}, {"GET", "/devtools/browser/../id"}, {"GET", "/devtools/browser/id"},
+ } {
+ t.Run(tc.method+tc.path, func(t *testing.T) {
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, httptest.NewRequest(tc.method, "http://api.example"+tc.path, nil))
+ if w.Code != 400 {
+ t.Fatalf("status %d", w.Code)
+ }
+ })
+ }
+}
+
+func TestCDPRejectsUntrustedDiscovery(t *testing.T) {
+ for _, payload := range []string{
+ `{"webSocketDebuggerUrl":"ws://evil.example/devtools/browser/id"}`,
+ `{"webSocketDebuggerUrl":"ws://127.0.0.1:9222/devtools/browser/id?token=secret"}`,
+ `{"webSocketDebuggerUrl":"ws://user@127.0.0.1:9222/devtools/browser/id"}`,
+ `{"webSocketDebuggerUrl":"ws://127.0.0.1:9222/devtools/browser/id#fragment"}`,
+ `{"webSocketDebuggerUrl":42}`, `null`, `[42]`, `not json`, strings.Repeat(" ", maxDiscoveryBytes+1),
+ } {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, payload) }))
+ h, err := NewCDPProxy(testTransport(t, upstream), "ws://api.example/instances/id/cdp")
+ if err != nil {
+ t.Fatal(err)
+ }
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, httptest.NewRequest("GET", "http://api.example/json/version", nil))
+ if w.Code != 502 {
+ t.Fatalf("invalid discovery accepted: %d", w.Code)
+ }
+ upstream.Close()
+ }
+}
+
+func TestCDPRejectsRedirect(t *testing.T) {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "http://evil.example", 302) }))
+ defer upstream.Close()
+ h, err := NewCDPProxy(testTransport(t, upstream), "ws://api.example/instances/id/cdp")
+ if err != nil {
+ t.Fatal(err)
+ }
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, httptest.NewRequest("GET", "http://api.example/json/version", nil))
+ if w.Code != 502 {
+ t.Fatalf("redirect status %d", w.Code)
+ }
+}
+
+func TestCDPRejectsDiscoveryUpgrade(t *testing.T) {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Connection", "Upgrade")
+ w.Header().Set("Upgrade", "websocket")
+ w.WriteHeader(http.StatusSwitchingProtocols)
+ }))
+ defer upstream.Close()
+ h, err := NewCDPProxy(testTransport(t, upstream), "ws://api.example/instances/id/cdp")
+ if err != nil {
+ t.Fatal(err)
+ }
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, httptest.NewRequest("GET", "http://api.example/json/version", nil))
+ if w.Code != 502 {
+ t.Fatalf("unexpected discovery upgrade: %d", w.Code)
+ }
+}
+
+func TestCDPWebSocketRoundTrip(t *testing.T) {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/devtools/browser/abc-123" || r.Header.Get("Authorization") != "" {
+ t.Error("invalid WebSocket upstream request")
+ }
+ conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ defer conn.Close()
+ kind, data, err := conn.ReadMessage()
+ if err == nil {
+ _ = conn.WriteMessage(kind, data)
+ }
+ }))
+ defer upstream.Close()
+ forwarder, err := NewCDPForwarder(testTransport(t, upstream))
+ if err != nil {
+ t.Fatal(err)
+ }
+ guest := httptest.NewServer(forwarder)
+ defer guest.Close()
+ h, err := NewCDPProxy(testTransport(t, guest), "ws://api.example/instances/id/cdp")
+ if err != nil {
+ t.Fatal(err)
+ }
+ proxy := httptest.NewServer(h)
+ defer proxy.Close()
+ headers := http.Header{"Authorization": []string{"Bearer private"}}
+ conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(proxy.URL, "http")+"/devtools/browser/abc-123", headers)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer conn.Close()
+ _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
+ if err := conn.WriteMessage(websocket.TextMessage, []byte(`{"id":1,"method":"Browser.getVersion"}`)); err != nil {
+ t.Fatal(err)
+ }
+ _, data, err := conn.ReadMessage()
+ if err != nil || string(data) != `{"id":1,"method":"Browser.getVersion"}` {
+ t.Fatalf("WebSocket round trip failed: %s %v", data, err)
+ }
+}
+
+func TestCDPRejectsRequestBody(t *testing.T) {
+ tr := &http.Transport{DialContext: func(context.Context, string, string) (net.Conn, error) {
+ t.Fatal("request body reached upstream")
+ return nil, nil
+ }}
+ h, err := NewCDPProxy(tr, "ws://api.example/instances/id/cdp")
+ if err != nil {
+ t.Fatal(err)
+ }
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, httptest.NewRequest("GET", "http://api.example/json/version", strings.NewReader("private body")))
+ if w.Code != 400 {
+ t.Fatalf("status %d", w.Code)
+ }
+}
+
+func TestCDPConfigurationAndTargetPaths(t *testing.T) {
+ for _, base := range []string{"http://api.example/cdp", "ws://user@api.example/cdp", "ws://api.example/cdp?token=secret", "ws://api.example/cdp?", "ws://api.example/cdp#fragment", "ws://api.example/cdp/", "ws:///cdp"} {
+ if _, err := NewCDPProxy(http.DefaultTransport, base); err == nil {
+ t.Fatalf("accepted base %s", base)
+ }
+ }
+ if _, err := NewCDPProxy(nil, "ws://api.example/cdp"); err == nil {
+ t.Fatal("nil transport accepted")
+ }
+ for _, path := range []string{"/devtools/browser/abc-123", "/devtools/page/ABC123"} {
+ if !debuggerPath(path) {
+ t.Fatalf("valid path rejected: %s", path)
+ }
+ }
+ for _, path := range []string{"/devtools/browser/", "/devtools/browser/..", "/devtools/page/id/extra", "/devtools/worker/id", "/devtools/page/id.token"} {
+ if debuggerPath(path) {
+ t.Fatalf("unsafe path accepted: %s", path)
+ }
+ }
+}
diff --git a/lib/desktop/origin.go b/lib/desktop/origin.go
new file mode 100644
index 000000000..5a742983e
--- /dev/null
+++ b/lib/desktop/origin.go
@@ -0,0 +1,34 @@
+package desktop
+
+import (
+ "fmt"
+ "net/url"
+ "strings"
+)
+
+// ParseOrigin validates an administrator-provided API origin. Forwarded headers
+// and request Host headers are deliberately not involved in URL generation.
+func ParseOrigin(raw string) (*url.URL, error) {
+ u, err := url.Parse(raw)
+ if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Hostname() == "" || u.User != nil || u.Opaque != "" || (u.Path != "" && u.Path != "/") || u.RawPath != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" {
+ return nil, fmt.Errorf("desktop API origin must be an http(s) origin without credentials, query or path")
+ }
+ u.Path = ""
+ u.Host = canonicalOriginHost(u.Scheme, u.Host)
+ return u, nil
+}
+
+// canonicalOriginHost lowercases the host and drops the scheme's default port,
+// so https://host:443 and the browser's https://host compare equal.
+func canonicalOriginHost(scheme, host string) string {
+ host = strings.ToLower(host)
+ if (scheme == "https" && strings.HasSuffix(host, ":443")) || (scheme == "http" && strings.HasSuffix(host, ":80")) {
+ host = host[:strings.LastIndex(host, ":")]
+ }
+ return host
+}
+
+func SameOrigin(raw string, expected *url.URL) bool {
+ u, err := url.Parse(raw)
+ return err == nil && u.Scheme == expected.Scheme && canonicalOriginHost(u.Scheme, u.Host) == canonicalOriginHost(expected.Scheme, expected.Host) && u.User == nil && u.Path == "" && u.RawQuery == "" && !u.ForceQuery && u.Fragment == "" && u.Opaque == ""
+}
diff --git a/lib/desktop/origin_test.go b/lib/desktop/origin_test.go
new file mode 100644
index 000000000..8a84c7d0c
--- /dev/null
+++ b/lib/desktop/origin_test.go
@@ -0,0 +1,21 @@
+package desktop
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestSameOriginNormalizesDefaultPorts(t *testing.T) {
+ expected, err := ParseOrigin("https://api.example.test:443")
+ require.NoError(t, err)
+ require.True(t, SameOrigin("https://api.example.test", expected))
+ require.True(t, SameOrigin("https://API.example.test:443", expected))
+ require.False(t, SameOrigin("https://api.example.test:8443", expected))
+ require.False(t, SameOrigin("http://api.example.test", expected))
+
+ plain, err := ParseOrigin("http://127.0.0.1:80")
+ require.NoError(t, err)
+ require.True(t, SameOrigin("http://127.0.0.1", plain))
+ require.False(t, SameOrigin("https://127.0.0.1", plain))
+}
diff --git a/lib/desktop/service.go b/lib/desktop/service.go
new file mode 100644
index 000000000..693032a08
--- /dev/null
+++ b/lib/desktop/service.go
@@ -0,0 +1,187 @@
+package desktop
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+)
+
+const (
+ ProtocolVersion = 1
+ MaxSessions = 16
+ AgentPort uint32 = 2223
+ maxStatusBytes = 8 << 10
+)
+
+// Status reports the selected user session, not VMM or root-service readiness.
+// BrowserManaged means the desktop agent established ownership of this browser.
+// Merely finding an open loopback debugging port must not set BrowserManaged.
+type Status struct {
+ Version int `json:"version"`
+ OS string `json:"os"`
+ Architecture string `json:"architecture"`
+ UID uint32 `json:"uid"`
+ ConsoleUID uint32 `json:"console_uid"`
+ GUISession bool `json:"gui_session"`
+ BrowserManaged bool `json:"browser_managed"`
+ BrowserReady bool `json:"browser_ready"`
+ Browser string `json:"browser,omitempty"`
+}
+
+func (s Status) ValidateRole(uid uint32) error {
+ if s.Version != ProtocolVersion || s.OS != "darwin" || s.Architecture != "arm64" || uid == 0 || s.UID != uid {
+ return fmt.Errorf("incompatible desktop agent or user")
+ }
+ if s.BrowserReady && (!s.BrowserManaged || !s.SessionReady()) {
+ return fmt.Errorf("inconsistent browser readiness")
+ }
+ return nil
+}
+
+func (s Status) SessionReady() bool { return s.UID != 0 && s.ConsoleUID == s.UID && s.GUISession }
+
+// Backend must implement fixed browser provisioning, not client-selected commands,
+// paths, arguments or profiles. System shutdown and arbitrary exec are not here.
+type Backend interface {
+ Status(context.Context) (Status, error)
+ StartBrowser(context.Context) (Status, error)
+}
+
+// NewService is the guest desktop role's HTTP interface. Its listener must admit
+// host-CID connections only; it must never be published on a guest TCP interface.
+// uid selects the provisioned non-root user and does not come from the request.
+func NewService(backend Backend, uid uint32, cdp http.Handler) (http.Handler, error) {
+ if backend == nil || uid == 0 || cdp == nil {
+ return nil, fmt.Errorf("desktop backend, non-root user and CDP handler required")
+ }
+ launching := make(chan struct{}, 1)
+ sessions := make(chan struct{}, MaxSessions)
+ mux := http.NewServeMux()
+ respond := func(w http.ResponseWriter, s Status) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = json.NewEncoder(w).Encode(s)
+ }
+ mux.HandleFunc("GET /status", func(w http.ResponseWriter, r *http.Request) {
+ if ValidateBodylessRequest(r) != nil {
+ http.Error(w, "invalid status request", 400)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
+ defer cancel()
+ s, err := backend.Status(ctx)
+ if err != nil || s.ValidateRole(uid) != nil {
+ http.Error(w, "desktop status unavailable", 503)
+ return
+ }
+ respond(w, s)
+ })
+ mux.HandleFunc("POST /browser/start", func(w http.ResponseWriter, r *http.Request) {
+ if ValidateBodylessRequest(r) != nil {
+ http.Error(w, "browser launch arguments unsupported", 400)
+ return
+ }
+ select {
+ case launching <- struct{}{}:
+ defer func() { <-launching }()
+ default:
+ http.Error(w, "browser launch in progress", 409)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ s, err := backend.Status(ctx)
+ if err != nil || s.ValidateRole(uid) != nil {
+ http.Error(w, "desktop status unavailable", 503)
+ return
+ }
+ if !s.SessionReady() {
+ http.Error(w, "selected desktop session not ready", 409)
+ return
+ }
+ if !s.BrowserReady {
+ s, err = backend.StartBrowser(ctx)
+ }
+ if err != nil || s.ValidateRole(uid) != nil || !s.BrowserReady {
+ http.Error(w, "managed browser not ready", 503)
+ return
+ }
+ respond(w, s)
+ })
+ mux.Handle("GET /", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if err := ValidateCDPRequest(r); err != nil {
+ http.Error(w, err.Error(), 400)
+ return
+ }
+ select {
+ case sessions <- struct{}{}:
+ defer func() { <-sessions }()
+ default:
+ http.Error(w, "desktop session limit reached", 429)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
+ s, err := backend.Status(ctx)
+ cancel()
+ if err != nil || s.ValidateRole(uid) != nil || !s.BrowserReady {
+ http.Error(w, "managed browser not ready", 409)
+ return
+ }
+ cdp.ServeHTTP(w, r)
+ }))
+ return mux, nil
+}
+
+// Probe validates a bounded desktop role handshake over the supplied transport.
+// The request carries no API headers or caller-selected endpoint.
+func Probe(ctx context.Context, transport http.RoundTripper, uid uint32, start bool) (Status, error) {
+ if transport == nil {
+ return Status{}, errors.New("desktop transport required")
+ }
+ method, path := http.MethodGet, "/status"
+ if start {
+ method, path = http.MethodPost, "/browser/start"
+ }
+ request, err := http.NewRequestWithContext(ctx, method, "http://desktop"+path, nil)
+ if err != nil {
+ return Status{}, err
+ }
+ response, err := transport.RoundTrip(request)
+ if err != nil {
+ return Status{}, err
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusOK {
+ return Status{}, &AgentStatusError{StatusCode: response.StatusCode}
+ }
+ data, err := io.ReadAll(io.LimitReader(response.Body, maxStatusBytes+1))
+ if err != nil {
+ return Status{}, err
+ }
+ if len(data) > maxStatusBytes {
+ return Status{}, errors.New("desktop status too large")
+ }
+ var s Status
+ if err = json.Unmarshal(data, &s); err != nil {
+ return Status{}, err
+ }
+ if err = s.ValidateRole(uid); err != nil {
+ return Status{}, err
+ }
+ if start && !s.BrowserReady {
+ return Status{}, errors.New("browser launch did not establish readiness")
+ }
+ return s, nil
+}
+
+// AgentStatusError preserves the desktop agent's HTTP status so callers can map
+// state conflicts (for example, a launch already in progress) without guessing.
+type AgentStatusError struct{ StatusCode int }
+
+func (e *AgentStatusError) Error() string {
+ return fmt.Sprintf("desktop agent returned %d", e.StatusCode)
+}
diff --git a/lib/desktop/service_test.go b/lib/desktop/service_test.go
new file mode 100644
index 000000000..3109cdce9
--- /dev/null
+++ b/lib/desktop/service_test.go
@@ -0,0 +1,209 @@
+package desktop
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+type fixtureBackend struct {
+ state Status
+ starts atomic.Int32
+}
+
+func (b *fixtureBackend) Status(context.Context) (Status, error) { return b.state, nil }
+func (b *fixtureBackend) StartBrowser(context.Context) (Status, error) {
+ b.starts.Add(1)
+ s := b.state
+ s.BrowserManaged = true
+ s.BrowserReady = true
+ s.Browser = "Chrome/test"
+ return s, nil
+}
+func readyStatus() Status {
+ return Status{Version: ProtocolVersion, OS: "darwin", Architecture: "arm64", UID: 501, ConsoleUID: 501, GUISession: true}
+}
+
+func TestDesktopRoleReadiness(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ change func(*Status)
+ valid, ready bool
+ }{
+ {"ready", func(*Status) {}, true, true},
+ {"logged out", func(s *Status) { s.ConsoleUID = 0 }, true, false},
+ {"no GUI", func(s *Status) { s.GUISession = false }, true, false},
+ {"wrong user", func(s *Status) { s.UID = 502 }, false, false},
+ {"root", func(s *Status) { s.UID = 0 }, false, false},
+ {"old version", func(s *Status) { s.Version = 0 }, false, false},
+ {"unmanaged browser", func(s *Status) { s.BrowserReady = true }, false, true},
+ {"browser without session", func(s *Status) { s.BrowserManaged = true; s.BrowserReady = true; s.GUISession = false }, false, false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ s := readyStatus()
+ tc.change(&s)
+ if (s.ValidateRole(501) == nil) != tc.valid {
+ t.Fatal("role validation")
+ }
+ if tc.valid && s.SessionReady() != tc.ready {
+ t.Fatal("session readiness")
+ }
+ })
+ }
+}
+
+func TestDesktopServiceLaunchPolicy(t *testing.T) {
+ for _, tc := range []struct {
+ name, path, method, body string
+ loggedIn bool
+ code, starts int
+ }{
+ {"status", "/status", "GET", "", true, 200, 0},
+ {"launch", "/browser/start", "POST", "", true, 200, 1},
+ {"logout", "/browser/start", "POST", "", false, 409, 0},
+ {"arguments", "/browser/start", "POST", "{\"args\":[\"--dangerous\"]}", true, 400, 0},
+ {"query", "/browser/start?profile=other", "POST", "", true, 400, 0},
+ {"shutdown unavailable", "/shutdown", "POST", "", true, 405, 0},
+ {"CDP before browser ready", "/json/version", "GET", "", true, 409, 0},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ b := &fixtureBackend{state: readyStatus()}
+ b.state.GUISession = tc.loggedIn
+ h, err := NewService(b, 501, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("unexpected CDP") }))
+ if err != nil {
+ t.Fatal(err)
+ }
+ r := httptest.NewRequest(tc.method, "http://guest"+tc.path, strings.NewReader(tc.body))
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != tc.code || int(b.starts.Load()) != tc.starts {
+ t.Fatalf("status=%d starts=%d", w.Code, b.starts.Load())
+ }
+ })
+ }
+}
+
+func TestDesktopProbeBoundsAndIdentity(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ payload string
+ valid bool
+ }{
+ {"valid", statusJSON(readyStatus()), true},
+ {"wrong UID", strings.Replace(statusJSON(readyStatus()), `"uid":501`, `"uid":502`, 1), false},
+ {"empty", "{}", false}, {"oversized", strings.Repeat(" ", maxStatusBytes+1), false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "" || r.URL.Path != "/status" {
+ t.Error("unexpected probe")
+ }
+ _, _ = w.Write([]byte(tc.payload))
+ }))
+ defer server.Close()
+ _, err := Probe(context.Background(), testTransport(t, server), 501, false)
+ if (err == nil) != tc.valid {
+ t.Fatalf("probe: %v", err)
+ }
+ })
+ }
+}
+func statusJSON(s Status) string { b, _ := json.Marshal(s); return string(b) }
+
+type blockedLaunchBackend struct {
+ fixtureBackend
+ entered, release chan struct{}
+}
+
+func (b *blockedLaunchBackend) StartBrowser(ctx context.Context) (Status, error) {
+ close(b.entered)
+ select {
+ case <-b.release:
+ return b.fixtureBackend.StartBrowser(ctx)
+ case <-ctx.Done():
+ return Status{}, ctx.Err()
+ }
+}
+func TestDesktopSerializesLaunches(t *testing.T) {
+ b := &blockedLaunchBackend{fixtureBackend: fixtureBackend{state: readyStatus()}, entered: make(chan struct{}), release: make(chan struct{})}
+ h, err := NewService(b, 501, http.NotFoundHandler())
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ first := httptest.NewRecorder()
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ h.ServeHTTP(first, httptest.NewRequest("POST", "http://guest/browser/start", nil).WithContext(ctx))
+ }()
+ select {
+ case <-b.entered:
+ case <-ctx.Done():
+ t.Fatal("first launch did not start")
+ }
+ second := httptest.NewRecorder()
+ h.ServeHTTP(second, httptest.NewRequest("POST", "http://guest/browser/start", nil))
+ if second.Code != 409 {
+ t.Errorf("concurrent launch status: %d", second.Code)
+ }
+ close(b.release)
+ select {
+ case <-done:
+ case <-ctx.Done():
+ t.Fatal("launch did not finish")
+ }
+ if first.Code != 200 || b.starts.Load() != 1 {
+ t.Fatalf("first launch status=%d starts=%d", first.Code, b.starts.Load())
+ }
+}
+
+func TestDesktopProbeCancellation(t *testing.T) {
+ entered := make(chan struct{})
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(entered); <-r.Context().Done() }))
+ defer server.Close()
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ done := make(chan error, 1)
+ go func() { _, err := Probe(ctx, testTransport(t, server), 501, false); done <- err }()
+ select {
+ case <-entered:
+ case <-ctx.Done():
+ t.Fatal("probe did not start")
+ }
+ cancel()
+ select {
+ case err := <-done:
+ if err == nil {
+ t.Fatal("canceled probe succeeded")
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("probe did not cancel")
+ }
+}
+
+func TestDesktopOrigin(t *testing.T) {
+ u, err := ParseOrigin("https://API.example:443/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !SameOrigin("https://api.example:443", u) {
+ t.Fatal("same origin rejected")
+ }
+ for _, bad := range []string{"null", "https://evil.example", "http://api.example:443", "https://api.example:443/path", "https://user@api.example:443", "https://api.example:443?", "https://api.example:443#x"} {
+ if SameOrigin(bad, u) {
+ t.Fatalf("accepted origin %s", bad)
+ }
+ }
+ for _, bad := range []string{"ws://api.example", "https://user@api.example", "https://api.example/path", "https://api.example?", "https://api.example#x"} {
+ if _, err := ParseOrigin(bad); err == nil {
+ t.Fatalf("accepted config %s", bad)
+ }
+ }
+}
diff --git a/lib/images/types.go b/lib/images/types.go
index f49437ef9..8093600e6 100644
--- a/lib/images/types.go
+++ b/lib/images/types.go
@@ -39,6 +39,9 @@ type MacOSImage struct {
// GuestAgent declares a provisioned system GuestService on vsock 2222.
// Readiness is probed separately; old templates remain unmanaged.
GuestAgent bool `json:"guest_agent,omitempty"`
+ // DesktopAgentUID declares the non-root desktop role provisioned on vsock2223.
+ // Zero keeps desktop/browser operations disabled; readiness is a live handshake.
+ DesktopAgentUID uint32 `json:"desktop_agent_uid,omitempty"`
}
// Validate checks the platform fields every macOS bundle must carry, whether it
diff --git a/lib/scopes/routes_test.go b/lib/scopes/routes_test.go
index f78bf3f89..40ccf4129 100644
--- a/lib/scopes/routes_test.go
+++ b/lib/scopes/routes_test.go
@@ -20,9 +20,11 @@ func TestAllRoutesHaveScopes(t *testing.T) {
r := chi.NewRouter()
noop := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
- // WebSocket endpoints registered outside OpenAPI (same as cmd/api/main.go)
+ // Custom endpoints registered outside OpenAPI (same as cmd/api/main.go)
r.Get("/instances/{id}/exec", noop)
r.Get("/instances/{id}/cp", noop)
+ r.Get("/instances/{id}/cdp/*", noop)
+ r.Post("/instances/{id}/cdp/start", noop)
// Public/unauthenticated endpoints
r.Get("/spec.yaml", noop)
@@ -88,6 +90,8 @@ func TestRouteScopesHaveNoStaleEntries(t *testing.T) {
// Mirror production routes
r.Get("/instances/{id}/exec", noop)
r.Get("/instances/{id}/cp", noop)
+ r.Get("/instances/{id}/cdp/*", noop)
+ r.Post("/instances/{id}/cdp/start", noop)
r.Get("/spec.yaml", noop)
r.Get("/spec.json", noop)
r.Get("/swagger", noop)
diff --git a/lib/scopes/scopes.go b/lib/scopes/scopes.go
index 9d078c1fb..0ac71e4f0 100644
--- a/lib/scopes/scopes.go
+++ b/lib/scopes/scopes.go
@@ -194,8 +194,10 @@ var PublicRoutes = map[string]bool{
// middleware directly (not via the Middleware() scope checker). These are
// outside the OpenAPI router group (e.g. WebSocket endpoints).
var DirectScopeRoutes = map[string]Scope{
- "GET /instances/{id}/exec": InstanceWrite,
- "GET /instances/{id}/cp": InstanceWrite,
+ "GET /instances/{id}/exec": InstanceWrite,
+ "GET /instances/{id}/cp": InstanceWrite,
+ "GET /instances/{id}/cdp/*": InstanceWrite,
+ "POST /instances/{id}/cdp/start": InstanceWrite,
}
// RouteScopes maps "METHOD /path-pattern" to the required scope.
diff --git a/lib/system/guest_agent/desktop_darwin.go b/lib/system/guest_agent/desktop_darwin.go
new file mode 100644
index 000000000..1905d5023
--- /dev/null
+++ b/lib/system/guest_agent/desktop_darwin.go
@@ -0,0 +1,36 @@
+//go:build darwin
+
+package main
+
+import (
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/kernel/hypeman/lib/desktop"
+)
+
+func runDesktopAgent() error {
+ backend, err := desktop.NewDarwinBackend()
+ if err != nil {
+ return err
+ }
+ transport := &http.Transport{Proxy: nil, DisableKeepAlives: true, ResponseHeaderTimeout: 3 * time.Second}
+ defer transport.CloseIdleConnections()
+ proxy, err := desktop.NewCDPForwarder(transport)
+ if err != nil {
+ return err
+ }
+ service, err := desktop.NewService(backend, uint32(os.Getuid()), proxy)
+ if err != nil {
+ return err
+ }
+ // Reuse the native host-CID-only listener, not a guest TCP listener or root RPC.
+ listener, err := listenVsock(desktop.AgentPort)
+ if err != nil {
+ return err
+ }
+ defer listener.Close()
+ server := &http.Server{Handler: service, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 32 << 10}
+ return server.Serve(listener)
+}
diff --git a/lib/system/guest_agent/desktop_other.go b/lib/system/guest_agent/desktop_other.go
new file mode 100644
index 000000000..010735f74
--- /dev/null
+++ b/lib/system/guest_agent/desktop_other.go
@@ -0,0 +1,7 @@
+//go:build !darwin
+
+package main
+
+import "fmt"
+
+func runDesktopAgent() error { return fmt.Errorf("desktop HTTP role is only supported on Darwin") }
diff --git a/lib/system/guest_agent/main.go b/lib/system/guest_agent/main.go
index 5f8159fc6..b08644b09 100644
--- a/lib/system/guest_agent/main.go
+++ b/lib/system/guest_agent/main.go
@@ -1,6 +1,7 @@
package main
import (
+ "flag"
"fmt"
"log"
"net"
@@ -27,6 +28,18 @@ type guestServer struct {
}
func main() {
+ role := flag.String("role", "system", "agent role: system or desktop (Darwin Aqua user only)")
+ flag.Parse()
+ switch *role {
+ case "desktop":
+ if err := runDesktopAgent(); err != nil {
+ log.Fatalf("[desktop-agent] %v", err)
+ }
+ return
+ case "system":
+ default:
+ log.Fatalf("unsupported agent role %q", *role)
+ }
// Listen on vsock port 2222 with retries
var l net.Listener
var err error