| 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 |