| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "io" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "sync" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "reasonix/internal/config" |
| 14 | ) |
| 15 | |
| 16 | // fakeServe is a minimal Serve stand-in for bridge tests: token handshake, |
| 17 | // session enter, an SSE feed that emits two frames then holds, and recorded |
| 18 | // command endpoints for the proxy bindings. |
| 19 | type fakeServe struct { |
| 20 | t *testing.T |
| 21 | token string |
| 22 | server *httptest.Server |
| 23 | |
| 24 | mu sync.Mutex |
| 25 | newCalled int |
| 26 | newSessionPath string |
| 27 | resumePath string |
| 28 | resumeSessionID string |
| 29 | cookieOnNew bool |
| 30 | sessions []serveSessionEntry |
| 31 | calls []string // "METHOD /path body" per command request |
| 32 | expectedPaths []string // foreground command fence headers |
| 33 | failNext string // non-empty ⇒ next command endpoint replies 409 with this text |
| 34 | failEnter string // non-empty ⇒ next /new or /resume replies 409 |
| 35 | enterDelay time.Duration |
| 36 | newStarted, newRelease chan struct{} |
| 37 | resumeStarted chan string |
| 38 | resumeRelease chan struct{} |
| 39 | failHistory bool // /history replies 500 when set |
| 40 | historyBody string |
| 41 | historyStarted, historyRelease chan struct{} |
| 42 | failSessions bool // /sessions replies 500 when set |
| 43 | sessionsFailCount int // /sessions replies 500 this many times, then recovers |
| 44 | resumeDropCount int // /resume commits the switch but drops the connection unanswered this many times |
| 45 | sessionsStarted chan struct{} |
| 46 | sessionsRelease chan struct{} |
| 47 | eventsConns int // /events connections opened |
| 48 | eventsQuery string |
| 49 | eventFrames []string |
| 50 | eventFeed <-chan string |
| 51 | eventsStatus int // non-zero makes /events fail before opening |
| 52 | eventsFailCount int // refuse this many /events opens with 503, then serve normally |
| 53 | eventsCloseEarly bool // return immediately after the initial 200 frames |
| 54 | statusPayload string |
| 55 | statusAfterCancel string |
| 56 | } |
| 57 | |
| 58 | func (fs *fakeServe) eventsCount() int { fs.mu.Lock(); defer fs.mu.Unlock(); return fs.eventsConns } |
| 59 | |
| 60 | func (fs *fakeServe) recorded() []string { |
| 61 | fs.mu.Lock() |
| 62 | defer fs.mu.Unlock() |
| 63 | out := make([]string, len(fs.calls)) |
| 64 | copy(out, fs.calls) |
| 65 | return out |
| 66 | } |
| 67 | |
| 68 | func (fs *fakeServe) recordedExpectedPaths() []string { |
| 69 | fs.mu.Lock() |
| 70 | defer fs.mu.Unlock() |
| 71 | return append([]string(nil), fs.expectedPaths...) |
| 72 | } |
| 73 | |
| 74 | func (fs *fakeServe) record(method, path, body string) { |
| 75 | fs.mu.Lock() |
| 76 | fs.calls = append(fs.calls, method+" "+path+" "+body) |
| 77 | fs.mu.Unlock() |
| 78 | } |
| 79 | |
| 80 | // newFakeServe builds a stand-in for the workspace Serve's HTTP surface. The |
| 81 | // mux is wrapped in a token-mode gate that mirrors the real authGate's |
| 82 | // contract: POST /auth/token is matched on the EXACT path (a "//auth/token" |
| 83 | // double slash — what naive base+path joins produce from EnsureServer's |
| 84 | // trailing-slash LocalURL — is denied with 401 before routing), and every |
| 85 | // other path requires the session cookie the bootstrap installs. A bare mux |
| 86 | // cannot catch this: it 301-redirects unclean paths and Go's client follows |
| 87 | // preserving POST, so a double-slash request would silently succeed here |
| 88 | // while the real Serve rejects it. |
| 89 | func newFakeServe(t *testing.T, token string, sessions []serveSessionEntry) *fakeServe { |
| 90 | t.Helper() |
| 91 | fs := &fakeServe{t: t, token: token, sessions: sessions} |
| 92 | mux := http.NewServeMux() |
| 93 | mux.HandleFunc("POST /auth/token", func(w http.ResponseWriter, r *http.Request) { |
| 94 | var body struct { |
| 95 | Token string `json:"token"` |
| 96 | } |
| 97 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Token != fs.token { |
| 98 | http.Error(w, "denied", http.StatusUnauthorized) |
| 99 | return |
| 100 | } |
| 101 | http.SetCookie(w, &http.Cookie{Name: "reasonix_token", Value: fs.token, Path: "/", HttpOnly: true}) |
| 102 | w.Header().Set(serveCapabilitiesHeader, "permission-presets-v1,present-files-v1,execution-v2,session-history-v1,session-identity-v1,session-ownership-v1") |
| 103 | w.WriteHeader(http.StatusNoContent) |
| 104 | }) |
| 105 | mux.HandleFunc("POST /new", func(w http.ResponseWriter, r *http.Request) { |
| 106 | fs.mu.Lock() |
| 107 | fs.newCalled++ |
| 108 | _, cookieErr := r.Cookie("reasonix_token") |
| 109 | fs.cookieOnNew = cookieErr == nil |
| 110 | fail := fs.failEnter |
| 111 | fs.failEnter = "" |
| 112 | enterDelay := fs.enterDelay |
| 113 | newSessionPath := fs.newSessionPath |
| 114 | newStarted, newRelease := fs.newStarted, fs.newRelease |
| 115 | if fail != "" { |
| 116 | fs.mu.Unlock() |
| 117 | http.Error(w, fail, http.StatusConflict) |
| 118 | return |
| 119 | } |
| 120 | // The serve abandons the current session on /new: no file, not listed. |
| 121 | for i := range fs.sessions { |
| 122 | fs.sessions[i].Current = false |
| 123 | } |
| 124 | fs.mu.Unlock() |
| 125 | if newStarted != nil { |
| 126 | select { |
| 127 | case newStarted <- struct{}{}: |
| 128 | default: |
| 129 | } |
| 130 | } |
| 131 | if newRelease != nil { |
| 132 | select { |
| 133 | case <-newRelease: |
| 134 | case <-r.Context().Done(): |
| 135 | return |
| 136 | } |
| 137 | } |
| 138 | if enterDelay > 0 { |
| 139 | time.Sleep(enterDelay) |
| 140 | } |
| 141 | if newSessionPath != "" { |
| 142 | w.Header().Set("X-Reasonix-Session-Path", newSessionPath) |
| 143 | } |
| 144 | w.WriteHeader(http.StatusNoContent) |
| 145 | }) |
| 146 | mux.HandleFunc("POST /resume", func(w http.ResponseWriter, r *http.Request) { |
| 147 | var body struct { |
| 148 | Path string `json:"path"` |
| 149 | SessionID string `json:"sessionId"` |
| 150 | } |
| 151 | // Mirrors the real handler: a canonical row carries only sessionId, so |
| 152 | // requiring a path here would hide every identity-route regression. |
| 153 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Path == "" && body.SessionID == "" { |
| 154 | http.Error(w, "missing path or sessionId", http.StatusBadRequest) |
| 155 | return |
| 156 | } |
| 157 | fs.mu.Lock() |
| 158 | fail := fs.failEnter |
| 159 | fs.failEnter = "" |
| 160 | enterDelay := fs.enterDelay |
| 161 | resumeStarted, resumeRelease := fs.resumeStarted, fs.resumeRelease |
| 162 | drop := fs.takeFault(&fs.resumeDropCount) |
| 163 | if fail != "" { |
| 164 | fs.mu.Unlock() |
| 165 | http.Error(w, fail, http.StatusConflict) |
| 166 | return |
| 167 | } |
| 168 | fs.resumePath, fs.resumeSessionID = body.Path, body.SessionID |
| 169 | for i := range fs.sessions { |
| 170 | fs.sessions[i].Current = body.SessionID != "" && fs.sessions[i].SessionID == body.SessionID || |
| 171 | body.SessionID == "" && fs.sessions[i].Path == body.Path |
| 172 | } |
| 173 | fs.mu.Unlock() |
| 174 | if body.SessionID != "" { |
| 175 | w.Header().Set("X-Reasonix-Session-ID", body.SessionID) |
| 176 | } |
| 177 | if drop { |
| 178 | // Serve committed the switch; only the response is lost. |
| 179 | dropHTTPConnection(w) |
| 180 | return |
| 181 | } |
| 182 | if resumeStarted != nil { |
| 183 | select { |
| 184 | case resumeStarted <- body.Path: |
| 185 | default: |
| 186 | } |
| 187 | } |
| 188 | if resumeRelease != nil { |
| 189 | select { |
| 190 | case <-resumeRelease: |
| 191 | case <-r.Context().Done(): |
| 192 | return |
| 193 | } |
| 194 | } |
| 195 | if enterDelay > 0 { |
| 196 | time.Sleep(enterDelay) |
| 197 | } |
| 198 | w.WriteHeader(http.StatusNoContent) |
| 199 | }) |
| 200 | mux.HandleFunc("GET /sessions", func(w http.ResponseWriter, r *http.Request) { |
| 201 | fs.record(r.Method, "/sessions", "") |
| 202 | fs.mu.Lock() |
| 203 | fail := fs.failSessions || fs.takeFault(&fs.sessionsFailCount) |
| 204 | started, release := fs.sessionsStarted, fs.sessionsRelease |
| 205 | sessions := append([]serveSessionEntry(nil), fs.sessions...) |
| 206 | fs.mu.Unlock() |
| 207 | if started != nil { |
| 208 | select { |
| 209 | case started <- struct{}{}: |
| 210 | default: |
| 211 | } |
| 212 | } |
| 213 | if release != nil { |
| 214 | select { |
| 215 | case <-release: |
| 216 | case <-r.Context().Done(): |
| 217 | return |
| 218 | } |
| 219 | } |
| 220 | if fail { |
| 221 | http.Error(w, "sessions unavailable", http.StatusInternalServerError) |
| 222 | return |
| 223 | } |
| 224 | writeTestJSON(w, sessions) |
| 225 | }) |
| 226 | mux.HandleFunc("GET /events", func(w http.ResponseWriter, r *http.Request) { |
| 227 | fs.mu.Lock() |
| 228 | fs.eventsConns++ |
| 229 | fs.eventsQuery = r.URL.RawQuery |
| 230 | eventsStatus := fs.eventsStatus |
| 231 | if fs.takeFault(&fs.eventsFailCount) && eventsStatus == 0 { |
| 232 | eventsStatus = http.StatusServiceUnavailable |
| 233 | } |
| 234 | closeEarly := fs.eventsCloseEarly |
| 235 | frames := append([]string(nil), fs.eventFrames...) |
| 236 | feed := fs.eventFeed |
| 237 | fs.mu.Unlock() |
| 238 | if eventsStatus != 0 { |
| 239 | http.Error(w, "event stream unavailable", eventsStatus) |
| 240 | return |
| 241 | } |
| 242 | w.Header().Set("Content-Type", "text/event-stream") |
| 243 | flusher, ok := w.(http.Flusher) |
| 244 | if !ok { |
| 245 | http.Error(w, "no flusher", http.StatusInternalServerError) |
| 246 | return |
| 247 | } |
| 248 | if len(frames) == 0 { |
| 249 | frames = []string{`{"kind":"session_start"}`, `{"kind":"ready"}`} |
| 250 | } |
| 251 | for _, frame := range frames { |
| 252 | fmt.Fprintf(w, "data: %s\n\n", frame) |
| 253 | } |
| 254 | flusher.Flush() |
| 255 | if closeEarly { |
| 256 | return |
| 257 | } |
| 258 | if feed == nil { |
| 259 | <-r.Context().Done() |
| 260 | return |
| 261 | } |
| 262 | for { |
| 263 | select { |
| 264 | case frame := <-feed: |
| 265 | fmt.Fprintf(w, "data: %s\n\n", frame) |
| 266 | flusher.Flush() |
| 267 | case <-r.Context().Done(): |
| 268 | return |
| 269 | } |
| 270 | } |
| 271 | }) |
| 272 | command := func(path string) http.HandlerFunc { |
| 273 | return func(w http.ResponseWriter, r *http.Request) { |
| 274 | data, _ := io.ReadAll(io.LimitReader(r.Body, 4<<10)) |
| 275 | fs.record(r.Method, path, string(data)) |
| 276 | fs.mu.Lock() |
| 277 | fs.expectedPaths = append(fs.expectedPaths, r.Header.Get(expectedSessionPathHeader)) |
| 278 | fail := fs.failNext |
| 279 | fs.failNext = "" |
| 280 | if path == "/cancel" && fs.statusAfterCancel != "" { |
| 281 | fs.statusPayload = fs.statusAfterCancel |
| 282 | } |
| 283 | fs.mu.Unlock() |
| 284 | if fail != "" { |
| 285 | http.Error(w, fail, http.StatusConflict) |
| 286 | return |
| 287 | } |
| 288 | if path == "/composer-profile" { |
| 289 | w.Header().Set("Content-Type", "application/json") |
| 290 | _, _ = io.WriteString(w, `{"drainedApprovalIDs":["approval-1"]}`) |
| 291 | return |
| 292 | } |
| 293 | if path == "/permission/preset" { |
| 294 | w.Header().Set("Content-Type", "application/json") |
| 295 | _, _ = io.WriteString(w, `{"snapshot":{"sessionId":"remote-session","generation":1,"revision":8,"preset":"workspace-write","workspaceRoot":"/workspace","grants":[],"capabilities":{"backend":"seatbelt","enforcement":"full","supportedPresets":["read-only","workspace-write","danger-full-access"]}}}`) |
| 296 | return |
| 297 | } |
| 298 | w.WriteHeader(http.StatusNoContent) |
| 299 | } |
| 300 | } |
| 301 | for _, path := range []string{"/submit", "/cancel", "/approve", "/plan-decision", "/answer", "/extension-form", "/rewind", "/goal", "/goal/edit", "/goal/pause", "/goal/resume", "/jobs/cancel", "/inbox/items", "/permission/preset", "/composer-profile", "/delete-session", "/model", "/effort", "/quality-floor", "/plan", "/compact", "/fork", "/summarize", "/forget", "/clear"} { |
| 302 | mux.HandleFunc("POST "+path, command(path)) |
| 303 | } |
| 304 | snapshot := func(path, payload string) { |
| 305 | mux.HandleFunc("GET "+path, func(w http.ResponseWriter, r *http.Request) { |
| 306 | fs.record(r.Method, path, "") |
| 307 | responsePayload := payload |
| 308 | if path == "/history" { |
| 309 | fs.mu.Lock() |
| 310 | fail := fs.failHistory |
| 311 | if fs.historyBody != "" { |
| 312 | responsePayload = fs.historyBody |
| 313 | } |
| 314 | started, release := fs.historyStarted, fs.historyRelease |
| 315 | fs.mu.Unlock() |
| 316 | if started != nil { |
| 317 | started <- struct{}{} |
| 318 | } |
| 319 | if release != nil { |
| 320 | select { |
| 321 | case <-release: |
| 322 | case <-r.Context().Done(): |
| 323 | return |
| 324 | } |
| 325 | } |
| 326 | if fail { |
| 327 | http.Error(w, "gone", http.StatusInternalServerError) |
| 328 | return |
| 329 | } |
| 330 | } |
| 331 | w.Header().Set("Content-Type", "application/json") |
| 332 | _, _ = w.Write([]byte(responsePayload)) |
| 333 | }) |
| 334 | } |
| 335 | snapshot("/history", `[{"role":"user","content":"hi"}]`) |
| 336 | snapshot("/context", `{"used":10}`) |
| 337 | snapshot("/todos", `[]`) |
| 338 | snapshot("/checkpoints", `[{"turn":1}]`) |
| 339 | snapshot("/models", `{"current":"remote/chat","label":"chat","models":[{"ref":"remote/chat","provider":"remote","model":"chat","active":true}]}`) |
| 340 | snapshot("/commands", `[{"name":"remote-review","description":"Review remotely","kind":"custom","group":"skills"}]`) |
| 341 | snapshot("/pending-prompts", `[{"kind":"approval_request","approval":{"id":"approval-1","tool":"bash"}}]`) |
| 342 | snapshot("/permission", `{"sessionId":"remote-session","generation":1,"revision":7,"preset":"workspace-write","workspaceRoot":"/workspace","grants":[],"capabilities":{"backend":"seatbelt","enforcement":"full","supportedPresets":["read-only","workspace-write","danger-full-access"]}}`) |
| 343 | mux.HandleFunc("GET /status", func(w http.ResponseWriter, r *http.Request) { |
| 344 | fs.record(r.Method, "/status", "") |
| 345 | fs.mu.Lock() |
| 346 | payload := fs.statusPayload |
| 347 | fs.mu.Unlock() |
| 348 | if payload == "" { |
| 349 | payload = `{"state":"ready"}` |
| 350 | } |
| 351 | w.Header().Set("Content-Type", "application/json") |
| 352 | _, _ = w.Write([]byte(payload)) |
| 353 | }) |
| 354 | snapshot("/branches", `{"branches":[]}`) |
| 355 | snapshot("/skills", `[]`) |
| 356 | gate := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 357 | if r.URL.Path == "/auth/token" { |
| 358 | mux.ServeHTTP(w, r) |
| 359 | return |
| 360 | } |
| 361 | if c, err := r.Cookie("reasonix_token"); err == nil && c.Value == fs.token { |
| 362 | mux.ServeHTTP(w, r) |
| 363 | return |
| 364 | } |
| 365 | http.Error(w, "Unauthorized", http.StatusUnauthorized) |
| 366 | }) |
| 367 | fs.server = httptest.NewServer(gate) |
| 368 | t.Cleanup(fs.server.Close) |
| 369 | return fs |
| 370 | } |
| 371 | |
| 372 | func (fs *fakeServe) snapshot() (newCalled int, resumePath string, cookieOnNew bool) { |
| 373 | fs.mu.Lock() |
| 374 | defer fs.mu.Unlock() |
| 375 | return fs.newCalled, fs.resumePath, fs.cookieOnNew |
| 376 | } |
| 377 | |
| 378 | func writeTestJSON(w http.ResponseWriter, v any) { |
| 379 | w.Header().Set("Content-Type", "application/json") |
| 380 | _ = json.NewEncoder(w).Encode(v) |
| 381 | } |
| 382 | |
| 383 | func TestRemoteHostFixtureIsolatesCanonicalWorkspaceRegistry(t *testing.T) { |
| 384 | isolateDesktopUserDirs(t) |
| 385 | prior := NewApp() |
| 386 | if _, err := prior.ensureDesktopWorkspace(t.Context(), "global", ""); err != nil { |
| 387 | t.Fatal(err) |
| 388 | } |
| 389 | seedBridgeTestHost(t, "box") |
| 390 | fresh := NewApp() |
| 391 | if _, err := fresh.ensureDesktopWorkspace(t.Context(), "global", ""); err != nil { |
| 392 | t.Fatalf("remote fixture reused another home's canonical registry: %v", err) |
| 393 | } |
| 394 | } |
| 395 | |
| 396 | func seedBridgeTestHost(t *testing.T, hostID string) { |
| 397 | t.Helper() |
| 398 | // A new config home also needs its own canonical registry: global workspace |
| 399 | // identity contains that home, while REASONIX_STATE_HOME otherwise survives. |
| 400 | home := isolateDesktopUserDirs(t) |
| 401 | t.Setenv("REASONIX_HOME", home) |
| 402 | if err := editUserConfig(func(c *config.Config) error { |
| 403 | return c.UpsertRemoteHost(config.RemoteHostEntry{Name: hostID, Host: "127.0.0.1", Port: 22, User: "dev"}) |
| 404 | }); err != nil { |
| 405 | t.Fatal(err) |
| 406 | } |
| 407 | } |
| 408 | |
| 409 | func waitForRemoteEventCount(t *testing.T, log *eventLog, prefix string, want int) { |
| 410 | t.Helper() |
| 411 | deadline := time.Now().Add(3 * time.Second) |
| 412 | for { |
| 413 | if got := log.count(prefix); got >= want { |
| 414 | return |
| 415 | } |
| 416 | if time.Now().After(deadline) { |
| 417 | t.Fatalf("event count for %q = %d, want >= %d (events: %v)", prefix, log.count(prefix), want, log.recorded()) |
| 418 | } |
| 419 | time.Sleep(10 * time.Millisecond) |
| 420 | } |
| 421 | } |
| 422 | |
| 423 | // cleanupRemoteTabPumps cancels every open tab's SSE pump and waits for all |
| 424 | // bridge tasks to return. Waiting matters on Windows: an async resume can |
| 425 | // publish its final tab snapshot after the assertion succeeds, racing the |
| 426 | // temporary user directory's cleanup. |
| 427 | func cleanupRemoteTabPumps(t *testing.T, a *App) { |
| 428 | t.Helper() |
| 429 | t.Cleanup(func() { |
| 430 | a.remoteTabMu.Lock() |
| 431 | for _, tab := range a.remoteTabs { |
| 432 | if tab.cancel != nil { |
| 433 | tab.cancel() |
| 434 | } |
| 435 | } |
| 436 | a.remoteTabMu.Unlock() |
| 437 | done := make(chan struct{}) |
| 438 | go func() { a.remoteTabTasks.Wait(); close(done) }() |
| 439 | select { |
| 440 | case <-done: |
| 441 | case <-time.After(5 * time.Second): |
| 442 | t.Error("remote tab tasks did not stop after pump cancellation") |
| 443 | } |
| 444 | }) |
| 445 | } |
| 446 | |
| 447 | // openReadyRemoteTab opens a tab against the fake serve and waits for ready. |
| 448 | func openReadyRemoteTab(t *testing.T, a *App, opts RemoteTabOpenOptions) TabMeta { |
| 449 | t.Helper() |
| 450 | meta, err := a.OpenRemoteProjectTab("box", "~/app", opts) |
| 451 | if err != nil { |
| 452 | t.Fatal(err) |
| 453 | } |
| 454 | waitForTabState(t, a, meta.ID, "ready") |
| 455 | return meta |
| 456 | } |
| 457 |