1package desktop
2
3import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "net"
12 neturl "net/url"
13 "os"
14 "path/filepath"
15 "sync"
16 "sync/atomic"
17 "syscall"
18 "time"
19
20 "github.com/bnema/dumber/internal/application/port"
21 "github.com/bnema/dumber/internal/infrastructure/process"
22 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
23 "github.com/bnema/dumber/internal/logging"
24 "github.com/rs/zerolog"
25)
26
27const browserLaunchSocketName = "browser-launch.sock"
28
29// Local browser-launch handoff needs enough headroom for a busy browser
30// process to accept and acknowledge the request before callers classify the
31// handoff as ambiguous.
32const browserLaunchIOTimeout = 250 * time.Millisecond
33
34const browserLaunchDirPerm = 0o700
35
36// ErrBrowserLaunchRelayUnconfirmed reports that the relay accepted a launch
37// request but the caller did not receive a confirmation response in time.
38var ErrBrowserLaunchRelayUnconfirmed = errors.New("browser launch relay did not confirm delivery")
39
40type browserLaunchRelay struct {
41 ipc runtimeprofile.IPCPaths
42 prepareMu sync.Mutex
43 prepared *browserLaunchRelayListener
44 lease io.Closer
45 serving bool
46 // admission is the shared admission/work-lease boundary: a lease is
47 // acquired before sending Accepted and released when dispatch settles.
48 // Shutdown closes it so losing requests get an error response instead
49 // of an acknowledgement. Initialized eagerly; never nil.
50 admission *process.AdmissionGate
51}
52
53type browserLaunchRequest struct {
54 RequestID string `json:"request_id,omitempty"`
55 URL string `json:"url"`
56 Instance string `json:"instance,omitempty"`
57 Action browserLaunchAction `json:"action,omitempty"`
58}
59
60type browserLaunchAction string
61
62const (
63 browserLaunchActionOpenExternalURL browserLaunchAction = "open-external-url"
64 browserLaunchActionOpenFreshWindow browserLaunchAction = "open-fresh-window"
65 browserLaunchActionOpenInstance browserLaunchAction = "open-instance"
66 // browserLaunchActionCloseAllWindows is a diagnostic-only action for the
67 // residency harness: it closes every user window through the standard
68 // removal path. It is honored only when the OWNING process sets
69 // DUMBER_DIAGNOSTIC_WINDOW_CLOSE=1; otherwise it is rejected before
70 // acknowledgement. It is never a general unauthenticated shutdown
71 // command: the relay socket itself remains owner-only (0700, uid).
72 browserLaunchActionCloseAllWindows browserLaunchAction = "close-all-windows"
73)
74
75// diagnosticWindowCloseEnvVar gates the diagnostic close action on the
76// owning (server) process environment.
77const diagnosticWindowCloseEnvVar = "DUMBER_DIAGNOSTIC_WINDOW_CLOSE"
78
79// windowCloseOpener is implemented by openers that support the diagnostic
80// close action. It is matched structurally so the port interface is
81// unchanged.
82type windowCloseOpener interface {
83 CloseAllWindows(ctx context.Context) error
84}
85
86type browserLaunchResponse struct {
87 RequestID string `json:"request_id,omitempty"`
88 Accepted bool `json:"accepted,omitempty"`
89 Error string `json:"error,omitempty"`
90}
91
92type browserLaunchRelayListener struct {
93 listener *net.UnixListener
94 socketPath string
95 socketInfo os.FileInfo
96 admission *process.AdmissionGate
97 seenMu sync.Mutex
98 seen map[string]struct{}
99 once sync.Once
100 err error
101 closed atomic.Bool
102}
103
104var newBrowserLaunchRequestID = func() string {
105 var buf [8]byte
106 if _, err := rand.Read(buf[:]); err == nil {
107 return "blr-" + hex.EncodeToString(buf[:])
108 }
109 return fmt.Sprintf("blr-%d", time.Now().UnixNano())
110}
111
112func NewBrowserLaunchRelay(ipc runtimeprofile.IPCPaths) port.BrowserLaunchRelay {
113 return &browserLaunchRelay{ipc: ipc, admission: process.NewAdmissionGate()}
114}
115
116// AdmissionGateProvider exposes the relay's shared admission boundary
117// without extending the port interface or regenerating mocks.
118type AdmissionGateProvider interface {
119 AdmissionGate() *process.AdmissionGate
120}
121
122// Compile-time check: the shared gate implements the application boundary.
123var _ port.AdmissionBoundary = (*process.AdmissionGate)(nil)
124
125// AdmissionGate returns the shared admission/work-lease boundary.
126func (r *browserLaunchRelay) AdmissionGate() *process.AdmissionGate {
127 if r == nil || r.admission == nil {
128 return process.NewAdmissionGate()
129 }
130 return r.admission
131}
132
133func (r *browserLaunchRelay) DeliverOpenExternalURL(ctx context.Context, url string) (bool, error) {
134 return r.deliver(ctx, url, "", "")
135}
136
137func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) {
138 return r.deliver(ctx, url, "", browserLaunchActionOpenFreshWindow)
139}
140
141func (r *browserLaunchRelay) DeliverOpenInstance(ctx context.Context, name, url string) (bool, error) {
142 return r.deliver(ctx, url, name, browserLaunchActionOpenInstance)
143}
144
145func (r *browserLaunchRelay) deliver(ctx context.Context, url, instance string, action browserLaunchAction) (bool, error) {
146 socketPath, err := r.socketPath()
147 if err != nil {
148 return false, err
149 }
150
151 if _, statErr := os.Stat(filepath.Dir(socketPath)); statErr == nil {
152 if ownerErr := validateBrowserLaunchSocketDirOwned(socketPath, uint32(os.Geteuid())); ownerErr != nil {
153 return false, ownerErr
154 }
155 } else if !os.IsNotExist(statErr) {
156 return false, fmt.Errorf("stat browser launch dir: %w", statErr)
157 }
158
159 conn, err := (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
160 if err != nil {
161 if isMissingRelayListener(err) {
162 return false, nil
163 }
164 return false, err
165 }
166 defer func() { _ = conn.Close() }()
167
168 requestID := newBrowserLaunchRequestID()
169 log := logging.FromContext(ctx)
170 log.Debug().
171 Str("request_id", requestID).
172 Str("url_host", safeURLHost(url)).
173 Msg("browser launch relay delivery started")
174
175 if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil {
176 return false, err
177 }
178 request := browserLaunchRequest{
179 RequestID: requestID,
180 URL: url,
181 Instance: instance,
182 Action: action,
183 }
184 if err := json.NewEncoder(conn).Encode(request); err != nil {
185 return false, err
186 }
187
188 if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil {
189 return false, err
190 }
191
192 var response browserLaunchResponse
193 decoder := json.NewDecoder(conn)
194 for {
195 if decodeErr := decoder.Decode(&response); decodeErr != nil {
196 if isBrowserLaunchReadTimeout(decodeErr) {
197 if ctxErr := ctx.Err(); ctxErr != nil {
198 return false, ctxErr
199 }
200 if _, ok := ctx.Deadline(); !ok {
201 log.Warn().
202 Str("request_id", requestID).
203 Str("url_host", safeURLHost(url)).
204 Dur("timeout", browserLaunchIOTimeout).
205 Msg("browser launch relay response timed out without caller deadline; delivery is unconfirmed")
206 return true, ErrBrowserLaunchRelayUnconfirmed
207 }
208 if deadlineErr := setBrowserLaunchConnDeadline(ctx, conn); deadlineErr != nil {
209 return false, deadlineErr
210 }
211 continue
212 }
213 return false, decodeErr
214 }
215 break
216 }
217 if response.RequestID != "" && response.RequestID != requestID {
218 return true, fmt.Errorf("mismatched browser launch relay response request id: got %q, want %q", response.RequestID, requestID)
219 }
220 if response.Error != "" {
221 log.Warn().
222 Str("request_id", requestID).
223 Str("url_host", safeURLHost(url)).
224 Str("relay_error", response.Error).
225 Msg("browser launch relay delivery rejected")
226 return true, errors.New(response.Error)
227 }
228
229 log.Debug().
230 Str("request_id", requestID).
231 Str("url_host", safeURLHost(url)).
232 Bool("accepted", response.Accepted || response.RequestID == "").
233 Msg("browser launch relay delivery acknowledged")
234 return true, nil
235}
236
237func isMissingRelayListener(err error) bool {
238 return os.IsNotExist(err) || errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENOENT)
239}
240
241func safeURLHost(raw string) string {
242 parsed, err := neturl.Parse(raw)
243 if err != nil {
244 return ""
245 }
246 return parsed.Host
247}
248
249func isBrowserLaunchReadTimeout(err error) bool {
250 var netErr net.Error
251 return errors.As(err, &netErr) && netErr.Timeout()
252}
253
254func browserLaunchSocketHasLiveListener(socketPath string) (bool, error) {
255 conn, err := net.DialTimeout("unix", socketPath, browserLaunchIOTimeout)
256 if err == nil {
257 _ = conn.Close()
258 return true, nil
259 }
260
261 if isMissingRelayListener(err) {
262 return false, nil
263 }
264
265 return false, fmt.Errorf("check browser launch socket: %w", err)
266}
267
268func setBrowserLaunchConnDeadline(ctx context.Context, conn net.Conn) error {
269 deadline := time.Now().Add(browserLaunchIOTimeout)
270 if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
271 deadline = ctxDeadline
272 }
273
274 return conn.SetDeadline(deadline)
275}
276
277func validateBrowserLaunchSocketDirOwned(socketPath string, expectedUID uint32) error {
278 socketDir := filepath.Dir(socketPath)
279 for dir := socketDir; ; dir = filepath.Dir(dir) {
280 info, err := os.Stat(dir)
281 if err != nil {
282 return fmt.Errorf("stat browser launch dir: %w", err)
283 }
284 stat, ok := info.Sys().(*syscall.Stat_t)
285 if !ok {
286 return nil
287 }
288
289 isSocketDir := dir == socketDir
290 if stat.Uid != expectedUID {
291 if isSocketDir || stat.Uid != 0 {
292 return fmt.Errorf("browser launch dir owner mismatch: %s owned by uid %d, want uid %d", dir, stat.Uid, expectedUID)
293 }
294 }
295 if info.Mode().Perm()&0o022 != 0 && (isSocketDir || info.Mode()&os.ModeSticky == 0) {
296 return fmt.Errorf("browser launch dir permissions too broad: %s has mode %04o", dir, info.Mode().Perm())
297 }
298 if parent := filepath.Dir(dir); parent == dir {
299 return nil
300 }
301 }
302}
303
304func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) {
305 r.prepareMu.Lock()
306 defer r.prepareMu.Unlock()
307 if r.serving {
308 return nil, errors.New("browser launch relay already serving")
309 }
310 listener := r.prepared
311 if listener != nil && listener.closed.Load() {
312 return nil, errors.New("browser launch relay reservation closed")
313 }
314 if listener == nil {
315 var err error
316 listener, err = r.bind(true)
317 if err != nil {
318 return nil, err
319 }
320 }
321 r.serving = true
322 go listener.serve(ctx, opener)
323 return listener, nil
324}
325
326// Prepare binds without accepting or acknowledging requests until Listen.
327// The supplied lease proves exclusive namespace ownership and must outlive
328// this relay; stale socket recovery is only safe while that lease is held.
329func (r *browserLaunchRelay) Prepare(namespaceLease io.Closer) error {
330 r.prepareMu.Lock()
331 defer r.prepareMu.Unlock()
332 if r.prepared != nil || r.serving {
333 return errors.New("browser launch relay already prepared or serving")
334 }
335 if namespaceLease == nil {
336 return errors.New("browser launch relay requires namespace lease")
337 }
338 listener, err := r.bind(true)
339 if err != nil {
340 return err
341 }
342 r.lease = namespaceLease
343 r.prepared = listener
344 return nil
345}
346
347func (r *browserLaunchRelay) bind(recoverStale bool) (*browserLaunchRelayListener, error) {
348 socketPath, err := r.socketPath()
349 if err != nil {
350 return nil, err
351 }
352
353 if mkdirErr := os.MkdirAll(filepath.Dir(socketPath), browserLaunchDirPerm); mkdirErr != nil {
354 return nil, fmt.Errorf("create browser launch dir: %w", mkdirErr)
355 }
356 if ownerErr := validateBrowserLaunchSocketDirOwned(socketPath, uint32(os.Geteuid())); ownerErr != nil {
357 return nil, ownerErr
358 }
359 listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
360 if err != nil {
361 if !recoverStale || !errors.Is(err, syscall.EADDRINUSE) {
362 return nil, fmt.Errorf("listen browser launch socket: %w", err)
363 }
364
365 live, liveErr := browserLaunchSocketHasLiveListener(socketPath)
366 if liveErr != nil {
367 return nil, liveErr
368 }
369 if live {
370 return nil, errors.New("browser launch relay already running")
371 }
372
373 if ownerErr := validateBrowserLaunchSocketDirOwned(socketPath, uint32(os.Geteuid())); ownerErr != nil {
374 return nil, ownerErr
375 }
376
377 if removeErr := os.Remove(socketPath); removeErr != nil && !os.IsNotExist(removeErr) {
378 return nil, fmt.Errorf("remove stale browser launch socket: %w", removeErr)
379 }
380
381 listener, err = net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
382 if err != nil {
383 return nil, fmt.Errorf("listen browser launch socket: %w", err)
384 }
385 }
386
387 // Go's UnixListener otherwise unlinks by pathname on Close, which can
388 // remove a successor socket created after this listener was closed.
389 listener.SetUnlinkOnClose(false)
390 socketInfo, err := os.Lstat(socketPath)
391 if err != nil {
392 _ = listener.Close()
393 return nil, fmt.Errorf("stat bound browser launch socket: %w", err)
394 }
395 return &browserLaunchRelayListener{
396 listener: listener,
397 socketPath: socketPath,
398 socketInfo: socketInfo,
399 admission: r.AdmissionGate(),
400 seen: make(map[string]struct{}),
401 }, nil
402}
403
404func (r *browserLaunchRelay) socketPath() (string, error) {
405 if r == nil {
406 return "", errors.New("browser launch relay missing IPC paths")
407 }
408 if r.ipc.BrowserLaunchSocket == "" {
409 return "", errors.New("browser launch relay missing browser launch socket path")
410 }
411 return r.ipc.BrowserLaunchSocket, nil
412}
413
414func (l *browserLaunchRelayListener) Close() error {
415 l.once.Do(func() {
416 l.closed.Store(true)
417 if l.listener != nil {
418 l.err = l.listener.Close()
419 }
420 // Remove only the directory entry that still names this listener.
421 // A concurrently-created successor owns a different inode and must live.
422 if current, err := os.Lstat(l.socketPath); err == nil && l.socketInfo != nil && os.SameFile(current, l.socketInfo) {
423 l.err = errors.Join(l.err, os.Remove(l.socketPath))
424 } else if err != nil && !os.IsNotExist(err) {
425 l.err = errors.Join(l.err, err)
426 }
427 })
428
429 return l.err
430}
431
432func (l *browserLaunchRelayListener) serve(ctx context.Context, opener port.BrowserWindowOpener) {
433 defer func() { _ = l.Close() }()
434
435 for {
436 if err := l.listener.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
437 return
438 }
439 conn, err := l.listener.AcceptUnix()
440 if err != nil {
441 if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
442 return
443 }
444 var netErr net.Error
445 if errors.As(err, &netErr) && netErr.Timeout() {
446 if ctx.Err() != nil {
447 return
448 }
449 continue
450 }
451 continue
452 }
453
454 go l.handleConnection(ctx, conn, opener)
455 }
456}
457
458// admitRelayConnection acquires one admission lease for the decoded request
459// and enforces pre-acknowledgement eligibility. It returns nil after sending
460// an error response (closed admission or unauthorized diagnostic action);
461// the caller must return without acknowledging. An admitted lease is released
462// by the caller on early failure and by dispatch completion otherwise.
463func admitRelayConnection(
464 conn *net.UnixConn,
465 configured *process.AdmissionGate,
466 request browserLaunchRequest,
467 requestID string,
468 log *zerolog.Logger,
469) *process.AdmissionGate {
470 gate := configured
471 if gate == nil {
472 gate = process.NewAdmissionGate()
473 }
474 refuse := func(errorBody string) *process.AdmissionGate {
475 if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
476 return nil
477 }
478 _ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: requestID, Error: errorBody})
479 return nil
480 }
481 if !gate.Acquire() {
482 log.Debug().
483 Str("request_id", requestID).
484 Msg("browser launch relay refused request after admission closed")
485 return refuse("shutting down")
486 }
487 // The diagnostic close action is rejected before acknowledgement
488 // unless the owning process explicitly enables it.
489 if request.Action == browserLaunchActionCloseAllWindows && os.Getenv(diagnosticWindowCloseEnvVar) != "1" {
490 gate.Release()
491 log.Warn().
492 Str("request_id", requestID).
493 Msg("browser launch relay rejected diagnostic close without owner opt-in")
494 return refuse("diagnostic close not enabled")
495 }
496 return gate
497}
498
499func (l *browserLaunchRelayListener) handleConnection(ctx context.Context, conn *net.UnixConn, opener port.BrowserWindowOpener) {
500 defer func() { _ = conn.Close() }()
501 log := logging.FromContext(ctx)
502 if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
503 return
504 }
505
506 var request browserLaunchRequest
507 if err := json.NewDecoder(conn).Decode(&request); err != nil {
508 return
509 }
510 requestID := request.RequestID
511 if requestID == "" {
512 requestID = newBrowserLaunchRequestID()
513 }
514 log.Debug().
515 Str("request_id", requestID).
516 Str("url_host", safeURLHost(request.URL)).
517 Msg("browser launch relay request received")
518
519 // Admit before acknowledgement: losing requests receive the existing
520 // error response here, never an acknowledgement for work that will not
521 // run. Acknowledgement still precedes expensive UI dispatch below, and
522 // unconfirmed delivery keeps its no-duplicate-fallback semantics.
523 gate := admitRelayConnection(conn, l.admission, request, requestID, log)
524 if gate == nil {
525 return
526 }
527
528 l.seenMu.Lock()
529 _, duplicate := l.seen[requestID]
530 if !duplicate {
531 l.seen[requestID] = struct{}{}
532 }
533 l.seenMu.Unlock()
534 if duplicate {
535 defer gate.Release()
536 _ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: requestID, Accepted: true})
537 return
538 }
539
540 if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
541 gate.Release()
542 return
543 }
544 ackErr := json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: requestID, Accepted: true})
545 if ackErr != nil {
546 // Decode plus admission transfers ownership of the request to the host.
547 // Dispatch must not depend on the client remaining alive for the ACK.
548 log.Warn().Err(ackErr).
549 Str("request_id", requestID).
550 Str("url_host", safeURLHost(request.URL)).
551 Msg("failed to encode browser launch response; dispatching retained request")
552 }
553 log.Debug().
554 Str("request_id", requestID).
555 Str("url_host", safeURLHost(request.URL)).
556 Msg("browser launch relay request accepted")
557
558 go func() {
559 // The lease spans acknowledgement through dispatch completion or
560 // definitive factory/dispatch failure. Factory/dispatch failure
561 // retains the documented existing failure semantics and never
562 // triggers duplicate fallback.
563 dispatchBrowserLaunch(ctx, request, requestID, opener, gate, log)
564 }()
565}
566
567// dispatchBrowserLaunch performs the admitted request's UI dispatch on behalf
568// of handleConnection, holding the admission lease until it completes. It
569// retains the documented failure semantics: a definitive factory or dispatch
570// failure only logs and releases the lease, never triggering duplicate
571// fallback.
572func dispatchBrowserLaunch(
573 ctx context.Context,
574 request browserLaunchRequest,
575 requestID string,
576 opener port.BrowserWindowOpener,
577 gate *process.AdmissionGate,
578 log *zerolog.Logger,
579) {
580 defer gate.Release()
581 if opener == nil {
582 log.Warn().
583 Str("request_id", requestID).
584 Str("url_host", safeURLHost(request.URL)).
585 Msg("browser launch relay accepted request without opener")
586 return
587 }
588 started := time.Now()
589 log.Debug().
590 Str("request_id", requestID).
591 Str("url_host", safeURLHost(request.URL)).
592 Msg("browser launch relay calling browser URL opener")
593 var err error
594 switch request.Action {
595 case "", browserLaunchActionOpenExternalURL:
596 err = opener.OpenExternalURL(ctx, request.URL)
597 case browserLaunchActionOpenFreshWindow:
598 err = opener.OpenFreshWindow(ctx, request.URL)
599 case browserLaunchActionOpenInstance:
600 instanceOpener, ok := opener.(port.BrowserInstanceOpener)
601 if !ok {
602 err = errors.New("browser launch relay opener does not support named instances")
603 } else {
604 err = instanceOpener.OpenInstance(ctx, request.Instance, request.URL)
605 }
606 case browserLaunchActionCloseAllWindows:
607 if closer, ok := opener.(windowCloseOpener); ok {
608 err = closer.CloseAllWindows(ctx)
609 } else {
610 err = errors.New("browser launch relay opener does not support diagnostic close")
611 }
612 default:
613 log.Warn().
614 Str("request_id", requestID).
615 Str("url_host", safeURLHost(request.URL)).
616 Str("action", string(request.Action)).
617 Msg("browser launch relay rejected unknown action")
618 return
619 }
620 if err != nil {
621 log.Warn().Err(err).
622 Str("request_id", requestID).
623 Str("url_host", safeURLHost(request.URL)).
624 Dur("elapsed", time.Since(started)).
625 Msg("browser launch relay browser URL opener failed")
626 return
627 }
628 log.Debug().
629 Str("request_id", requestID).
630 Str("url_host", safeURLHost(request.URL)).
631 Dur("elapsed", time.Since(started)).
632 Msg("browser launch relay browser URL opener returned success")
633}
634
635var _ port.BrowserLaunchRelay = (*browserLaunchRelay)(nil)