mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-11 17:10:38 -10:00
* feat(browser): add task-bound runtime and scoped diagnostic evidence Problem: production page injection and invalid captures prevent reliable browser inspection, while session exports lack browser troubleshooting evidence. Root cause: host-coupled page scripts, missing render ownership and mixed viewport/input facts allow stale observations and callbacks across lifecycle changes. Fix: bundle isolated semantic scripts; coordinate rendering, cancellation and identity; validate images; add optional query, wait, responsive, picker, diagnostics, recording and recovery capabilities; export bounded session-owned evidence without changing standing tool schemas. Verification: 279 Electron tests and typecheck; prior full Go and native/package evidence documented in the acceptance report. Native OOPIF CDP stalls and expanded scaled-input routing remain explicit merge blockers; no retry or deadline workaround hides them. * fix(browser): preserve cancelled capability write error chains Problem: capability transport failures lost their underlying cancellation identity, and generated Desktop inventory locations were stale. Root cause: the unknown-outcome wrapper formatted the original error as text instead of wrapping it; browser diagnostic additions shifted inventory source positions. Fix: wrap both the unknown-outcome sentinel and original transport error, and regenerate the existing Desktop inventory. Verification: a server-admission cancellation regression failed before the fix and now passes for pointer, viewport and recording writes; browser tests, root/Desktop golangci-lint and inventory tests pass. * fix(browser): route pointer input through the owning renderer Problem: zoom and native surface changes could drop clicks or send their press and release to different frames. Root cause: root CDP input used asynchronous cross-process hit-test data from the previous surface; DOM geometry alone did not establish input routing readiness. Fix: resolve the current frame in isolated worlds, use direct native input for in-process targets and the owning CDP session for OOPIFs, preserve coordinate scaling, and fence preparation against cancellation and identity changes. Strengthen diagnostic export URL assertions and align the native diagnostic fixture with host observation leases. Verification: 284 Electron tests, TypeScript and production build pass; macOS production fixture passes three scale transitions and 100 mixed-frame lifecycle cycles. Windows ARM64 and Linux ARM64/Xvfb pass the renderer-owned input matrix. Historical CDP stalls remain a separate merge gate. * fix(browser): own iframe sessions for the debugger connection lifetime Problem: adjacent frame observations and actions repeatedly tore down and initialized the same OOPIF protocol domains, exposing world-creation and initialization stalls. Root cause: target attachment ownership was tied to individual operations instead of the page debugger generation; cancellation and shared initialization therefore lacked a common owner. Fix: pool target sessions per connection, retain operation-scoped contexts and object groups, retire failed sessions after their last borrower, and clean late attachments without affecting replacement sessions. Preserve the existing one-second protocol budget and unknown write outcomes. Verification: 287 Electron tests and TypeScript pass; deterministic shared-waiter, cancellation, late-attach and cleanup-order regressions pass. Native macOS validates one attachment across snapshot/ref/input/upload, a fresh attachment after idle reconnect, the scale matrix and 100 mixed-frame lifecycle cycles. Windows ARM64 and Linux ARM64/Xvfb native fixtures pass. * fix(browser): preserve renderer-local pointer coordinates Problem: Scaled iframe clicks could hit a broad button away from its observed centre, and nested same-process descendants used the wrong coordinate origin. Root cause: Child CDP sessions accept renderer-local CSS coordinates, but input reapplied outer Fit scaling and treated the innermost frame as the renderer owner. Fix: Carry the session-owning frame through the operation runtime and dispatch its unscaled traversal point. Native fixtures assert exact received coordinates for same-process descendants inside OOPIFs. Verification: 288 Electron tests, typecheck, macOS 100-cycle native fixture, Windows ARM64 and Linux ARM64 native matrices, and the complete macOS packaged browser toolchain passed. * fix(browser): publish recording completion after cleanup Problem: macOS native CI could reject a new recording immediately after the previous job reported completed. Root cause: WebM validation published terminal status before asynchronous file cleanup released the global recording slot. Fix: Keep cleanup in finalizing and publish the terminal result and artifact at the same synchronous boundary that releases admission. Apply the same invariant to cancellation and interruption. Verification: A deterministic cleanup barrier fails on the prior implementation and passes for completed and cancelled jobs. All 289 Electron tests and TypeScript checks pass; native and packaged validation continue on this head. * test(cli): join controller teardown before removing fixtures Problem: Windows CI failed removing a clear-command fixture because its recovery database was still open. Root cause: The CLI controller owner helper only requested asynchronous Close; a Running poll did not join final persistence and store release. Fix: Wait for the existing Closed signal in the shared test owner, matching control tests, and remove the redundant timed cancellation poll. Verification: Complete internal/cli tests, focused race tests, and Windows ARM64 clear/cancel native test cases passed. Product behavior and test deadlines are unchanged.
202 lines
6.5 KiB
Go
202 lines
6.5 KiB
Go
package browser
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// httpHandler serves the contract over any Executor: constant-time bearer
|
|
// check, bounded JSON bodies, sentinels as 409 bodies, everything else 500.
|
|
type httpHandler struct {
|
|
exec Executor
|
|
token string
|
|
mux *http.ServeMux
|
|
}
|
|
|
|
// NewHTTPHandler exposes exec at /v1/browser/<method> behind a bearer token.
|
|
// GET /v1/browser/health answers 204 while exec is available, 503 otherwise.
|
|
func NewHTTPHandler(exec Executor, token string) http.Handler {
|
|
h := &httpHandler{exec: exec, token: strings.TrimSpace(token), mux: http.NewServeMux()}
|
|
h.mux.HandleFunc("GET "+httpHealthRoute, h.health)
|
|
h.mux.HandleFunc("POST "+httpRoutePrefix+"tabs", h.tabs)
|
|
h.mux.HandleFunc("POST "+httpRoutePrefix+"open", h.open)
|
|
h.mux.HandleFunc("POST "+httpRoutePrefix+"navigate", h.navigate)
|
|
h.mux.HandleFunc("POST "+httpRoutePrefix+"snapshot", h.snapshot)
|
|
h.mux.HandleFunc("POST "+httpRoutePrefix+"screenshot", h.screenshot)
|
|
h.mux.HandleFunc("POST "+httpRoutePrefix+"act", h.act)
|
|
h.mux.HandleFunc("POST "+httpRoutePrefix+"downloads", h.downloads)
|
|
h.mux.HandleFunc("POST "+httpRoutePrefix+"close", h.close)
|
|
h.mux.HandleFunc("POST "+httpRoutePrefix+"capability", h.capability)
|
|
return h
|
|
}
|
|
|
|
func (h *httpHandler) capability(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
Name string `json:"name"`
|
|
Args json.RawMessage `json:"args"`
|
|
}
|
|
if !decodeBody(w, r, &in) {
|
|
return
|
|
}
|
|
exec, ok := h.exec.(CapabilityExecutor)
|
|
if !ok {
|
|
writeWireError(w, http.StatusNotImplemented, "capability_unsupported", "browser enhancements unavailable")
|
|
return
|
|
}
|
|
// Only published tools can cross this boundary; host RPC names are not input.
|
|
for _, candidate := range CapabilityTools(h.exec) {
|
|
if candidate.Name() == "browser_"+in.Name {
|
|
result, err := exec.BrowserCapability(sessionContext(r), in.Name, in.Args)
|
|
writeResult(w, result, err)
|
|
return
|
|
}
|
|
}
|
|
writeWireError(w, http.StatusNotImplemented, "capability_unsupported", "unknown browser capability")
|
|
}
|
|
|
|
func (h *httpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if !h.authorized(r) {
|
|
w.Header().Set("WWW-Authenticate", `Bearer realm="reasonix-browser"`)
|
|
writeWireError(w, http.StatusUnauthorized, "unauthorized", "invalid browser broker token")
|
|
return
|
|
}
|
|
h.mux.ServeHTTP(w, r)
|
|
}
|
|
|
|
func (h *httpHandler) authorized(r *http.Request) bool {
|
|
prefix, value, ok := strings.Cut(strings.TrimSpace(r.Header.Get("Authorization")), " ")
|
|
if !ok || !strings.EqualFold(prefix, "Bearer") || h.token == "" {
|
|
return false
|
|
}
|
|
return subtle.ConstantTimeCompare([]byte(strings.TrimSpace(value)), []byte(h.token)) == 1
|
|
}
|
|
|
|
func (h *httpHandler) health(w http.ResponseWriter, r *http.Request) {
|
|
if a, ok := h.exec.(Availability); ok && !a.Available(sessionContext(r)) {
|
|
writeWireError(w, http.StatusServiceUnavailable, "unavailable", "no browser is available for this session")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func sessionContext(r *http.Request) context.Context {
|
|
return WithSession(r.Context(), strings.TrimSpace(r.Header.Get(SessionHeader)))
|
|
}
|
|
|
|
// decode reads a bounded JSON body into in; a false return means the reply
|
|
// was already written.
|
|
func decodeBody(w http.ResponseWriter, r *http.Request, in any) bool {
|
|
r.Body = http.MaxBytesReader(w, r.Body, httpMaxRequestBytes)
|
|
if err := json.NewDecoder(r.Body).Decode(in); err != nil {
|
|
writeWireError(w, http.StatusBadRequest, "bad_request", "invalid JSON body: "+err.Error())
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func writeWireError(w http.ResponseWriter, status int, code, message string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(wireError{Error: code, Message: message})
|
|
}
|
|
|
|
// writeResult maps err onto the wire: sentinels become 409 with their code,
|
|
// anything else is a 500 the client reports as a plain error.
|
|
func writeResult(w http.ResponseWriter, v any, err error) {
|
|
if err != nil {
|
|
for _, code := range []string{wireStaleReference, wireTakenOver, wireNoGrant, wireUnknownOutcome} {
|
|
if errors.Is(err, wireErrorCodes[code]) {
|
|
writeWireError(w, http.StatusConflict, code, err.Error())
|
|
return
|
|
}
|
|
}
|
|
writeWireError(w, http.StatusInternalServerError, "failed", err.Error())
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func (h *httpHandler) tabs(w http.ResponseWriter, r *http.Request) {
|
|
var in struct{}
|
|
if !decodeBody(w, r, &in) {
|
|
return
|
|
}
|
|
tabs, err := h.exec.Tabs(sessionContext(r))
|
|
out := wireTabs{Tabs: make([]wireTab, 0, len(tabs))}
|
|
for _, t := range tabs {
|
|
out.Tabs = append(out.Tabs, toWireTab(t))
|
|
}
|
|
writeResult(w, out, err)
|
|
}
|
|
|
|
func (h *httpHandler) open(w http.ResponseWriter, r *http.Request) {
|
|
var in wireOpenRequest
|
|
if !decodeBody(w, r, &in) {
|
|
return
|
|
}
|
|
tab, err := h.exec.Open(sessionContext(r), OpenRequest(in))
|
|
writeResult(w, toWireTab(tab), err)
|
|
}
|
|
|
|
func (h *httpHandler) navigate(w http.ResponseWriter, r *http.Request) {
|
|
var in wireNavigateRequest
|
|
if !decodeBody(w, r, &in) {
|
|
return
|
|
}
|
|
tab, err := h.exec.Navigate(sessionContext(r), NavigateRequest(in))
|
|
writeResult(w, toWireTab(tab), err)
|
|
}
|
|
|
|
func (h *httpHandler) snapshot(w http.ResponseWriter, r *http.Request) {
|
|
var in wireSnapshotRequest
|
|
if !decodeBody(w, r, &in) {
|
|
return
|
|
}
|
|
snap, err := h.exec.Snapshot(sessionContext(r), SnapshotRequest(in))
|
|
writeResult(w, wireSnapshot(snap), err)
|
|
}
|
|
|
|
func (h *httpHandler) screenshot(w http.ResponseWriter, r *http.Request) {
|
|
var in wireScreenshotRequest
|
|
if !decodeBody(w, r, &in) {
|
|
return
|
|
}
|
|
shot, err := h.exec.Screenshot(sessionContext(r), ScreenshotRequest(in))
|
|
writeResult(w, wireScreenshot(shot), err)
|
|
}
|
|
|
|
func (h *httpHandler) act(w http.ResponseWriter, r *http.Request) {
|
|
var in wireActRequest
|
|
if !decodeBody(w, r, &in) {
|
|
return
|
|
}
|
|
res, err := h.exec.Act(sessionContext(r), in.request())
|
|
writeResult(w, wireActResult(res), err)
|
|
}
|
|
|
|
func (h *httpHandler) downloads(w http.ResponseWriter, r *http.Request) {
|
|
var in wireDownloadsRequest
|
|
if !decodeBody(w, r, &in) {
|
|
return
|
|
}
|
|
downloads, err := h.exec.Downloads(sessionContext(r), in.request())
|
|
out := wireDownloads{Downloads: make([]wireDownload, 0, len(downloads))}
|
|
for _, d := range downloads {
|
|
out.Downloads = append(out.Downloads, wireDownload(d))
|
|
}
|
|
writeResult(w, out, err)
|
|
}
|
|
|
|
func (h *httpHandler) close(w http.ResponseWriter, r *http.Request) {
|
|
var in wireCloseRequest
|
|
if !decodeBody(w, r, &in) {
|
|
return
|
|
}
|
|
writeResult(w, struct{}{}, h.exec.Close(sessionContext(r), CloseRequest(in)))
|
|
}
|