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