v0.32.0 0617278

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	"syscall"
17	"time"
18
19	"github.com/bnema/dumber/internal/application/port"
20	"github.com/bnema/dumber/internal/infrastructure/process"
21	"github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
22	"github.com/bnema/dumber/internal/logging"
23	"github.com/rs/zerolog"
24)
25
26const browserLaunchSocketName = "browser-launch.sock"
27
28// Local browser-launch handoff needs enough headroom for a busy browser
29// process to accept and acknowledge the request before callers classify the
30// handoff as ambiguous.
31const browserLaunchIOTimeout = 250 * time.Millisecond
32
33const browserLaunchDirPerm = 0o700
34
35// ErrBrowserLaunchRelayUnconfirmed reports that the relay accepted a launch
36// request but the caller did not receive a confirmation response in time.
37var ErrBrowserLaunchRelayUnconfirmed = errors.New("browser launch relay did not confirm delivery")
38
39type browserLaunchRelay struct {
40	ipc runtimeprofile.IPCPaths
41	// admission is the shared admission/work-lease boundary: a lease is
42	// acquired before sending Accepted and released when dispatch settles.
43	// Shutdown closes it so losing requests get an error response instead
44	// of an acknowledgement. Initialized eagerly; never nil.
45	admission *process.AdmissionGate
46}
47
48type browserLaunchRequest struct {
49	RequestID string              `json:"request_id,omitempty"`
50	URL       string              `json:"url"`
51	Action    browserLaunchAction `json:"action,omitempty"`
52}
53
54type browserLaunchAction string
55
56const (
57	browserLaunchActionOpenExternalURL browserLaunchAction = "open-external-url"
58	browserLaunchActionOpenFreshWindow browserLaunchAction = "open-fresh-window"
59	// browserLaunchActionCloseAllWindows is a diagnostic-only action for the
60	// residency harness: it closes every user window through the standard
61	// removal path. It is honored only when the OWNING process sets
62	// DUMBER_DIAGNOSTIC_WINDOW_CLOSE=1; otherwise it is rejected before
63	// acknowledgement. It is never a general unauthenticated shutdown
64	// command: the relay socket itself remains owner-only (0700, uid).
65	browserLaunchActionCloseAllWindows browserLaunchAction = "close-all-windows"
66)
67
68// diagnosticWindowCloseEnvVar gates the diagnostic close action on the
69// owning (server) process environment.
70const diagnosticWindowCloseEnvVar = "DUMBER_DIAGNOSTIC_WINDOW_CLOSE"
71
72// windowCloseOpener is implemented by openers that support the diagnostic
73// close action. It is matched structurally so the port interface is
74// unchanged.
75type windowCloseOpener interface {
76	CloseAllWindows(ctx context.Context) error
77}
78
79type browserLaunchResponse struct {
80	RequestID string `json:"request_id,omitempty"`
81	Accepted  bool   `json:"accepted,omitempty"`
82	Error     string `json:"error,omitempty"`
83}
84
85type browserLaunchRelayListener struct {
86	listener   *net.UnixListener
87	socketPath string
88	admission  *process.AdmissionGate
89	once       sync.Once
90	err        error
91}
92
93var newBrowserLaunchRequestID = func() string {
94	var buf [8]byte
95	if _, err := rand.Read(buf[:]); err == nil {
96		return "blr-" + hex.EncodeToString(buf[:])
97	}
98	return fmt.Sprintf("blr-%d", time.Now().UnixNano())
99}
100
101func NewBrowserLaunchRelay(ipc runtimeprofile.IPCPaths) port.BrowserLaunchRelay {
102	return &browserLaunchRelay{ipc: ipc, admission: process.NewAdmissionGate()}
103}
104
105// AdmissionGateProvider exposes the relay's shared admission boundary
106// without extending the port interface or regenerating mocks.
107type AdmissionGateProvider interface {
108	AdmissionGate() *process.AdmissionGate
109}
110
111// Compile-time check: the shared gate implements the application boundary.
112var _ port.AdmissionBoundary = (*process.AdmissionGate)(nil)
113
114// AdmissionGate returns the shared admission/work-lease boundary.
115func (r *browserLaunchRelay) AdmissionGate() *process.AdmissionGate {
116	if r == nil || r.admission == nil {
117		return process.NewAdmissionGate()
118	}
119	return r.admission
120}
121
122func (r *browserLaunchRelay) DeliverOpenExternalURL(ctx context.Context, url string) (bool, error) {
123	return r.deliver(ctx, url, "")
124}
125
126func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) {
127	return r.deliver(ctx, url, browserLaunchActionOpenFreshWindow)
128}
129
130func (r *browserLaunchRelay) deliver(ctx context.Context, url string, action browserLaunchAction) (bool, error) {
131	socketPath, err := r.socketPath()
132	if err != nil {
133		return false, err
134	}
135
136	conn, err := (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
137	if err != nil {
138		if isMissingRelayListener(err) {
139			return false, nil
140		}
141		return false, err
142	}
143	defer func() { _ = conn.Close() }()
144
145	requestID := newBrowserLaunchRequestID()
146	log := logging.FromContext(ctx)
147	log.Debug().
148		Str("request_id", requestID).
149		Str("url_host", safeURLHost(url)).
150		Msg("browser launch relay delivery started")
151
152	if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil {
153		return false, err
154	}
155	if err := json.NewEncoder(conn).Encode(browserLaunchRequest{RequestID: requestID, URL: url, Action: action}); err != nil {
156		return false, err
157	}
158
159	if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil {
160		return false, err
161	}
162
163	var response browserLaunchResponse
164	decoder := json.NewDecoder(conn)
165	for {
166		if decodeErr := decoder.Decode(&response); decodeErr != nil {
167			if isBrowserLaunchReadTimeout(decodeErr) {
168				if ctxErr := ctx.Err(); ctxErr != nil {
169					return false, ctxErr
170				}
171				if _, ok := ctx.Deadline(); !ok {
172					log.Warn().
173						Str("request_id", requestID).
174						Str("url_host", safeURLHost(url)).
175						Dur("timeout", browserLaunchIOTimeout).
176						Msg("browser launch relay response timed out without caller deadline; delivery is unconfirmed")
177					return true, ErrBrowserLaunchRelayUnconfirmed
178				}
179				if deadlineErr := setBrowserLaunchConnDeadline(ctx, conn); deadlineErr != nil {
180					return false, deadlineErr
181				}
182				continue
183			}
184			return false, decodeErr
185		}
186		break
187	}
188	if response.RequestID != "" && response.RequestID != requestID {
189		return true, fmt.Errorf("mismatched browser launch relay response request id: got %q, want %q", response.RequestID, requestID)
190	}
191	if response.Error != "" {
192		log.Warn().
193			Str("request_id", requestID).
194			Str("url_host", safeURLHost(url)).
195			Str("relay_error", response.Error).
196			Msg("browser launch relay delivery rejected")
197		return true, errors.New(response.Error)
198	}
199
200	log.Debug().
201		Str("request_id", requestID).
202		Str("url_host", safeURLHost(url)).
203		Bool("accepted", response.Accepted || response.RequestID == "").
204		Msg("browser launch relay delivery acknowledged")
205	return true, nil
206}
207
208func isMissingRelayListener(err error) bool {
209	return os.IsNotExist(err) || errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENOENT)
210}
211
212func safeURLHost(raw string) string {
213	parsed, err := neturl.Parse(raw)
214	if err != nil {
215		return ""
216	}
217	return parsed.Host
218}
219
220func isBrowserLaunchReadTimeout(err error) bool {
221	var netErr net.Error
222	return errors.As(err, &netErr) && netErr.Timeout()
223}
224
225func browserLaunchSocketHasLiveListener(socketPath string) (bool, error) {
226	conn, err := net.DialTimeout("unix", socketPath, browserLaunchIOTimeout)
227	if err == nil {
228		_ = conn.Close()
229		return true, nil
230	}
231
232	if isMissingRelayListener(err) {
233		return false, nil
234	}
235
236	return false, fmt.Errorf("check browser launch socket: %w", err)
237}
238
239func setBrowserLaunchConnDeadline(ctx context.Context, conn net.Conn) error {
240	deadline := time.Now().Add(browserLaunchIOTimeout)
241	if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
242		deadline = ctxDeadline
243	}
244
245	return conn.SetDeadline(deadline)
246}
247
248func validateBrowserLaunchSocketDirOwned(socketPath string, expectedUID uint32) error {
249	socketDir := filepath.Dir(socketPath)
250	for dir := socketDir; ; dir = filepath.Dir(dir) {
251		info, err := os.Stat(dir)
252		if err != nil {
253			return fmt.Errorf("stat browser launch dir: %w", err)
254		}
255		stat, ok := info.Sys().(*syscall.Stat_t)
256		if !ok {
257			return nil
258		}
259
260		isSocketDir := dir == socketDir
261		if stat.Uid != expectedUID {
262			if isSocketDir || stat.Uid != 0 {
263				return fmt.Errorf("browser launch dir owner mismatch: %s owned by uid %d, want uid %d", dir, stat.Uid, expectedUID)
264			}
265		}
266		if info.Mode().Perm()&0o022 != 0 && (isSocketDir || info.Mode()&os.ModeSticky == 0) {
267			return fmt.Errorf("browser launch dir permissions too broad: %s has mode %04o", dir, info.Mode().Perm())
268		}
269		if parent := filepath.Dir(dir); parent == dir {
270			return nil
271		}
272	}
273}
274
275func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) {
276	socketPath, err := r.socketPath()
277	if err != nil {
278		return nil, err
279	}
280
281	if mkdirErr := os.MkdirAll(filepath.Dir(socketPath), browserLaunchDirPerm); mkdirErr != nil {
282		return nil, fmt.Errorf("create browser launch dir: %w", mkdirErr)
283	}
284	if ownerErr := validateBrowserLaunchSocketDirOwned(socketPath, uint32(os.Geteuid())); ownerErr != nil {
285		return nil, ownerErr
286	}
287	listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
288	if err != nil {
289		if !errors.Is(err, syscall.EADDRINUSE) {
290			return nil, fmt.Errorf("listen browser launch socket: %w", err)
291		}
292
293		live, liveErr := browserLaunchSocketHasLiveListener(socketPath)
294		if liveErr != nil {
295			return nil, liveErr
296		}
297		if live {
298			return nil, errors.New("browser launch relay already running")
299		}
300
301		if ownerErr := validateBrowserLaunchSocketDirOwned(socketPath, uint32(os.Geteuid())); ownerErr != nil {
302			return nil, ownerErr
303		}
304
305		if removeErr := os.Remove(socketPath); removeErr != nil && !os.IsNotExist(removeErr) {
306			return nil, fmt.Errorf("remove stale browser launch socket: %w", removeErr)
307		}
308
309		listener, err = net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
310		if err != nil {
311			return nil, fmt.Errorf("listen browser launch socket: %w", err)
312		}
313	}
314
315	relayListener := &browserLaunchRelayListener{listener: listener, socketPath: socketPath, admission: r.AdmissionGate()}
316	go relayListener.serve(ctx, opener)
317
318	return relayListener, nil
319}
320
321func (r *browserLaunchRelay) socketPath() (string, error) {
322	if r == nil {
323		return "", errors.New("browser launch relay missing IPC paths")
324	}
325	if r.ipc.BrowserLaunchSocket == "" {
326		return "", errors.New("browser launch relay missing browser launch socket path")
327	}
328	return r.ipc.BrowserLaunchSocket, nil
329}
330
331func (l *browserLaunchRelayListener) Close() error {
332	l.once.Do(func() {
333		if l.listener != nil {
334			l.err = l.listener.Close()
335		}
336		_ = os.Remove(l.socketPath)
337	})
338
339	return l.err
340}
341
342func (l *browserLaunchRelayListener) serve(ctx context.Context, opener port.BrowserWindowOpener) {
343	defer func() { _ = l.Close() }()
344
345	for {
346		if err := l.listener.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
347			return
348		}
349		conn, err := l.listener.AcceptUnix()
350		if err != nil {
351			if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
352				return
353			}
354			var netErr net.Error
355			if errors.As(err, &netErr) && netErr.Timeout() {
356				if ctx.Err() != nil {
357					return
358				}
359				continue
360			}
361			continue
362		}
363
364		go l.handleConnection(ctx, conn, opener)
365	}
366}
367
368// admitRelayConnection acquires one admission lease for the decoded request
369// and enforces pre-acknowledgement eligibility. It returns nil after sending
370// an error response (closed admission or unauthorized diagnostic action);
371// the caller must return without acknowledging. An admitted lease is released
372// by the caller on early failure and by dispatch completion otherwise.
373func admitRelayConnection(
374	conn *net.UnixConn,
375	configured *process.AdmissionGate,
376	request browserLaunchRequest,
377	requestID string,
378	log *zerolog.Logger,
379) *process.AdmissionGate {
380	gate := configured
381	if gate == nil {
382		gate = process.NewAdmissionGate()
383	}
384	refuse := func(errorBody string) *process.AdmissionGate {
385		if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
386			return nil
387		}
388		_ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: requestID, Error: errorBody})
389		return nil
390	}
391	if !gate.Acquire() {
392		log.Debug().
393			Str("request_id", requestID).
394			Msg("browser launch relay refused request after admission closed")
395		return refuse("shutting down")
396	}
397	// The diagnostic close action is rejected before acknowledgement
398	// unless the owning process explicitly enables it.
399	if request.Action == browserLaunchActionCloseAllWindows && os.Getenv(diagnosticWindowCloseEnvVar) != "1" {
400		gate.Release()
401		log.Warn().
402			Str("request_id", requestID).
403			Msg("browser launch relay rejected diagnostic close without owner opt-in")
404		return refuse("diagnostic close not enabled")
405	}
406	return gate
407}
408
409func (l *browserLaunchRelayListener) handleConnection(ctx context.Context, conn *net.UnixConn, opener port.BrowserWindowOpener) {
410	defer func() { _ = conn.Close() }()
411	log := logging.FromContext(ctx)
412	if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
413		return
414	}
415
416	var request browserLaunchRequest
417	if err := json.NewDecoder(conn).Decode(&request); err != nil {
418		return
419	}
420	requestID := request.RequestID
421	if requestID == "" {
422		requestID = newBrowserLaunchRequestID()
423	}
424	log.Debug().
425		Str("request_id", requestID).
426		Str("url_host", safeURLHost(request.URL)).
427		Msg("browser launch relay request received")
428
429	// Admit before acknowledgement: losing requests receive the existing
430	// error response here, never an acknowledgement for work that will not
431	// run. Acknowledgement still precedes expensive UI dispatch below, and
432	// unconfirmed delivery keeps its no-duplicate-fallback semantics.
433	gate := admitRelayConnection(conn, l.admission, request, requestID, log)
434	if gate == nil {
435		return
436	}
437
438	if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
439		gate.Release()
440		return
441	}
442	if err := json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: requestID, Accepted: true}); err != nil {
443		log.Warn().Err(err).
444			Str("request_id", requestID).
445			Str("url_host", safeURLHost(request.URL)).
446			Msg("failed to encode browser launch response")
447		gate.Release()
448		return
449	}
450	log.Debug().
451		Str("request_id", requestID).
452		Str("url_host", safeURLHost(request.URL)).
453		Msg("browser launch relay request accepted")
454
455	go func() {
456		// The lease spans acknowledgement through dispatch completion or
457		// definitive factory/dispatch failure. Factory/dispatch failure
458		// retains the documented existing failure semantics and never
459		// triggers duplicate fallback.
460		defer gate.Release()
461		if opener == nil {
462			log.Warn().
463				Str("request_id", requestID).
464				Str("url_host", safeURLHost(request.URL)).
465				Msg("browser launch relay accepted request without opener")
466			return
467		}
468		started := time.Now()
469		log.Debug().
470			Str("request_id", requestID).
471			Str("url_host", safeURLHost(request.URL)).
472			Msg("browser launch relay calling browser URL opener")
473		var err error
474		switch request.Action {
475		case "", browserLaunchActionOpenExternalURL:
476			err = opener.OpenExternalURL(ctx, request.URL)
477		case browserLaunchActionOpenFreshWindow:
478			err = opener.OpenFreshWindow(ctx, request.URL)
479		case browserLaunchActionCloseAllWindows:
480			if closer, ok := opener.(windowCloseOpener); ok {
481				err = closer.CloseAllWindows(ctx)
482			} else {
483				err = errors.New("browser launch relay opener does not support diagnostic close")
484			}
485		default:
486			log.Warn().
487				Str("request_id", requestID).
488				Str("url_host", safeURLHost(request.URL)).
489				Str("action", string(request.Action)).
490				Msg("browser launch relay rejected unknown action")
491			return
492		}
493		if err != nil {
494			log.Warn().Err(err).
495				Str("request_id", requestID).
496				Str("url_host", safeURLHost(request.URL)).
497				Dur("elapsed", time.Since(started)).
498				Msg("browser launch relay browser URL opener failed")
499			return
500		}
501		log.Debug().
502			Str("request_id", requestID).
503			Str("url_host", safeURLHost(request.URL)).
504			Dur("elapsed", time.Since(started)).
505			Msg("browser launch relay browser URL opener returned success")
506	}()
507}
508
509var _ 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