browser_launch_relay.go RawHistoryFolder

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)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close