返回 DeepSeek-Reasonix
browser_broker.go
根目录 / internal / serve / browser_broker.go
1 package serve
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "net"
9 "net/http"
10 "net/url"
11 "strings"
12 "sync"
13
14 "reasonix/internal/browser"
15 "reasonix/internal/control"
16 "reasonix/internal/servecontract"
17 )
18
19 // Capability tokens advertised on the /auth/token handshake reply so a
20 // desktop can tell what this serve supports without a second round trip.
21 const (
22 capabilitiesHeader = "X-Reasonix-Serve-Capabilities"
23 capabilityBrowser = "browser"
24 capabilityPermissionPresets = "permission-presets-v1"
25 capabilityPresentFiles = "present-files-v1"
26 capabilityExecutionV2 = "execution-v2"
27 capabilitySessionHistory = "session-history-v1"
28 capabilitySessionIdentityV1 = "session-identity-v1"
29 capabilitySessionOwnershipV1 = "session-ownership-v1"
30 capabilitySessionContentV1 = "session-content-v1"
31 capabilitySessionReadV2 = "session-read-v2"
32 capabilityHistoryWindowV1 = "history-window-v1"
33 capabilityExtensionFormInstanceV1 = "extension-form-instance-v1"
34 capabilityInteractionTargetV1 = "interaction-target-v1"
35 capabilityGoalLifecycleV2 = servecontract.GoalLifecycleV2
36 capabilityTranscriptOutline = servecontract.TranscriptOutlineV1
37 capabilityForkTargetsV1 = servecontract.SessionForkTargetsV1
38 capabilityAttachmentsV1 = "attachments-v1"
39 )
40
41 // BrowserBroker is Serve's end of the desktop browser broker: one HTTP
42 // executor whose endpoint and token follow the SSH connection generation. The
43 // desktop rebinds it after a reconnect through POST /browser/broker, so the
44 // controllers built around it never need a rebuild to keep their browser.
45 type BrowserBroker struct {
46 mu sync.Mutex
47 exec browser.Executor
48 endpoint string
49 }
50
51 // NewBrowserBroker dials the broker at endpoint, which must be a loopback
52 // http URL: the token authorises browser writes and must not leave the host.
53 func NewBrowserBroker(endpoint, token string) (*BrowserBroker, error) {
54 b := &BrowserBroker{}
55 if err := b.Rebind(endpoint, token); err != nil {
56 return nil, err
57 }
58 return b, nil
59 }
60
61 // Rebind points every session at a new endpoint and token; the previous
62 // generation's health cache is dropped with the executor that held it.
63 func (b *BrowserBroker) Rebind(endpoint, token string) error {
64 endpoint = strings.TrimSpace(endpoint)
65 token = strings.TrimSpace(token)
66 if err := validateBrokerEndpoint(endpoint); err != nil {
67 return err
68 }
69 if token == "" {
70 return errors.New("browser broker: token is required")
71 }
72 b.mu.Lock()
73 b.exec = browser.NewHTTPExecutor(endpoint, token, nil)
74 b.endpoint = endpoint
75 b.mu.Unlock()
76 return nil
77 }
78
79 // Endpoint reports the current broker URL.
80 func (b *BrowserBroker) Endpoint() string {
81 b.mu.Lock()
82 defer b.mu.Unlock()
83 return b.endpoint
84 }
85
86 func (b *BrowserBroker) current() browser.Executor {
87 b.mu.Lock()
88 defer b.mu.Unlock()
89 return b.exec
90 }
91
92 // BrowserBroker itself satisfies browser.Executor by delegating to the current
93 // generation without a session scope; controllers should prefer the scoped
94 // view from ForSession, and these methods exist so the broker can sit in
95 // boot.Options.BrowserExecutor until one is derived.
96 func (b *BrowserBroker) Available(ctx context.Context) bool {
97 exec := b.current()
98 if a, ok := exec.(browser.Availability); ok {
99 return a.Available(ctx)
100 }
101 return exec != nil
102 }
103
104 func (b *BrowserBroker) Tabs(ctx context.Context) ([]browser.Tab, error) {
105 return b.current().Tabs(ctx)
106 }
107
108 func (b *BrowserBroker) Open(ctx context.Context, req browser.OpenRequest) (browser.Tab, error) {
109 return b.current().Open(ctx, req)
110 }
111
112 func (b *BrowserBroker) Navigate(ctx context.Context, req browser.NavigateRequest) (browser.Tab, error) {
113 return b.current().Navigate(ctx, req)
114 }
115
116 func (b *BrowserBroker) Snapshot(ctx context.Context, req browser.SnapshotRequest) (browser.Snapshot, error) {
117 return b.current().Snapshot(ctx, req)
118 }
119
120 func (b *BrowserBroker) Screenshot(ctx context.Context, req browser.ScreenshotRequest) (browser.Screenshot, error) {
121 return b.current().Screenshot(ctx, req)
122 }
123
124 func (b *BrowserBroker) Act(ctx context.Context, req browser.ActRequest) (browser.ActResult, error) {
125 return b.current().Act(ctx, req)
126 }
127
128 func (b *BrowserBroker) Downloads(ctx context.Context, req browser.DownloadsRequest) ([]browser.Download, error) {
129 return b.current().Downloads(ctx, req)
130 }
131
132 func (b *BrowserBroker) Close(ctx context.Context, req browser.CloseRequest) error {
133 return b.current().Close(ctx, req)
134 }
135
136 func validateBrokerEndpoint(endpoint string) error {
137 u, err := url.Parse(endpoint)
138 if err != nil || u.Scheme != "http" || u.Path != "" && u.Path != "/" || u.RawQuery != "" || u.Fragment != "" || u.User != nil {
139 return fmt.Errorf("browser broker: endpoint %q must be a plain http loopback URL", endpoint)
140 }
141 host := u.Hostname()
142 if ip := net.ParseIP(host); ip == nil || !ip.IsLoopback() {
143 return fmt.Errorf("browser broker: endpoint %q must point at a loopback address", endpoint)
144 }
145 if u.Port() == "" {
146 return fmt.Errorf("browser broker: endpoint %q must carry a port", endpoint)
147 }
148 return nil
149 }
150
151 // ForSession returns the Executor one controller builds around: every call
152 // is scoped to the session the tag currently routes, so the desktop broker
153 // can bind the request to exactly one desktop tab.
154 func (b *BrowserBroker) ForSession(tag *SessionTagSink) browser.Executor {
155 if b == nil {
156 return nil
157 }
158 return sessionBrowserExecutor{broker: b, tag: tag}
159 }
160
161 // sessionBrowserExecutor is a per-controller view over the shared broker.
162 type sessionBrowserExecutor struct {
163 broker *BrowserBroker
164 tag *sessionTagSink
165 }
166
167 func (s sessionBrowserExecutor) scope(ctx context.Context) (context.Context, browser.Executor) {
168 if s.tag != nil {
169 ctx = browser.WithSession(ctx, s.tag.Path())
170 }
171 return ctx, s.broker.current()
172 }
173
174 func (s sessionBrowserExecutor) Available(ctx context.Context) bool {
175 ctx, exec := s.scope(ctx)
176 if a, ok := exec.(browser.Availability); ok {
177 return a.Available(ctx)
178 }
179 return exec != nil
180 }
181
182 func (s sessionBrowserExecutor) Tabs(ctx context.Context) ([]browser.Tab, error) {
183 ctx, exec := s.scope(ctx)
184 return exec.Tabs(ctx)
185 }
186
187 func (s sessionBrowserExecutor) Open(ctx context.Context, req browser.OpenRequest) (browser.Tab, error) {
188 ctx, exec := s.scope(ctx)
189 return exec.Open(ctx, req)
190 }
191
192 func (s sessionBrowserExecutor) Navigate(ctx context.Context, req browser.NavigateRequest) (browser.Tab, error) {
193 ctx, exec := s.scope(ctx)
194 return exec.Navigate(ctx, req)
195 }
196
197 func (s sessionBrowserExecutor) Snapshot(ctx context.Context, req browser.SnapshotRequest) (browser.Snapshot, error) {
198 ctx, exec := s.scope(ctx)
199 return exec.Snapshot(ctx, req)
200 }
201
202 func (s sessionBrowserExecutor) Screenshot(ctx context.Context, req browser.ScreenshotRequest) (browser.Screenshot, error) {
203 ctx, exec := s.scope(ctx)
204 return exec.Screenshot(ctx, req)
205 }
206
207 func (s sessionBrowserExecutor) Act(ctx context.Context, req browser.ActRequest) (browser.ActResult, error) {
208 ctx, exec := s.scope(ctx)
209 return exec.Act(ctx, req)
210 }
211
212 func (s sessionBrowserExecutor) Downloads(ctx context.Context, req browser.DownloadsRequest) ([]browser.Download, error) {
213 ctx, exec := s.scope(ctx)
214 return exec.Downloads(ctx, req)
215 }
216
217 func (s sessionBrowserExecutor) Close(ctx context.Context, req browser.CloseRequest) error {
218 ctx, exec := s.scope(ctx)
219 return exec.Close(ctx, req)
220 }
221
222 // sessionBrowserExecutor binds the configured executor to one controller's
223 // tag. A broker gains the session scope; any other executor (tests, embedded
224 // hosts) is handed through untouched.
225 func (s *Server) sessionBrowserExecutor(tag *sessionTagSink) browser.Executor {
226 if b, ok := s.buildOptions.BrowserExecutor.(*BrowserBroker); ok {
227 return b.ForSession(tag)
228 }
229 return s.buildOptions.BrowserExecutor
230 }
231
232 func (s *Server) browserBroker() *BrowserBroker {
233 b, _ := s.buildOptions.BrowserExecutor.(*BrowserBroker)
234 return b
235 }
236
237 // capabilities lists what the handshake advertises to the desktop.
238 func (s *Server) capabilities() []string {
239 caps := []string{
240 capabilityPermissionPresets,
241 capabilityPresentFiles,
242 capabilityExecutionV2,
243 capabilitySessionHistory,
244 }
245 if identity, ok := s.ctl().(control.IdentityLifecycle); ok && identity.UsesExclusiveSession() {
246 if _, ok := s.ctl().(*control.Controller); ok {
247 caps = append(caps, servecontract.SubmissionIdentityV1)
248 }
249 caps = append(caps, capabilitySessionIdentityV1, capabilitySessionOwnershipV1, capabilitySessionContentV1, capabilitySessionReadV2, capabilityHistoryWindowV1, capabilityGoalLifecycleV2, capabilityForkTargetsV1, servecontract.TranscriptV2, servecontract.SessionExportV1)
250 }
251 if _, ok := s.ctl().(interface {
252 SubmitExtensionFormExact(context.Context, string, string, uint64, string, map[string]any) error
253 }); ok {
254 caps = append(caps, capabilityExtensionFormInstanceV1)
255 }
256 if _, ok := s.ctl().(exactPromptResolver); ok {
257 caps = append(caps, capabilityInteractionTargetV1)
258 }
259 if s.buildOptions.BrowserExecutor != nil {
260 caps = append(caps, capabilityBrowser)
261 }
262 // Announce the outline from the same capability the route enforces, so a
263 // controller without the projection never advertises a route that answers
264 // 501.
265 if _, ok := s.ctl().(control.TranscriptOutlineAPI); ok {
266 caps = append(caps, capabilityTranscriptOutline)
267 }
268 return caps
269 }
270
271 // browserBrokerRebind lets the desktop hand a reused serve the broker of a
272 // new SSH connection generation: the reverse forward moved and the token
273 // rotated, and the process environment cannot follow.
274 func (s *Server) browserBrokerRebind(w http.ResponseWriter, r *http.Request) {
275 broker := s.browserBroker()
276 if broker == nil {
277 http.Error(w, "this serve was started without a browser broker", http.StatusConflict)
278 return
279 }
280 var body struct {
281 Endpoint string `json:"endpoint"`
282 Token string `json:"token"`
283 }
284 r.Body = http.MaxBytesReader(w, r.Body, 8<<10)
285 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
286 http.Error(w, "Bad Request", http.StatusBadRequest)
287 return
288 }
289 if err := broker.Rebind(body.Endpoint, body.Token); err != nil {
290 http.Error(w, err.Error(), http.StatusBadRequest)
291 return
292 }
293 w.WriteHeader(http.StatusNoContent)
294 }
295
295 lines GO