返回 DeepSeek-Reasonix
browser_executor.go
根目录 / desktop / browser_executor.go
1 package main
2
3 import (
4 "context"
5 "crypto/rand"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "log/slog"
12 "os"
13 "path/filepath"
14 "sync"
15 "sync/atomic"
16 "time"
17
18 "reasonix/desktop/internal/browserops"
19 "reasonix/internal/browser"
20 "reasonix/internal/config"
21 "reasonix/internal/extension/rpcwire"
22 )
23
24 // Shell error codes for host/browser.* replies; anything else is transport
25 // failure and therefore an unknown outcome for a reserved write.
26 const (
27 hostBrowserErrStaleReference = -32010
28 hostBrowserErrTakenOver = -32011
29 hostBrowserErrNoGrant = -32012
30 )
31
32 const hostBrowserReadTimeout = 60 * time.Second
33
34 type hostRequester interface {
35 Request(ctx context.Context, method string, params any, result any) error
36 }
37
38 // hostBrowserExecutor implements browser.Executor for one desktop tab. Every
39 // call carries the tab's grant; the shell binds tabs, epochs and document
40 // tokens to that grant so a revoked or restarted service can never act.
41 type hostBrowserExecutor struct {
42 app *App
43 host hostRequester
44 tabID string
45 grantID string
46 // sessionKey overrides the grant's session binding when set; remote
47 // broker executors use it because their tabs are not workspace tabs.
48 sessionKey string
49 diagnosticScope string // Opaque export identity; never used to authorize operations.
50 granted atomic.Bool
51 revoked atomic.Bool
52 grantMu sync.Mutex
53 }
54
55 // tabBrowserExecutor is the stable executor held by a long-lived Controller.
56 // Every operation follows the runtime-owned sink to its current surface and
57 // resolves an immutable host grant. Reusing the original surface for another
58 // session must never give the old controller that session's browser.
59 type tabBrowserExecutor struct {
60 app *App
61 tabID string
62 sink *tabEventSink // runtime-owned binding follows detach/reattach
63 }
64
65 type hostBrowserTab struct {
66 ID string `json:"id"`
67 URL string `json:"url"`
68 Title string `json:"title"`
69 Loading bool `json:"loading"`
70 Temporary bool `json:"temporary"`
71 Error string `json:"error,omitempty"`
72 }
73
74 func (t hostBrowserTab) tab() browser.Tab {
75 return browser.Tab{ID: t.ID, URL: t.URL, Title: t.Title, Loading: t.Loading, Temporary: t.Temporary, Error: t.Error}
76 }
77
78 func (a *App) browserExecutorForTab(tab *WorkspaceTab) browser.Executor {
79 if a == nil || tab == nil {
80 return nil
81 }
82 a.mu.RLock()
83 tabID, sink := tab.ID, tab.sink
84 a.mu.RUnlock()
85 return a.browserExecutorForRuntime(tabID, sink)
86 }
87
88 func (a *App) browserExecutorForRuntime(tabID string, sink *tabEventSink) browser.Executor {
89 if !a.hostMode() || a.browserControl.off() {
90 return nil
91 }
92 return &tabBrowserExecutor{app: a, tabID: tabID, sink: sink}
93 }
94
95 func (a *App) hostBrowserExecutorForTab(tabID string) *hostBrowserExecutor {
96 return a.hostBrowserExecutorForBinding(tabID, nil)
97 }
98
99 // browserBindingTabLocked resolves the runtime's current owner, including a
100 // detached owner. Holding App.mu before reading the sink binding matches the
101 // transfer lock order and prevents a stale tab ID from selecting another task.
102 func (a *App) browserBindingTabLocked(tabID string, sink *tabEventSink) *WorkspaceTab {
103 if sink != nil {
104 tabID, _ = sink.binding()
105 }
106 tab := a.tabByEventSinkIDLocked(tabID)
107 if tab == nil || tab.removed || (sink != nil && tab.sink != sink) {
108 return nil
109 }
110 return tab
111 }
112
113 func (a *App) hostBrowserExecutorForBinding(tabID string, sink *tabEventSink) *hostBrowserExecutor {
114 a.mu.RLock()
115 tab := a.browserBindingTabLocked(tabID, sink)
116 if tab == nil {
117 a.mu.RUnlock()
118 return nil
119 }
120 tabID = tab.ID
121 identity := tab.SessionID
122 if identity == "" {
123 identity = tab.SessionPath
124 }
125 sessionKey := fmt.Sprintf("%s:%d", identity, tab.SessionGeneration)
126 a.browserExecMu.Lock()
127 if a.browserExecutors == nil {
128 a.browserExecutors = map[string]*hostBrowserExecutor{}
129 }
130 if exec, ok := a.browserExecutors[tabID]; ok {
131 if exec.sessionKey == sessionKey {
132 a.browserExecMu.Unlock()
133 a.mu.RUnlock()
134 return exec
135 }
136 delete(a.browserExecutors, tabID)
137 replacement := &hostBrowserExecutor{app: a, host: a.hostShell.server, tabID: tabID, grantID: newBrowserGrantID(), sessionKey: sessionKey, diagnosticScope: browserDiagnosticScope(localDesktopHostID, tab.SessionID)}
138 a.browserExecutors[tabID] = replacement
139 a.browserExecMu.Unlock()
140 a.mu.RUnlock()
141 a.releaseFileBrowserPreviewsForTask(tabID)
142 a.revokeBrowserExecutor(exec)
143 return replacement
144 }
145 exec := &hostBrowserExecutor{app: a, host: a.hostShell.server, tabID: tabID, grantID: newBrowserGrantID(), sessionKey: sessionKey, diagnosticScope: browserDiagnosticScope(localDesktopHostID, tab.SessionID)}
146 a.browserExecutors[tabID] = exec
147 a.browserExecMu.Unlock()
148 a.mu.RUnlock()
149 return exec
150 }
151
152 func newBrowserGrantID() string {
153 buf := make([]byte, 32)
154 if _, err := rand.Read(buf); err != nil {
155 panic(err)
156 }
157 return "grant-" + hex.EncodeToString(buf)
158 }
159
160 func (a *App) revokeRemoteBrowserHost(hostID string) {
161 a.remoteTabMu.Lock()
162 var ids []string
163 for _, tab := range a.remoteTabs {
164 if tab != nil && tab.ref.HostID == hostID {
165 ids = append(ids, tab.id)
166 }
167 }
168 a.remoteTabMu.Unlock()
169 for _, id := range ids {
170 a.forgetRemoteBrowserExecutor(id)
171 }
172 }
173
174 // forgetBrowserExecutorLocked drops the tab's executor and revokes its grant
175 // off the caller's lock; a revoked executor fails closed forever.
176 func (a *App) forgetBrowserExecutorLocked(tabID string) {
177 a.browserExecMu.Lock()
178 exec, ok := a.browserExecutors[tabID]
179 delete(a.browserExecutors, tabID)
180 a.browserExecMu.Unlock()
181 a.releaseFileBrowserPreviewsForTask(tabID)
182 if !ok {
183 return
184 }
185 a.revokeBrowserExecutor(exec)
186 }
187
188 func (e *tabBrowserExecutor) current() (*hostBrowserExecutor, error) {
189 if e == nil || e.app == nil || !e.app.hostMode() || e.app.browserControl.off() {
190 return nil, browser.ErrNoGrant
191 }
192 exec := e.app.hostBrowserExecutorForBinding(e.tabID, e.sink)
193 if exec == nil {
194 return nil, browser.ErrNoGrant
195 }
196 return exec, nil
197 }
198
199 func (e *tabBrowserExecutor) Available(ctx context.Context) bool {
200 return e.UnavailableReason(ctx) == ""
201 }
202
203 // Discovery is observational: do not mint or rotate a grant while searching
204 // the capability catalog. Execution resolves the same binding under App.mu.
205 func (e *tabBrowserExecutor) UnavailableReason(context.Context) string {
206 if e == nil || e.app == nil || !e.app.hostMode() {
207 return "the built-in browser host is not attached to this task"
208 }
209 if e.app.browserControl.off() {
210 return "built-in browser control is disabled in the host settings"
211 }
212 e.app.mu.RLock()
213 tab := e.app.browserBindingTabLocked(e.tabID, e.sink)
214 e.app.mu.RUnlock()
215 if tab == nil {
216 return "the browser's task runtime binding is no longer available"
217 }
218 return ""
219 }
220 func (e *tabBrowserExecutor) Tabs(ctx context.Context) ([]browser.Tab, error) {
221 x, err := e.current()
222 if err != nil {
223 return nil, err
224 }
225 return x.Tabs(ctx)
226 }
227 func (e *tabBrowserExecutor) Open(ctx context.Context, req browser.OpenRequest) (browser.Tab, error) {
228 x, err := e.current()
229 if err != nil {
230 return browser.Tab{}, err
231 }
232 return x.Open(ctx, req)
233 }
234 func (e *tabBrowserExecutor) Navigate(ctx context.Context, req browser.NavigateRequest) (browser.Tab, error) {
235 x, err := e.current()
236 if err != nil {
237 return browser.Tab{}, err
238 }
239 return x.Navigate(ctx, req)
240 }
241 func (e *tabBrowserExecutor) Close(ctx context.Context, req browser.CloseRequest) error {
242 x, err := e.current()
243 if err != nil {
244 return err
245 }
246 return x.Close(ctx, req)
247 }
248 func (e *tabBrowserExecutor) Snapshot(ctx context.Context, req browser.SnapshotRequest) (browser.Snapshot, error) {
249 x, err := e.current()
250 if err != nil {
251 return browser.Snapshot{}, err
252 }
253 return x.Snapshot(ctx, req)
254 }
255 func (e *tabBrowserExecutor) Screenshot(ctx context.Context, req browser.ScreenshotRequest) (browser.Screenshot, error) {
256 x, err := e.current()
257 if err != nil {
258 return browser.Screenshot{}, err
259 }
260 return x.Screenshot(ctx, req)
261 }
262 func (e *tabBrowserExecutor) Act(ctx context.Context, req browser.ActRequest) (browser.ActResult, error) {
263 x, err := e.current()
264 if err != nil {
265 return browser.ActResult{}, err
266 }
267 return x.Act(ctx, req)
268 }
269 func (e *tabBrowserExecutor) Downloads(ctx context.Context, req browser.DownloadsRequest) ([]browser.Download, error) {
270 x, err := e.current()
271 if err != nil {
272 return nil, err
273 }
274 return x.Downloads(ctx, req)
275 }
276 func (e *tabBrowserExecutor) PreviewFile(ctx context.Context, req browser.FilePreviewRequest) (browser.Tab, error) {
277 x, err := e.current()
278 if err != nil {
279 return browser.Tab{}, err
280 }
281 return x.PreviewFile(ctx, req)
282 }
283
284 func (a *App) revokeBrowserExecutor(exec *hostBrowserExecutor) {
285 exec.revoked.Store(true)
286 a.goSafe("revokeBrowserGrant", func() {
287 exec.grantMu.Lock()
288 defer exec.grantMu.Unlock()
289 if !exec.granted.Load() {
290 return
291 }
292 ctx, cancel := context.WithTimeout(context.Background(), rpcHostWindowTimeout)
293 defer cancel()
294 _ = exec.host.Request(ctx, "host/browser.revoke", map[string]string{"grantId": exec.grantID}, nil)
295 })
296 }
297
298 // forgetRemoteBrowserExecutor drops the broker executor of a closed remote
299 // tab; its grant ID is namespaced with the "remote/" prefix used at creation.
300 func (a *App) forgetRemoteBrowserExecutor(remoteTabID string) {
301 a.forgetBrowserExecutorLocked("remote/" + remoteTabID)
302 }
303
304 func (a *App) browserLedger() (*browserops.Ledger, error) {
305 a.browserExecMu.Lock()
306 defer a.browserExecMu.Unlock()
307 if a.browserOps != nil {
308 return a.browserOps, nil
309 }
310 ledger, err := browserops.Open(filepath.Join(config.MemoryUserDir(), "browser", "operations-v1.json"))
311 if err != nil {
312 return nil, err
313 }
314 a.browserOps = ledger
315 return ledger, nil
316 }
317
318 func (e *hostBrowserExecutor) Available(context.Context) bool {
319 return !e.revoked.Load() && e.app.hostMode()
320 }
321
322 // browserSessionKey is the session identity the grant binds to: the explicit
323 // override for broker-created executors, else the workspace tab's session.
324 func (e *hostBrowserExecutor) browserSessionKey() string {
325 if e.sessionKey != "" {
326 return e.sessionKey
327 }
328 return e.app.tabSessionKeyForBrowser(e.tabID)
329 }
330
331 func (e *hostBrowserExecutor) ensureGrant(ctx context.Context) error {
332 if err := ctx.Err(); err != nil {
333 return err
334 }
335 if e.revoked.Load() {
336 return browser.ErrNoGrant
337 }
338 if e.granted.Load() {
339 return nil
340 }
341 e.grantMu.Lock()
342 defer e.grantMu.Unlock()
343 if e.revoked.Load() {
344 return browser.ErrNoGrant
345 }
346 if e.granted.Load() {
347 return nil
348 }
349 params := map[string]string{"grantId": e.grantID, "tabId": e.tabID, "sessionId": e.browserSessionKey()}
350 params["diagnosticScope"] = e.diagnosticScope
351 if err := e.host.Request(ctx, "host/browser.grant", params, nil); err != nil {
352 return mapHostBrowserError(err)
353 }
354 if e.revoked.Load() {
355 return browser.ErrNoGrant
356 }
357 e.granted.Store(true)
358 return nil
359 }
360
361 func (a *App) tabSessionKeyForBrowser(tabID string) string {
362 a.mu.RLock()
363 defer a.mu.RUnlock()
364 if tab, ok := a.tabs[tabID]; ok {
365 return tab.SessionPath
366 }
367 return ""
368 }
369
370 func (e *hostBrowserExecutor) call(ctx context.Context, method string, params map[string]any, result any) error {
371 if err := e.ensureGrant(ctx); err != nil {
372 return err
373 }
374 if err := ctx.Err(); err != nil {
375 return err
376 }
377 if params == nil {
378 params = map[string]any{}
379 }
380 params["grantId"] = e.grantID
381 ctx, cancel := context.WithTimeout(ctx, hostBrowserReadTimeout)
382 defer cancel()
383 requestID := make([]byte, 16)
384 if _, err := rand.Read(requestID); err != nil {
385 return err
386 }
387 params["requestId"] = hex.EncodeToString(requestID)
388 if deadline, ok := ctx.Deadline(); ok {
389 params["deadline"] = deadline.UnixMilli()
390 }
391 if err := e.host.Request(ctx, method, params, result); err != nil {
392 if ctx.Err() != nil {
393 cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), time.Second)
394 defer cleanupCancel()
395 // Old shells may not implement cancellation; preserve the original
396 // unknown write outcome rather than retrying the operation.
397 _ = e.host.Request(cleanupCtx, "host/browser.cancel", map[string]any{"grantId": e.grantID, "requestId": params["requestId"]}, nil)
398 }
399 return mapHostBrowserError(err)
400 }
401 return nil
402 }
403
404 func mapHostBrowserError(err error) error {
405 var resp *rpcwire.ResponseError
406 if errors.As(err, &resp) {
407 switch resp.Code {
408 case hostBrowserErrStaleReference:
409 return browser.ErrStaleReference
410 case hostBrowserErrTakenOver:
411 return browser.ErrTakenOver
412 case hostBrowserErrNoGrant:
413 return browser.ErrNoGrant
414 }
415 return fmt.Errorf("browser host: %s", resp.Message)
416 }
417 return err
418 }
419
420 func (e *hostBrowserExecutor) Tabs(ctx context.Context) ([]browser.Tab, error) {
421 var out struct {
422 Tabs []hostBrowserTab `json:"tabs"`
423 }
424 if err := e.call(ctx, "host/browser.tabs.list", nil, &out); err != nil {
425 return nil, err
426 }
427 tabs := make([]browser.Tab, 0, len(out.Tabs))
428 for _, t := range out.Tabs {
429 tabs = append(tabs, t.tab())
430 }
431 return tabs, nil
432 }
433
434 func (e *hostBrowserExecutor) Open(ctx context.Context, req browser.OpenRequest) (browser.Tab, error) {
435 var out hostBrowserTab
436 err := e.write(ctx, req.OperationID, "open", "", req, "host/browser.tabs.open", map[string]any{"url": req.URL, "temporary": req.Temporary}, &out)
437 return out.tab(), err
438 }
439
440 func (e *hostBrowserExecutor) Navigate(ctx context.Context, req browser.NavigateRequest) (browser.Tab, error) {
441 var out hostBrowserTab
442 err := e.write(ctx, req.OperationID, "navigate", req.TabID, req, "host/browser.tabs.navigate", map[string]any{"tabId": req.TabID, "url": req.URL, "action": req.Action}, &out)
443 return out.tab(), err
444 }
445
446 // navigateFilePreview is renderer-only plumbing for an explicit refresh. The
447 // host still verifies task and session ownership, but does not require agent
448 // mode, so refreshing a user-taken-over page does not hand control back.
449 func (e *hostBrowserExecutor) navigateFilePreview(ctx context.Context, req browser.NavigateRequest) (browser.Tab, error) {
450 var out hostBrowserTab
451 err := e.write(ctx, req.OperationID, "navigate", req.TabID, req, "host/browser.tabs.navigate", map[string]any{
452 "tabId": req.TabID, "url": req.URL, "action": req.Action, "allowHuman": true,
453 }, &out)
454 return out.tab(), err
455 }
456
457 func (e *hostBrowserExecutor) Close(ctx context.Context, req browser.CloseRequest) error {
458 err := e.write(ctx, req.OperationID, "close", req.TabID, req, "host/browser.tabs.close", map[string]any{"tabId": req.TabID}, nil)
459 if err == nil {
460 e.app.releaseFileBrowserPreviewTab(req.TabID)
461 }
462 return err
463 }
464
465 func (e *hostBrowserExecutor) PreviewFile(ctx context.Context, req browser.FilePreviewRequest) (browser.Tab, error) {
466 _, generation, err := e.app.fileBrowserPreviewTab(e.tabID, 0)
467 if err != nil {
468 return browser.Tab{}, err
469 }
470 result, err := e.app.openFileBrowserPreview(ctx, e.tabID, FileBrowserPreviewRequest{
471 Source: req.Source, Path: req.Path, ToolCallID: req.ToolCallID, OperationID: req.OperationID,
472 ExpectedSessionGeneration: generation,
473 }, e)
474 if err != nil {
475 return browser.Tab{}, err
476 }
477 if result.Error != "" {
478 return browser.Tab{}, errors.New(result.Error)
479 }
480 return browser.Tab{ID: result.TabID, URL: result.URL, Loading: result.Status == "loading"}, nil
481 }
482
483 // Every browser write uses the same durable reservation, including history
484 // operations whose reply may disappear after the browser already navigated.
485 func (e *hostBrowserExecutor) write(ctx context.Context, id, action, tabID string, request any, method string, params map[string]any, out any) error {
486 if err := e.ensureGrant(ctx); err != nil {
487 return err
488 }
489 ledger, err := e.app.browserLedger()
490 if err != nil {
491 return err
492 }
493 digest, err := actDigest(request)
494 if err != nil {
495 return err
496 }
497 if err := ledger.Reserve(browserops.Operation{ID: id, SessionID: e.browserSessionKey(), Generation: e.grantID, TabID: tabID, Action: action, Digest: digest, DiagnosticScope: e.diagnosticScope}); err != nil {
498 if errors.Is(err, browserops.ErrDuplicateOperation) {
499 return fmt.Errorf("%w: operationId already recorded", browser.ErrUnknownOutcome)
500 }
501 return err
502 }
503 if params == nil {
504 params = map[string]any{}
505 }
506 params["operationId"] = id
507 err = e.call(ctx, method, params, out)
508 if err == nil {
509 if raw, ok := out.(*json.RawMessage); ok {
510 var receipt struct {
511 Outcome string `json:"outcome"`
512 }
513 if json.Unmarshal(*raw, &receipt) == nil && receipt.Outcome == "unknown" {
514 e.settle(ledger, id, browserops.StateUnknown, "host reported an interrupted operation")
515 return browser.ErrUnknownOutcome
516 }
517 }
518 e.settle(ledger, id, browserops.StateExecuted, "")
519 return nil
520 }
521 if errors.Is(err, browser.ErrNoGrant) || errors.Is(err, browser.ErrTakenOver) || errors.Is(err, browser.ErrStaleReference) {
522 e.settle(ledger, id, browserops.StateNotExecuted, err.Error())
523 return err
524 }
525 e.settle(ledger, id, browserops.StateUnknown, err.Error())
526 return fmt.Errorf("%w: %s", browser.ErrUnknownOutcome, err.Error())
527 }
528
529 func (e *hostBrowserExecutor) Snapshot(ctx context.Context, req browser.SnapshotRequest) (browser.Snapshot, error) {
530 var out struct {
531 Observation *browser.Observation `json:"observation"`
532 DocumentToken string `json:"documentToken"`
533 URL string `json:"url"`
534 Title string `json:"title"`
535 Tree string `json:"tree"`
536 Refs int `json:"refs"`
537 }
538 err := e.call(ctx, "host/browser.snapshot", map[string]any{"tabId": req.TabID, "selector": req.Selector}, &out)
539 return browser.Snapshot{DocumentToken: out.DocumentToken, URL: out.URL, Title: out.Title, Tree: out.Tree, Refs: out.Refs, Observation: out.Observation}, err
540 }
541
542 func (e *hostBrowserExecutor) Screenshot(ctx context.Context, req browser.ScreenshotRequest) (browser.Screenshot, error) {
543 dir, err := e.captureDir()
544 if err != nil {
545 return browser.Screenshot{}, err
546 }
547 var out struct {
548 Observation *browser.Observation `json:"observation"`
549 Path string `json:"path"`
550 MIME string `json:"mime"`
551 Width int `json:"width"`
552 Height int `json:"height"`
553 ObservationToken string `json:"observationToken"`
554 CSSWidth int `json:"cssWidth"`
555 CSSHeight int `json:"cssHeight"`
556 }
557 err = e.call(ctx, "host/browser.screenshot", map[string]any{"tabId": req.TabID, "ref": req.Ref, "fullPage": req.FullPage, "directory": dir}, &out)
558 return browser.Screenshot{Path: out.Path, MIME: out.MIME, Width: out.Width, Height: out.Height, ObservationToken: out.ObservationToken, CSSWidth: out.CSSWidth, CSSHeight: out.CSSHeight, Observation: out.Observation}, err
559 }
560
561 // captureDir is the task-owned scratch directory the shell writes captures
562 // and downloads into; it lives outside the data home and is per tab.
563 func (e *hostBrowserExecutor) captureDir() (string, error) {
564 dir := filepath.Join(os.TempDir(), "reasonix-browser", e.tabID)
565 if err := os.MkdirAll(dir, 0o700); err != nil {
566 return "", err
567 }
568 return dir, nil
569 }
570
571 func (e *hostBrowserExecutor) Downloads(ctx context.Context, req browser.DownloadsRequest) ([]browser.Download, error) {
572 var out struct {
573 Downloads []struct {
574 ID string `json:"id"`
575 URL string `json:"url"`
576 Path string `json:"path"`
577 State string `json:"state"`
578 Bytes int64 `json:"bytes"`
579 } `json:"downloads"`
580 }
581 params := map[string]any{"tabId": req.TabID, "waitForMs": req.WaitFor.Milliseconds()}
582 if err := e.call(ctx, "host/browser.downloads", params, &out); err != nil {
583 return nil, err
584 }
585 downloads := make([]browser.Download, 0, len(out.Downloads))
586 for _, d := range out.Downloads {
587 downloads = append(downloads, browser.Download{ID: d.ID, URL: d.URL, Path: d.Path, State: d.State, Bytes: d.Bytes})
588 }
589 return downloads, nil
590 }
591
592 // Act reserves the operation in the ledger before the shell touches the
593 // page and settles it from the receipt. A lost receipt stays unknown and is
594 // reported as such; the ledger rejects the same operationId forever.
595 func (e *hostBrowserExecutor) Act(ctx context.Context, req browser.ActRequest) (browser.ActResult, error) {
596 ledger, err := e.app.browserLedger()
597 if err != nil {
598 return browser.ActResult{}, err
599 }
600 if err := e.ensureGrant(ctx); err != nil {
601 return browser.ActResult{}, err
602 }
603 digest, err := actDigest(req)
604 if err != nil {
605 return browser.ActResult{}, err
606 }
607 op := browserops.Operation{
608 DiagnosticScope: e.diagnosticScope,
609 ID: req.OperationID,
610 SessionID: e.browserSessionKey(),
611 Generation: e.grantID,
612 TabID: req.TabID,
613 DocumentToken: req.DocumentToken,
614 Action: req.Action,
615 Digest: digest,
616 }
617 if err := ledger.Reserve(op); err != nil {
618 if errors.Is(err, browserops.ErrDuplicateOperation) {
619 return browser.ActResult{Outcome: browser.OutcomeUnknown}, fmt.Errorf("%w: operationId already recorded", browser.ErrUnknownOutcome)
620 }
621 return browser.ActResult{}, err
622 }
623 if req.Action == browser.ActionUpload {
624 files, cleanup, err := e.prepareUploadFiles(req.Files)
625 if err != nil {
626 e.settle(ledger, req.OperationID, browserops.StateNotExecuted, err.Error())
627 return browser.ActResult{}, err
628 }
629 defer cleanup()
630 req.Files = files
631 }
632 var out struct {
633 Executed *bool `json:"executed"`
634 Outcome string `json:"outcome"`
635 Reason string `json:"reason"`
636 DocumentToken string `json:"documentToken"`
637 }
638 params := map[string]any{
639 "operationId": req.OperationID, "tabId": req.TabID, "documentToken": req.DocumentToken,
640 "action": req.Action, "ref": req.Ref, "text": req.Text, "keys": req.Keys,
641 "options": nonNil(req.Options), "files": nonNil(req.Files), "submit": req.Submit,
642 "deltaX": req.DeltaX, "deltaY": req.DeltaY,
643 }
644 callErr := e.call(ctx, "host/browser.act", params, &out)
645 switch {
646 case callErr == nil && out.Executed == nil:
647 e.settle(ledger, req.OperationID, browserops.StateUnknown, "host returned no execution receipt")
648 return browser.ActResult{Outcome: browser.OutcomeUnknown}, browser.ErrUnknownOutcome
649 case callErr == nil && out.Outcome == browser.OutcomeUnknown:
650 e.settle(ledger, req.OperationID, browserops.StateUnknown, out.Reason)
651 return browser.ActResult{Outcome: browser.OutcomeUnknown}, fmt.Errorf("%w: %s", browser.ErrUnknownOutcome, out.Reason)
652 case callErr == nil && *out.Executed:
653 e.settle(ledger, req.OperationID, browserops.StateExecuted, "")
654 return browser.ActResult{Executed: true, Outcome: browser.OutcomeExecuted, DocumentToken: out.DocumentToken}, nil
655 case callErr == nil:
656 e.settle(ledger, req.OperationID, browserops.StateNotExecuted, out.Reason)
657 return browser.ActResult{Executed: false, Outcome: browser.OutcomeNotExecuted, Reason: out.Reason, DocumentToken: out.DocumentToken}, nil
658 case errors.Is(callErr, browser.ErrStaleReference), errors.Is(callErr, browser.ErrTakenOver), errors.Is(callErr, browser.ErrNoGrant):
659 e.settle(ledger, req.OperationID, browserops.StateNotExecuted, callErr.Error())
660 return browser.ActResult{}, callErr
661 default:
662 e.settle(ledger, req.OperationID, browserops.StateUnknown, callErr.Error())
663 return browser.ActResult{Outcome: browser.OutcomeUnknown}, fmt.Errorf("%w: %s", browser.ErrUnknownOutcome, callErr.Error())
664 }
665 }
666
667 func (e *hostBrowserExecutor) settle(ledger *browserops.Ledger, id string, state browserops.State, reason string) {
668 if err := ledger.Settle(id, state, reason); err != nil {
669 slog.Warn("desktop browser: settle operation", "operation", id, "state", state, "err", err)
670 }
671 }
672
673 func actDigest(req any) (string, error) {
674 raw, err := json.Marshal(req)
675 if err != nil {
676 return "", err
677 }
678 sum := sha256.Sum256(raw)
679 return hex.EncodeToString(sum[:]), nil
680 }
681
681 lines GO