返回 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 granted atomic.Bool
50 revoked atomic.Bool
51 grantMu sync.Mutex
52 }
53
54 type hostBrowserTab struct {
55 ID string `json:"id"`
56 URL string `json:"url"`
57 Title string `json:"title"`
58 Loading bool `json:"loading"`
59 Temporary bool `json:"temporary"`
60 }
61
62 func (t hostBrowserTab) tab() browser.Tab {
63 return browser.Tab{ID: t.ID, URL: t.URL, Title: t.Title, Loading: t.Loading, Temporary: t.Temporary}
64 }
65
66 func (a *App) browserExecutorForTab(tab *WorkspaceTab) browser.Executor {
67 if tab == nil || !a.hostMode() || a.browserControl.off() {
68 return nil
69 }
70 a.browserExecMu.Lock()
71 defer a.browserExecMu.Unlock()
72 if a.browserExecutors == nil {
73 a.browserExecutors = map[string]*hostBrowserExecutor{}
74 }
75 if exec, ok := a.browserExecutors[tab.ID]; ok {
76 return exec
77 }
78 exec := &hostBrowserExecutor{app: a, host: a.hostShell.server, tabID: tab.ID, grantID: newBrowserGrantID()}
79 a.browserExecutors[tab.ID] = exec
80 return exec
81 }
82
83 func newBrowserGrantID() string {
84 buf := make([]byte, 32)
85 if _, err := rand.Read(buf); err != nil {
86 panic(err)
87 }
88 return "grant-" + hex.EncodeToString(buf)
89 }
90
91 func (a *App) revokeRemoteBrowserHost(hostID string) {
92 a.remoteTabMu.Lock()
93 var ids []string
94 for _, tab := range a.remoteTabs {
95 if tab != nil && tab.ref.HostID == hostID {
96 ids = append(ids, tab.id)
97 }
98 }
99 a.remoteTabMu.Unlock()
100 for _, id := range ids {
101 a.forgetRemoteBrowserExecutor(id)
102 }
103 }
104
105 // forgetBrowserExecutorLocked drops the tab's executor and revokes its grant
106 // off the caller's lock; a revoked executor fails closed forever.
107 func (a *App) forgetBrowserExecutorLocked(tabID string) {
108 a.browserExecMu.Lock()
109 exec, ok := a.browserExecutors[tabID]
110 delete(a.browserExecutors, tabID)
111 a.browserExecMu.Unlock()
112 if !ok {
113 return
114 }
115 a.revokeBrowserExecutor(exec)
116 }
117
118 func (a *App) revokeBrowserExecutor(exec *hostBrowserExecutor) {
119 exec.revoked.Store(true)
120 a.goSafe("revokeBrowserGrant", func() {
121 exec.grantMu.Lock()
122 defer exec.grantMu.Unlock()
123 ctx, cancel := context.WithTimeout(context.Background(), rpcHostWindowTimeout)
124 defer cancel()
125 _ = exec.host.Request(ctx, "host/browser.revoke", map[string]string{"grantId": exec.grantID}, nil)
126 })
127 }
128
129 // forgetRemoteBrowserExecutor drops the broker executor of a closed remote
130 // tab; its grant ID is namespaced with the "remote/" prefix used at creation.
131 func (a *App) forgetRemoteBrowserExecutor(remoteTabID string) {
132 a.forgetBrowserExecutorLocked("remote/" + remoteTabID)
133 }
134
135 func (a *App) browserLedger() (*browserops.Ledger, error) {
136 a.browserExecMu.Lock()
137 defer a.browserExecMu.Unlock()
138 if a.browserOps != nil {
139 return a.browserOps, nil
140 }
141 ledger, err := browserops.Open(filepath.Join(config.MemoryUserDir(), "browser", "operations-v1.json"))
142 if err != nil {
143 return nil, err
144 }
145 a.browserOps = ledger
146 return ledger, nil
147 }
148
149 func (e *hostBrowserExecutor) Available(context.Context) bool {
150 return !e.revoked.Load() && e.app.hostMode()
151 }
152
153 // browserSessionKey is the session identity the grant binds to: the explicit
154 // override for broker-created executors, else the workspace tab's session.
155 func (e *hostBrowserExecutor) browserSessionKey() string {
156 if e.sessionKey != "" {
157 return e.sessionKey
158 }
159 return e.app.tabSessionKeyForBrowser(e.tabID)
160 }
161
162 func (e *hostBrowserExecutor) ensureGrant(ctx context.Context) error {
163 if err := ctx.Err(); err != nil {
164 return err
165 }
166 if e.revoked.Load() {
167 return browser.ErrNoGrant
168 }
169 if e.granted.Load() {
170 return nil
171 }
172 e.grantMu.Lock()
173 defer e.grantMu.Unlock()
174 if e.revoked.Load() {
175 return browser.ErrNoGrant
176 }
177 if e.granted.Load() {
178 return nil
179 }
180 params := map[string]string{"grantId": e.grantID, "tabId": e.tabID, "sessionId": e.browserSessionKey()}
181 if err := e.host.Request(ctx, "host/browser.grant", params, nil); err != nil {
182 return mapHostBrowserError(err)
183 }
184 if e.revoked.Load() {
185 return browser.ErrNoGrant
186 }
187 e.granted.Store(true)
188 return nil
189 }
190
191 func (a *App) tabSessionKeyForBrowser(tabID string) string {
192 a.mu.RLock()
193 defer a.mu.RUnlock()
194 if tab, ok := a.tabs[tabID]; ok {
195 return tab.SessionPath
196 }
197 return ""
198 }
199
200 func (e *hostBrowserExecutor) call(ctx context.Context, method string, params map[string]any, result any) error {
201 if err := e.ensureGrant(ctx); err != nil {
202 return err
203 }
204 if err := ctx.Err(); err != nil {
205 return err
206 }
207 if params == nil {
208 params = map[string]any{}
209 }
210 params["grantId"] = e.grantID
211 ctx, cancel := context.WithTimeout(ctx, hostBrowserReadTimeout)
212 defer cancel()
213 if err := e.host.Request(ctx, method, params, result); err != nil {
214 return mapHostBrowserError(err)
215 }
216 return nil
217 }
218
219 func mapHostBrowserError(err error) error {
220 var resp *rpcwire.ResponseError
221 if errors.As(err, &resp) {
222 switch resp.Code {
223 case hostBrowserErrStaleReference:
224 return browser.ErrStaleReference
225 case hostBrowserErrTakenOver:
226 return browser.ErrTakenOver
227 case hostBrowserErrNoGrant:
228 return browser.ErrNoGrant
229 }
230 return fmt.Errorf("browser host: %s", resp.Message)
231 }
232 return err
233 }
234
235 func (e *hostBrowserExecutor) Tabs(ctx context.Context) ([]browser.Tab, error) {
236 var out struct {
237 Tabs []hostBrowserTab `json:"tabs"`
238 }
239 if err := e.call(ctx, "host/browser.tabs.list", nil, &out); err != nil {
240 return nil, err
241 }
242 tabs := make([]browser.Tab, 0, len(out.Tabs))
243 for _, t := range out.Tabs {
244 tabs = append(tabs, t.tab())
245 }
246 return tabs, nil
247 }
248
249 func (e *hostBrowserExecutor) Open(ctx context.Context, req browser.OpenRequest) (browser.Tab, error) {
250 var out hostBrowserTab
251 err := e.write(ctx, req.OperationID, "open", "", req, "host/browser.tabs.open", map[string]any{"url": req.URL, "temporary": req.Temporary}, &out)
252 return out.tab(), err
253 }
254
255 func (e *hostBrowserExecutor) Navigate(ctx context.Context, req browser.NavigateRequest) (browser.Tab, error) {
256 var out hostBrowserTab
257 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)
258 return out.tab(), err
259 }
260
261 func (e *hostBrowserExecutor) Close(ctx context.Context, req browser.CloseRequest) error {
262 return e.write(ctx, req.OperationID, "close", req.TabID, req, "host/browser.tabs.close", map[string]any{"tabId": req.TabID}, nil)
263 }
264
265 // Every browser write uses the same durable reservation, including history
266 // operations whose reply may disappear after the browser already navigated.
267 func (e *hostBrowserExecutor) write(ctx context.Context, id, action, tabID string, request any, method string, params map[string]any, out any) error {
268 if err := e.ensureGrant(ctx); err != nil {
269 return err
270 }
271 ledger, err := e.app.browserLedger()
272 if err != nil {
273 return err
274 }
275 digest, err := actDigest(request)
276 if err != nil {
277 return err
278 }
279 if err := ledger.Reserve(browserops.Operation{ID: id, SessionID: e.browserSessionKey(), Generation: e.grantID, TabID: tabID, Action: action, Digest: digest}); err != nil {
280 if errors.Is(err, browserops.ErrDuplicateOperation) {
281 return fmt.Errorf("%w: operationId already recorded", browser.ErrUnknownOutcome)
282 }
283 return err
284 }
285 err = e.call(ctx, method, params, out)
286 if err == nil {
287 e.settle(ledger, id, browserops.StateExecuted, "")
288 return nil
289 }
290 if errors.Is(err, browser.ErrNoGrant) || errors.Is(err, browser.ErrTakenOver) || errors.Is(err, browser.ErrStaleReference) {
291 e.settle(ledger, id, browserops.StateNotExecuted, err.Error())
292 return err
293 }
294 e.settle(ledger, id, browserops.StateUnknown, err.Error())
295 return fmt.Errorf("%w: %s", browser.ErrUnknownOutcome, err.Error())
296 }
297
298 func (e *hostBrowserExecutor) Snapshot(ctx context.Context, req browser.SnapshotRequest) (browser.Snapshot, error) {
299 var out struct {
300 DocumentToken string `json:"documentToken"`
301 URL string `json:"url"`
302 Title string `json:"title"`
303 Tree string `json:"tree"`
304 Refs int `json:"refs"`
305 }
306 err := e.call(ctx, "host/browser.snapshot", map[string]any{"tabId": req.TabID, "selector": req.Selector}, &out)
307 return browser.Snapshot{DocumentToken: out.DocumentToken, URL: out.URL, Title: out.Title, Tree: out.Tree, Refs: out.Refs}, err
308 }
309
310 func (e *hostBrowserExecutor) Screenshot(ctx context.Context, req browser.ScreenshotRequest) (browser.Screenshot, error) {
311 dir, err := e.captureDir()
312 if err != nil {
313 return browser.Screenshot{}, err
314 }
315 var out struct {
316 Path string `json:"path"`
317 MIME string `json:"mime"`
318 Width int `json:"width"`
319 Height int `json:"height"`
320 }
321 err = e.call(ctx, "host/browser.screenshot", map[string]any{"tabId": req.TabID, "ref": req.Ref, "fullPage": req.FullPage, "directory": dir}, &out)
322 return browser.Screenshot{Path: out.Path, MIME: out.MIME, Width: out.Width, Height: out.Height}, err
323 }
324
325 // captureDir is the task-owned scratch directory the shell writes captures
326 // and downloads into; it lives outside the data home and is per tab.
327 func (e *hostBrowserExecutor) captureDir() (string, error) {
328 dir := filepath.Join(os.TempDir(), "reasonix-browser", e.tabID)
329 if err := os.MkdirAll(dir, 0o700); err != nil {
330 return "", err
331 }
332 return dir, nil
333 }
334
335 func (e *hostBrowserExecutor) Downloads(ctx context.Context, req browser.DownloadsRequest) ([]browser.Download, error) {
336 var out struct {
337 Downloads []struct {
338 ID string `json:"id"`
339 URL string `json:"url"`
340 Path string `json:"path"`
341 State string `json:"state"`
342 Bytes int64 `json:"bytes"`
343 } `json:"downloads"`
344 }
345 params := map[string]any{"tabId": req.TabID, "waitForMs": req.WaitFor.Milliseconds()}
346 if err := e.call(ctx, "host/browser.downloads", params, &out); err != nil {
347 return nil, err
348 }
349 downloads := make([]browser.Download, 0, len(out.Downloads))
350 for _, d := range out.Downloads {
351 downloads = append(downloads, browser.Download{ID: d.ID, URL: d.URL, Path: d.Path, State: d.State, Bytes: d.Bytes})
352 }
353 return downloads, nil
354 }
355
356 // Act reserves the operation in the ledger before the shell touches the
357 // page and settles it from the receipt. A lost receipt stays unknown and is
358 // reported as such; the ledger rejects the same operationId forever.
359 func (e *hostBrowserExecutor) Act(ctx context.Context, req browser.ActRequest) (browser.ActResult, error) {
360 ledger, err := e.app.browserLedger()
361 if err != nil {
362 return browser.ActResult{}, err
363 }
364 if err := e.ensureGrant(ctx); err != nil {
365 return browser.ActResult{}, err
366 }
367 digest, err := actDigest(req)
368 if err != nil {
369 return browser.ActResult{}, err
370 }
371 op := browserops.Operation{
372 ID: req.OperationID,
373 SessionID: e.browserSessionKey(),
374 Generation: e.grantID,
375 TabID: req.TabID,
376 DocumentToken: req.DocumentToken,
377 Action: req.Action,
378 Digest: digest,
379 }
380 if err := ledger.Reserve(op); err != nil {
381 if errors.Is(err, browserops.ErrDuplicateOperation) {
382 return browser.ActResult{Outcome: browser.OutcomeUnknown}, fmt.Errorf("%w: operationId already recorded", browser.ErrUnknownOutcome)
383 }
384 return browser.ActResult{}, err
385 }
386 if req.Action == browser.ActionUpload {
387 files, cleanup, err := e.prepareUploadFiles(req.Files)
388 if err != nil {
389 e.settle(ledger, req.OperationID, browserops.StateNotExecuted, err.Error())
390 return browser.ActResult{}, err
391 }
392 defer cleanup()
393 req.Files = files
394 }
395 var out struct {
396 Executed *bool `json:"executed"`
397 Outcome string `json:"outcome"`
398 Reason string `json:"reason"`
399 DocumentToken string `json:"documentToken"`
400 }
401 params := map[string]any{
402 "operationId": req.OperationID, "tabId": req.TabID, "documentToken": req.DocumentToken,
403 "action": req.Action, "ref": req.Ref, "text": req.Text, "keys": req.Keys,
404 "options": nonNil(req.Options), "files": nonNil(req.Files), "submit": req.Submit,
405 "deltaX": req.DeltaX, "deltaY": req.DeltaY,
406 }
407 callErr := e.call(ctx, "host/browser.act", params, &out)
408 switch {
409 case callErr == nil && out.Executed == nil:
410 e.settle(ledger, req.OperationID, browserops.StateUnknown, "host returned no execution receipt")
411 return browser.ActResult{Outcome: browser.OutcomeUnknown}, browser.ErrUnknownOutcome
412 case callErr == nil && out.Outcome == browser.OutcomeUnknown:
413 e.settle(ledger, req.OperationID, browserops.StateUnknown, out.Reason)
414 return browser.ActResult{Outcome: browser.OutcomeUnknown}, fmt.Errorf("%w: %s", browser.ErrUnknownOutcome, out.Reason)
415 case callErr == nil && *out.Executed:
416 e.settle(ledger, req.OperationID, browserops.StateExecuted, "")
417 return browser.ActResult{Executed: true, Outcome: browser.OutcomeExecuted, DocumentToken: out.DocumentToken}, nil
418 case callErr == nil:
419 e.settle(ledger, req.OperationID, browserops.StateNotExecuted, out.Reason)
420 return browser.ActResult{Executed: false, Outcome: browser.OutcomeNotExecuted, Reason: out.Reason, DocumentToken: out.DocumentToken}, nil
421 case errors.Is(callErr, browser.ErrStaleReference), errors.Is(callErr, browser.ErrTakenOver), errors.Is(callErr, browser.ErrNoGrant):
422 e.settle(ledger, req.OperationID, browserops.StateNotExecuted, callErr.Error())
423 return browser.ActResult{}, callErr
424 default:
425 e.settle(ledger, req.OperationID, browserops.StateUnknown, callErr.Error())
426 return browser.ActResult{Outcome: browser.OutcomeUnknown}, fmt.Errorf("%w: %s", browser.ErrUnknownOutcome, callErr.Error())
427 }
428 }
429
430 func (e *hostBrowserExecutor) settle(ledger *browserops.Ledger, id string, state browserops.State, reason string) {
431 if err := ledger.Settle(id, state, reason); err != nil {
432 slog.Warn("desktop browser: settle operation", "operation", id, "state", state, "err", err)
433 }
434 }
435
436 func actDigest(req any) (string, error) {
437 raw, err := json.Marshal(req)
438 if err != nil {
439 return "", err
440 }
441 sum := sha256.Sum256(raw)
442 return hex.EncodeToString(sum[:]), nil
443 }
444
444 lines GO