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