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