返回 DeepSeek-Reasonix
session_fence.go
根目录 / internal / serve / session_fence.go
1 package serve
2
3 import (
4 "bytes"
5 "errors"
6 "io"
7 "net/http"
8 "strings"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/control"
12 )
13
14 const (
15 sessionPathHeader = "X-Reasonix-Session-Path"
16 sessionIDHeader = "X-Reasonix-Session-ID"
17 sessionTakenOverHeader = "X-Reasonix-Taken-Over"
18 expectedSessionPathHeader = "X-Reasonix-Expected-Session-Path"
19 expectedSessionIDHeader = "X-Reasonix-Expected-Session-ID"
20 expectedModelSettingsHeader = "X-Reasonix-Expected-Model-Settings"
21 foregroundMutationMaxBody = 8 << 20
22 )
23
24 func writeSessionIDHeader(w http.ResponseWriter, ctrl control.SessionAPI) {
25 identity, ok := ctrl.(control.IdentityLifecycle)
26 if !ok {
27 return
28 }
29 if ref, bound := identity.SessionRef(); bound {
30 w.Header().Set(sessionIDHeader, ref.SessionID)
31 }
32 }
33
34 var errExpectedSessionChanged = errors.New("active session changed; retry on the current session")
35
36 // expectedSessionErrorLocked fences a foreground mutation to the session the
37 // caller displayed when it issued the request. The header is optional for
38 // compatibility with browser clients and older Desktop builds. bindMu must be
39 // held so validation and controller use share one publication epoch.
40 func (s *Server) expectedSessionErrorLocked(r *http.Request) error {
41 if expectedID := strings.TrimSpace(r.Header.Get(expectedSessionIDHeader)); expectedID != "" {
42 identity, ok := s.ctl().(control.IdentityLifecycle)
43 if !ok {
44 return errExpectedSessionChanged
45 }
46 ref, ok := identity.SessionRef()
47 if !ok || ref.SessionID != expectedID {
48 return errExpectedSessionChanged
49 }
50 return nil
51 }
52 return s.expectedSessionPathErrorLocked(r.Header.Get(expectedSessionPathHeader))
53 }
54
55 func (s *Server) expectedSessionPathErrorLocked(rawExpected string) error {
56 expected := strings.TrimSpace(rawExpected)
57 if expected == "" {
58 return nil
59 }
60 // Identity sessions carry no legacy path: their session-id reference is
61 // compared against the bound controller's exclusive identity, matching
62 // the Expected-Session-ID header protocol.
63 if id, ok := strings.CutPrefix(expected, remoteSessionIDQueryPrefix); ok {
64 identity, hasIdentity := s.ctl().(control.IdentityLifecycle)
65 if !hasIdentity {
66 return errExpectedSessionChanged
67 }
68 ref, bound := identity.SessionRef()
69 if !bound || ref.SessionID != id {
70 return errExpectedSessionChanged
71 }
72 return nil
73 }
74 canonical := agent.CanonicalSessionPath(expected)
75 actual := agent.CanonicalSessionPath(strings.TrimSpace(s.ctl().SessionPath()))
76 if actual != canonical {
77 return errExpectedSessionChanged
78 }
79 return nil
80 }
81
82 // expectedSessionIsSpectatorPinLocked reports whether the caller's expected
83 // session is one a local runtime owns (a spectator pin). Such a caller is
84 // deliberately viewing a non-foreground session; write commands to it must be
85 // refused with the takeover wording, while foreground-switch commands may pass
86 // (validated by validateSwitchExpectedLocked).
87 func (s *Server) expectedSessionIsSpectatorPinLocked(r *http.Request) bool {
88 return s.sessionMirrored(r.Header.Get(expectedSessionPathHeader))
89 }
90
91 func (s *Server) validateExpectedSessionLocked(w http.ResponseWriter, r *http.Request) bool {
92 if expected := r.Header.Get(expectedModelSettingsHeader); expected != "" {
93 snapshot, ok := s.ctl().(interface{ ModelSettingsSourceRevision() string })
94 if !ok || snapshot.ModelSettingsSourceRevision() != expected {
95 http.Error(w, "session model settings changed; apply the latest saved settings before starting this run", http.StatusConflict)
96 return false
97 }
98 }
99 if err := s.expectedSessionErrorLocked(r); err != nil {
100 // A spectator pinned to a local-owned session is not misrouted — it is
101 // read-only by ownership. Answer with the takeover wording instead of
102 // the generic "active session changed".
103 if s.expectedSessionIsSpectatorPinLocked(r) {
104 http.Error(w, errSessionTakenOver, http.StatusConflict)
105 return false
106 }
107 http.Error(w, err.Error(), http.StatusConflict)
108 return false
109 }
110 return true
111 }
112
113 // validateSwitchExpectedLocked is the fence for foreground-switch commands
114 // (/new, /clear, /resume). A spectator pinned to a local-owned session is
115 // allowed to switch: it is leaving its read-only pin for a real foreground
116 // session, which is the only way "the remote" regains the ability to act.
117 func (s *Server) validateSwitchExpectedLocked(w http.ResponseWriter, r *http.Request) bool {
118 if err := s.expectedSessionErrorLocked(r); err != nil {
119 if s.expectedSessionIsSpectatorPinLocked(r) {
120 return true
121 }
122 http.Error(w, err.Error(), http.StatusConflict)
123 return false
124 }
125 return true
126 }
127
128 func (s *Server) foregroundMutation(next http.HandlerFunc) http.HandlerFunc {
129 return func(w http.ResponseWriter, r *http.Request) {
130 if !bufferForegroundMutationBody(w, r) {
131 return
132 }
133 s.bindMu.Lock()
134 defer s.bindMu.Unlock()
135 if !s.validateExpectedSessionLocked(w, r) {
136 return
137 }
138 // A mirrored foreground is owned by a local runtime; every mutation
139 // (submit-adjacent commands, inbox, approvals) is read-only-refused
140 // until the remote side reclaims the session.
141 if s.rejectMirroredForegroundLocked(w) {
142 return
143 }
144 switch r.URL.Path {
145 case "/goal/resume", "/compact", "/summarize":
146 if err := s.refreshRunModelSettingsLocked(r.Context()); err != nil {
147 http.Error(w, err.Error(), http.StatusConflict)
148 return
149 }
150 if !s.validateExpectedSessionLocked(w, r) {
151 return
152 }
153 }
154 next(w, r)
155 }
156 }
157
158 // bufferForegroundMutationBody drains the bounded JSON body before acquiring
159 // bindMu. An authenticated client that uploads slowly can occupy its own
160 // handler, but cannot freeze every foreground command and session transition.
161 func bufferForegroundMutationBody(w http.ResponseWriter, r *http.Request) bool {
162 if r.Body == nil || r.Body == http.NoBody {
163 return true
164 }
165 limited := http.MaxBytesReader(w, r.Body, foregroundMutationMaxBody)
166 body, err := io.ReadAll(limited)
167 _ = limited.Close()
168 if err != nil {
169 var tooLarge *http.MaxBytesError
170 if errors.As(err, &tooLarge) {
171 http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
172 } else {
173 http.Error(w, "failed to read request body", http.StatusBadRequest)
174 }
175 return false
176 }
177 r.Body = io.NopCloser(bytes.NewReader(body))
178 r.ContentLength = int64(len(body))
179 return true
180 }
181
181 lines GO