返回 DeepSeek-Reasonix
remote_fork_targets_test.go
根目录 / desktop / remote_fork_targets_test.go
1 package main
2
3 import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "net/http/httptest"
10 "strings"
11 "sync"
12 "testing"
13 )
14
15 // forkTestCapabilities is what a current Serve advertises; the fork token is
16 // appended only for the serve that actually mounts the fork routes.
17 const forkTestCapabilities = "execution-v2,session-history-v1,session-identity-v1,session-ownership-v1"
18
19 const forkTestSessionID = "parent-1"
20
21 // forkServeCall is one request the fork serve received. It keeps the fence
22 // header the bridge attached, which is what proves a command was fenced to the
23 // session the tab had open.
24 type forkServeCall struct {
25 method, path, body, fence string
26 }
27
28 // forkServe is a Serve stand-in for the remote fork bindings: the token
29 // handshake with a caller-chosen capability list, the endpoints a remote tab
30 // needs to reach ready, and the two fork routes under test. Every request but
31 // the handshake and the event stream is recorded, so a binding that reached a
32 // route it should not have is visible to the assertions.
33 type forkServe struct {
34 t *testing.T
35 token string
36 caps string
37 server *httptest.Server
38 mu sync.Mutex
39 calls []forkServeCall
40 targets string
41 forkBody string
42 forkStatus int
43 dropForkResponse bool
44 }
45
46 func newForkServe(t *testing.T, forkCapable bool) *forkServe {
47 t.Helper()
48 fs := &forkServe{
49 t: t, token: "s3cret", caps: forkTestCapabilities,
50 targets: `{"source":{"hostId":"box","sessionId":"parent-1"},"targets":[],"verifiable":false}`,
51 forkBody: `{"sessionId":"child-1","turnId":"turn-1","turnNumber":1}`,
52 }
53 if forkCapable {
54 fs.caps += "," + serveCapabilitySessionForkTargetsV1
55 }
56 mux := http.NewServeMux()
57 mux.HandleFunc("POST /auth/token", func(w http.ResponseWriter, r *http.Request) {
58 var body struct {
59 Token string `json:"token"`
60 }
61 if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Token != fs.token {
62 http.Error(w, "denied", http.StatusUnauthorized)
63 return
64 }
65 http.SetCookie(w, &http.Cookie{Name: "reasonix_token", Value: fs.token, Path: "/", HttpOnly: true})
66 w.Header().Set(serveCapabilitiesHeader, fs.caps)
67 w.WriteHeader(http.StatusNoContent)
68 })
69 mux.HandleFunc("POST /new", func(w http.ResponseWriter, _ *http.Request) {
70 w.Header().Set("X-Reasonix-Session-ID", forkTestSessionID)
71 w.WriteHeader(http.StatusNoContent)
72 })
73 mux.HandleFunc("GET /sessions", func(w http.ResponseWriter, _ *http.Request) {
74 writeTestJSON(w, []serveSessionEntry{
75 {Name: "current", Current: true, HostID: "box", SessionID: forkTestSessionID},
76 })
77 })
78 mux.HandleFunc("GET /status", func(w http.ResponseWriter, _ *http.Request) {
79 _, _ = w.Write([]byte(`{"state":"ready"}`))
80 })
81 mux.HandleFunc("GET /history", func(w http.ResponseWriter, _ *http.Request) {
82 _, _ = w.Write([]byte(`[]`))
83 })
84 mux.HandleFunc("GET /events", func(w http.ResponseWriter, r *http.Request) {
85 w.Header().Set("Content-Type", "text/event-stream")
86 flusher, ok := w.(http.Flusher)
87 if !ok {
88 http.Error(w, "no flusher", http.StatusInternalServerError)
89 return
90 }
91 for _, frame := range []string{`{"kind":"session_start"}`, `{"kind":"ready"}`} {
92 fmt.Fprintf(w, "data: %s\n\n", frame)
93 }
94 flusher.Flush()
95 <-r.Context().Done()
96 })
97 mux.HandleFunc("GET /fork-targets", func(w http.ResponseWriter, _ *http.Request) {
98 fs.mu.Lock()
99 payload := fs.targets
100 fs.mu.Unlock()
101 w.Header().Set("Content-Type", "application/json")
102 _, _ = w.Write([]byte(payload))
103 })
104 mux.HandleFunc("POST /fork-session", func(w http.ResponseWriter, _ *http.Request) {
105 fs.mu.Lock()
106 status, payload := fs.forkStatus, fs.forkBody
107 drop := fs.dropForkResponse
108 fs.dropForkResponse = false
109 fs.mu.Unlock()
110 if drop {
111 if hijacker, ok := w.(http.Hijacker); ok {
112 connection, _, _ := hijacker.Hijack()
113 _ = connection.Close()
114 return
115 }
116 }
117 if status != 0 {
118 http.Error(w, payload, status)
119 return
120 }
121 w.Header().Set("Content-Type", "application/json")
122 _, _ = w.Write([]byte(payload))
123 })
124 gate := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
125 if r.URL.Path != "/auth/token" && r.URL.Path != "/events" {
126 fs.record(r)
127 }
128 if r.URL.Path == "/auth/token" {
129 mux.ServeHTTP(w, r)
130 return
131 }
132 if c, err := r.Cookie("reasonix_token"); err == nil && c.Value == fs.token {
133 mux.ServeHTTP(w, r)
134 return
135 }
136 http.Error(w, "Unauthorized", http.StatusUnauthorized)
137 })
138 fs.server = httptest.NewServer(gate)
139 t.Cleanup(fs.server.Close)
140 return fs
141 }
142
143 // record keeps one request. Bodies are re-marshaled from their decoded object
144 // so the recorded form does not depend on the field order the binding wrote.
145 func (fs *forkServe) record(r *http.Request) {
146 call := forkServeCall{
147 method: r.Method, path: r.URL.Path,
148 fence: r.Header.Get(expectedSessionIDHeader),
149 }
150 if raw, err := io.ReadAll(io.LimitReader(r.Body, 8<<10)); err == nil && len(raw) > 0 {
151 r.Body = io.NopCloser(bytes.NewReader(raw))
152 var decoded any
153 if json.Unmarshal(raw, &decoded) == nil {
154 normalized, _ := json.Marshal(decoded)
155 call.body = string(normalized)
156 }
157 }
158 fs.mu.Lock()
159 fs.calls = append(fs.calls, call)
160 fs.mu.Unlock()
161 }
162
163 func forkRemoteAnchor(a *App, tabID, turnID string, boundary uint64) ForkAnchorView {
164 a.remoteTabMu.Lock()
165 defer a.remoteTabMu.Unlock()
166 return ForkAnchorView{SourceHostID: "box", SourceSessionID: forkTestSessionID,
167 SessionGeneration: a.remoteTabs[tabID].gen, TurnID: turnID, BoundarySequence: boundary}
168 }
169
170 func (fs *forkServe) recorded() []forkServeCall {
171 fs.mu.Lock()
172 defer fs.mu.Unlock()
173 return append([]forkServeCall(nil), fs.calls...)
174 }
175
176 // openForkTab attaches a remote tab to this serve's session and waits for it,
177 // which is when the handshake capabilities are recorded on the tab.
178 func openForkTab(t *testing.T, fs *forkServe) (*App, TabMeta) {
179 t.Helper()
180 isolateDesktopUserDirs(t)
181 kernel := &fakeRemoteKernel{
182 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
183 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
184 ensureToken: fs.token,
185 }
186 seedBridgeTestHost(t, "box")
187 a := &App{remoteRuntime: kernel}
188 cleanupRemoteTabPumps(t, a)
189 return a, openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true})
190 }
191
192 // TestForkTargetsCapableRejectsUnknownAndUnadvertisedTabs pins the default: an
193 // unknown tab, a tab whose handshake has not run, and a serve that advertised
194 // no fork capability all report false, so no older serve is treated as capable.
195 func TestForkTargetsCapableRejectsUnknownAndUnadvertisedTabs(t *testing.T) {
196 fs := newForkServe(t, false)
197 a, meta := openForkTab(t, fs)
198
199 if a.forkTargetsCapable("missing") {
200 t.Fatal("unknown tab reported fork-targets capable")
201 }
202 a.remoteTabMu.Lock()
203 a.remoteTabs["pending"] = &remoteTab{id: "pending"}
204 a.remoteTabMu.Unlock()
205 if a.forkTargetsCapable("pending") {
206 t.Fatal("tab without a handshake reported fork-targets capable")
207 }
208 if a.forkTargetsCapable(meta.ID) {
209 t.Fatalf("serve advertising %q reported fork-targets capable", fs.caps)
210 }
211
212 capable := newForkServe(t, true)
213 capableApp, capableMeta := openForkTab(t, capable)
214 if !capableApp.forkTargetsCapable(capableMeta.ID) {
215 t.Fatalf("serve advertising %q reported fork-targets incapable", capable.caps)
216 }
217 }
218
219 // assertTabMetaForkTargetsSupported reads one tab exactly as the renderer does,
220 // through ListTabs, and requires the field to be emitted even when it is false.
221 func assertTabMetaForkTargetsSupported(t *testing.T, a *App, tabID string, want bool) {
222 t.Helper()
223 found := false
224 for _, meta := range a.ListTabs() {
225 if meta.ID != tabID {
226 continue
227 }
228 found = true
229 if meta.ForkTargetsSupported != want {
230 t.Fatalf("tab %q forkTargetsSupported = %v, want %v", tabID, meta.ForkTargetsSupported, want)
231 }
232 raw, err := json.Marshal(meta)
233 if err != nil {
234 t.Fatalf("tab %q: marshal meta: %v", tabID, err)
235 }
236 if !strings.Contains(string(raw), `"forkTargetsSupported":`) {
237 t.Fatalf("tab %q meta = %s, want forkTargetsSupported always emitted", tabID, raw)
238 }
239 }
240 if !found {
241 t.Fatalf("tab %q is missing from ListTabs", tabID)
242 }
243 }
244
245 // TestRemoteTabMetaForkTargetsSupportedFollowsHandshake pins the renderer's
246 // view: the tab a capable serve backs reads true, the tab behind a serve that
247 // never advertised the capability reads false, and a tab whose handshake has
248 // not run yet reads false too.
249 func TestRemoteTabMetaForkTargetsSupportedFollowsHandshake(t *testing.T) {
250 capable, capableMeta := openForkTab(t, newForkServe(t, true))
251 unsupported, unsupportedMeta := openForkTab(t, newForkServe(t, false))
252
253 assertTabMetaForkTargetsSupported(t, capable, capableMeta.ID, true)
254 assertTabMetaForkTargetsSupported(t, unsupported, unsupportedMeta.ID, false)
255
256 // A restored shell reaches the strip before its handshake, so its meta must
257 // report false rather than leave the field out.
258 unsupported.remoteTabMu.Lock()
259 unsupported.remoteTabs["no-handshake"] = &remoteTab{
260 id: "no-handshake", state: "connecting",
261 ref: RemoteTabRef{HostID: "box", Workspace: "~/app"},
262 }
263 unsupported.remoteTabMu.Unlock()
264 assertTabMetaForkTargetsSupported(t, unsupported, "no-handshake", false)
265 }
266
267 // TestLocalTabMetaForkTargetsSupportedIsFalse pins that a local tab never reads
268 // as supported: the local tab builder does not set the field, and the field has
269 // no omitempty, so a renderer sees false rather than a missing field.
270 func TestLocalTabMetaForkTargetsSupportedIsFalse(t *testing.T) {
271 isolateDesktopUserDirs(t)
272
273 app := NewApp()
274 tab, err := app.EnsureBlankTab("global", "")
275 if err != nil {
276 t.Fatal(err)
277 }
278 assertTabMetaForkTargetsSupported(t, app, tab.ID, false)
279 }
280
281 // TestForkTargetsRemoteTabEmptyWithoutCapability pins the empty answer: no
282 // error the caller must string-match, a slice that marshals as [] rather than
283 // null, and no request to a route the serve does not have.
284 func TestForkTargetsRemoteTabEmptyWithoutCapability(t *testing.T) {
285 fs := newForkServe(t, false)
286 a, meta := openForkTab(t, fs)
287 before := len(fs.recorded())
288
289 for _, tabID := range []string{meta.ID, "missing"} {
290 view, err := a.ForkTargetsRemoteTab(tabID)
291 if err != nil {
292 t.Fatalf("ForkTargetsRemoteTab(%q): %v", tabID, err)
293 }
294 if view.Targets == nil {
295 t.Fatalf("ForkTargetsRemoteTab(%q).Targets is nil; frontend expects []", tabID)
296 }
297 encoded, err := json.Marshal(view)
298 if err != nil {
299 t.Fatalf("marshal view: %v", err)
300 }
301 if !strings.Contains(string(encoded), `"targets":[]`) {
302 t.Fatalf("ForkTargetsRemoteTab(%q) encoded %s, want an empty array", tabID, encoded)
303 }
304 }
305 if got := fs.recorded()[before:]; len(got) != 0 {
306 t.Fatalf("incapable serve received %+v, want no request at all", got)
307 }
308 }
309
310 // TestForkTargetsRemoteTabDecodesServeTargets pins the read path: the serve's
311 // targets and verifiability reach the view unchanged.
312 func TestForkTargetsRemoteTabDecodesServeTargets(t *testing.T) {
313 fs := newForkServe(t, true)
314 fs.targets = `{"source":{"hostId":"box","sessionId":"parent-1"},"targets":[{"turnId":"turn-1","boundarySequence":7,"turnNumber":1,"status":"committed","messageId":"m1","available":true},` +
315 `{"turnId":"turn-2","turnNumber":2,"status":"open","available":false,"reason":"turn_open"}],"verifiable":true}`
316 a, meta := openForkTab(t, fs)
317
318 view, err := a.ForkTargetsRemoteTab(meta.ID)
319 if err != nil {
320 t.Fatalf("ForkTargetsRemoteTab: %v", err)
321 }
322 if !view.Verifiable || len(view.Targets) != 2 {
323 t.Fatalf("view = %+v, want two verifiable targets", view)
324 }
325 if view.Targets[0].TurnID != "turn-1" || !view.Targets[0].Available || view.Targets[0].MessageID != "m1" {
326 t.Fatalf("first target = %+v", view.Targets[0])
327 }
328 if view.Targets[1].Available || view.Targets[1].Reason != "turn_open" {
329 t.Fatalf("second target = %+v, want the open-turn refusal", view.Targets[1])
330 }
331 fenced := false
332 for _, call := range fs.recorded() {
333 if call.path == "/fork-targets" && call.fence == forkTestSessionID {
334 fenced = true
335 }
336 }
337 if !fenced {
338 t.Fatal("fork target GET did not carry the expected session id")
339 }
340 }
341
342 func TestCreateForkRemoteTabRejectsStaleSourceBeforePosting(t *testing.T) {
343 fs := newForkServe(t, true)
344 a, meta := openForkTab(t, fs)
345 anchor := forkRemoteAnchor(a, meta.ID, "shared-turn", 9)
346 a.remoteTabMu.Lock()
347 a.remoteTabs[meta.ID].routing.currentPath = remoteSessionIDRoutePrefix + "source-b"
348 a.remoteTabMu.Unlock()
349 before := len(fs.recorded())
350 view, err := a.CreateForkRemoteTab(meta.ID, anchor)
351 if err != nil || view.Reason != "stale_source" {
352 t.Fatalf("stale remote create = %+v, err=%v", view, err)
353 }
354 if got := fs.recorded()[before:]; len(got) != 0 {
355 t.Fatalf("stale remote create reached Serve: %+v", got)
356 }
357 }
358
359 func TestCreateForkRemoteTabReusesOperationAfterLostResponse(t *testing.T) {
360 fs := newForkServe(t, true)
361 a, meta := openForkTab(t, fs)
362 anchor := forkRemoteAnchor(a, meta.ID, "turn-1", 9)
363 fs.mu.Lock()
364 fs.dropForkResponse = true
365 fs.mu.Unlock()
366 before := len(fs.recorded())
367 if _, err := a.CreateForkRemoteTab(meta.ID, anchor); err == nil {
368 t.Fatal("dropped response unexpectedly succeeded")
369 }
370 // Reconstruct Desktop and its remote tab before retrying. The journal is a
371 // host file, so neither the App instance nor the restored tab id is part of
372 // the idempotency key.
373 restarted := &App{remoteRuntime: &fakeRemoteKernel{
374 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
375 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
376 ensureToken: fs.token,
377 }}
378 cleanupRemoteTabPumps(t, restarted)
379 restartedMeta := openReadyRemoteTab(t, restarted, RemoteTabOpenOptions{NewSession: true})
380 restartedAnchor := forkRemoteAnchor(restarted, restartedMeta.ID, anchor.TurnID, anchor.BoundarySequence)
381 view, err := restarted.CreateForkRemoteTab(restartedMeta.ID, restartedAnchor)
382 if err != nil || view.SessionID != "child-1" || view.OperationID == "" {
383 t.Fatalf("retry = %+v, err=%v", view, err)
384 }
385 var operations []string
386 for _, call := range fs.recorded()[before:] {
387 if call.path != "/fork-session" {
388 continue
389 }
390 var body map[string]any
391 if json.Unmarshal([]byte(call.body), &body) == nil {
392 operations = append(operations, fmt.Sprint(body["operationId"]))
393 }
394 }
395 if len(operations) != 2 || operations[0] == "" || operations[0] != operations[1] {
396 t.Fatalf("operation ids across unknown-result retry = %v", operations)
397 }
398 }
399
400 // TestCreateForkRemoteTabRefusesUnsupportedServe pins that a serve without the
401 // capability gets the unsupported reason and no fork request at all: falling
402 // back to /fork would switch the parent session the caller must keep.
403 func TestCreateForkRemoteTabRefusesUnsupportedServe(t *testing.T) {
404 fs := newForkServe(t, false)
405 a, meta := openForkTab(t, fs)
406 before := len(fs.recorded())
407
408 view, err := a.CreateForkRemoteTab(meta.ID, forkRemoteAnchor(a, meta.ID, "turn-1", 7))
409 if err != nil {
410 t.Fatalf("CreateForkRemoteTab: %v", err)
411 }
412 if view.Opened {
413 t.Fatal("unsupported serve reported an opened fork")
414 }
415 if !strings.Contains(view.Error, serveCapabilitySessionForkTargetsV1) {
416 t.Fatalf("unsupported error = %q, want the capability named", view.Error)
417 }
418 if got := fs.recorded()[before:]; len(got) != 0 {
419 t.Fatalf("unsupported serve received %+v, want no request at all", got)
420 }
421 }
422
423 // TestCreateForkRemoteTabRequiresTurnID pins that a missing turn id is a
424 // programming error rather than a state a serve could refuse.
425 func TestCreateForkRemoteTabRequiresTurnID(t *testing.T) {
426 a := &App{}
427 if _, err := a.CreateForkRemoteTab("missing", ForkAnchorView{TurnID: " "}); err == nil {
428 t.Fatal("empty turn id was accepted")
429 }
430 }
431
432 // TestCreateForkRemoteTabPostsFencedForkSession pins the create path: one
433 // fenced POST to /fork-session carrying the turn and operation, a child
434 // identity in the view, and no request that would rebind the parent session.
435 func TestCreateForkRemoteTabPostsFencedForkSession(t *testing.T) {
436 fs := newForkServe(t, true)
437 a, meta := openForkTab(t, fs)
438 before := len(fs.recorded())
439
440 view, err := a.CreateForkRemoteTab(meta.ID, forkRemoteAnchor(a, meta.ID, "turn-4", 9))
441 if err != nil {
442 t.Fatalf("CreateForkRemoteTab: %v", err)
443 }
444 if !view.Opened || view.SessionID != "child-1" || view.Error != "" {
445 t.Fatalf("view = %+v, want the opened child", view)
446 }
447 posted := false
448 for _, call := range fs.recorded()[before:] {
449 // /new, /resume, /clear, and /fork all move or reload the session the
450 // tab has open; creating a child must leave that route and lease alone.
451 switch call.path {
452 case "/fork-session":
453 posted = true
454 if call.method != http.MethodPost {
455 t.Fatalf("fork-session method = %s", call.method)
456 }
457 var body map[string]any
458 if err := json.Unmarshal([]byte(call.body), &body); err != nil {
459 t.Fatalf("fork-session body %s: %v", call.body, err)
460 }
461 if body["turnId"] != "turn-4" || body["operationId"] == "" || body["sourceSessionId"] != forkTestSessionID || body["boundarySequence"] != float64(9) {
462 t.Fatalf("fork-session body = %s, want the turn and operation", call.body)
463 }
464 if call.fence != forkTestSessionID {
465 t.Fatalf("fork-session fence = %q, want the parent session id %q", call.fence, forkTestSessionID)
466 }
467 case "/fork", "/new", "/resume", "/clear":
468 t.Fatalf("create fork reached the session-switching route %s", call.path)
469 }
470 }
471 if !posted {
472 t.Fatalf("no fork-session request: %+v", fs.recorded()[before:])
473 }
474 a.remoteTabMu.Lock()
475 tab := a.remoteTabs[meta.ID]
476 path, state := tab.routing.currentPath, tab.state
477 a.remoteTabMu.Unlock()
478 if path != remoteSessionIDRoutePrefix+forkTestSessionID || state != "ready" {
479 t.Fatalf("parent tab moved to path %q state %q, want its original session still ready", path, state)
480 }
481 }
482
483 // TestCreateForkRemoteTabSurfacesRefusalReason pins that a refusal status keeps
484 // the serve's own words instead of a status code.
485 func TestCreateForkRemoteTabSurfacesRefusalReason(t *testing.T) {
486 fs := newForkServe(t, true)
487 a, meta := openForkTab(t, fs)
488 const reason = `session: turn "turn-2" cannot start a fork (turn_open)`
489 fs.mu.Lock()
490 fs.forkStatus, fs.forkBody = http.StatusConflict, `{"code":"fork_unavailable","reason":"turn_open","message":"`+strings.ReplaceAll(reason, `"`, `\"`)+`"}`
491 fs.mu.Unlock()
492
493 view, err := a.CreateForkRemoteTab(meta.ID, forkRemoteAnchor(a, meta.ID, "turn-2", 9))
494 if err != nil {
495 t.Fatalf("CreateForkRemoteTab: %v", err)
496 }
497 if view.Opened || view.Error != reason {
498 t.Fatalf("view = %+v, want the refusal %q with nothing opened", view, reason)
499 }
500 journal, loadErr := loadForkOperations(forkOperationsPath())
501 if loadErr != nil || len(journal.Operations) != 0 {
502 t.Fatalf("explicit remote refusal journal = %+v, err=%v", journal, loadErr)
503 }
504 }
505
505 lines GO