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)