返回 DeepSeek-Reasonix
provider_setup.go
根目录 / internal / serve / provider_setup.go
1 package serve
2
3 import (
4 "context"
5 _ "embed"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log/slog"
11 "net/http"
12 "strings"
13
14 "reasonix/internal/config"
15 )
16
17 //go:embed provider_setup.html
18 var providerSetupHTML []byte
19
20 const providerSetupMaxBody = 20 << 10
21
22 type providerSetupState struct {
23 Enabled bool `json:"-"`
24 Required bool `json:"required"`
25 ActivationPending bool `json:"activationPending,omitempty"`
26 Provider string `json:"provider,omitempty"`
27 Model string `json:"model,omitempty"`
28 ModelRef string `json:"modelRef,omitempty"`
29 KeyEnv string `json:"keyEnv,omitempty"`
30 CredentialRevision string `json:"-"`
31 Error string `json:"error,omitempty"`
32 }
33
34 // EnableProviderSetupForListener enables the credential-writing setup surface
35 // only for loopback listeners. Remote Desktop reaches it through an SSH tunnel;
36 // a directly exposed HTTP listener must never accept provider secrets.
37 func (s *Server) EnableProviderSetupForListener(addr string) bool {
38 if s == nil || !isLoopbackHost(addr) {
39 return false
40 }
41 s.providerSetupMu.Lock()
42 s.providerSetup.Enabled = true
43 s.providerSetupMu.Unlock()
44 s.refreshProviderSetup(currentModelRef(s.ctl()))
45 return true
46 }
47
48 func (s *Server) refreshProviderSetup(ref string) {
49 s.providerSetupMu.RLock()
50 enabled := s.providerSetup.Enabled
51 s.providerSetupMu.RUnlock()
52 if !enabled {
53 return
54 }
55
56 next := providerSetupState{Enabled: true}
57 // Resolve the missing-key state and its credential-file revision under the
58 // same cross-process lock used by every writer. This prevents capturing a
59 // stale "missing" snapshot paired with a newer revision.
60 unlockCredentials, lockErr := config.LockUserCredentialEdits()
61 if lockErr != nil {
62 next.Error = "Unable to inspect the remote Reasonix credentials."
63 } else {
64 // Keep the credential snapshot atomic without reversing the documented
65 // config -> credential lock order. Provider setup only inspects config, so
66 // it must not run on-disk migrations or acquire a config edit lock here.
67 cfg, err := config.LoadForRootReadOnly(".")
68 if err != nil {
69 next.Error = "Unable to load the remote Reasonix configuration."
70 } else if entry, ok := cfg.ResolveModel(strings.TrimSpace(ref)); ok && entry.RequiresAPIKey() && entry.APIKey() == "" && config.IsValidCredentialKey(entry.APIKeyEnv) {
71 next.Required = true
72 next.Provider = entry.Name
73 next.Model = entry.Model
74 next.ModelRef = entry.Name + "/" + entry.Model
75 next.KeyEnv = strings.TrimSpace(entry.APIKeyEnv)
76 next.CredentialRevision = config.CredentialStoreRevision()
77 }
78 unlockCredentials()
79 }
80
81 s.providerSetupMu.Lock()
82 s.providerSetup = next
83 s.providerSetupMu.Unlock()
84 }
85
86 func (s *Server) providerSetupSnapshot() (providerSetupState, bool) {
87 if s == nil {
88 return providerSetupState{}, false
89 }
90 s.providerSetupMu.RLock()
91 defer s.providerSetupMu.RUnlock()
92 return s.providerSetup, s.providerSetup.Enabled
93 }
94
95 func (s *Server) providerSetupIndex(w http.ResponseWriter) {
96 w.Header().Set("Content-Type", "text/html; charset=utf-8")
97 w.Header().Set("Cache-Control", "no-store")
98 w.Header().Set("Referrer-Policy", "no-referrer")
99 w.Header().Set("X-Content-Type-Options", "nosniff")
100 w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src 'self'; form-action 'self'; frame-ancestors 'none'")
101 lang := "auto"
102 if cfg, err := config.Load(); err == nil {
103 if dl := cfg.DesktopLanguage(); dl != "" {
104 lang = dl
105 }
106 }
107 html := strings.ReplaceAll(string(providerSetupHTML), "__LANG__", lang)
108 _, _ = w.Write([]byte(html))
109 }
110
111 func (s *Server) providerSetupStatus(w http.ResponseWriter, r *http.Request) {
112 w.Header().Set("Cache-Control", "no-store")
113 setup, ok := s.providerSetupSnapshot()
114 if !ok {
115 http.NotFound(w, r)
116 return
117 }
118 writeJSON(w, setup)
119 }
120
121 func (s *Server) providerSetupSave(w http.ResponseWriter, r *http.Request) {
122 w.Header().Set("Cache-Control", "no-store")
123 r.Body = http.MaxBytesReader(w, r.Body, providerSetupMaxBody)
124 dec := json.NewDecoder(r.Body)
125 dec.DisallowUnknownFields()
126 var body struct {
127 APIKey string `json:"apiKey"`
128 }
129 if err := dec.Decode(&body); err != nil {
130 http.Error(w, "invalid provider setup request", http.StatusBadRequest)
131 return
132 }
133 if err := ensureProviderSetupJSONEOF(dec); err != nil {
134 http.Error(w, "invalid provider setup request", http.StatusBadRequest)
135 return
136 }
137 key := strings.TrimSpace(body.APIKey)
138 if len(key) > 16<<10 {
139 http.Error(w, "API key is too large", http.StatusBadRequest)
140 return
141 }
142 if err := s.configureProviderCredential(r.Context(), key); err != nil {
143 status := providerSetupHTTPStatus(err)
144 if errors.Is(err, errProviderSetupAPIKeyRequired) {
145 http.Error(w, errProviderSetupAPIKeyRequired.Error(), status)
146 return
147 }
148 if status == http.StatusConflict {
149 http.Error(w, errProviderSetupUnavailable.Error(), status)
150 return
151 }
152 // Setup failures can contain filesystem or provider details. Keep those in
153 // the remote process log rather than reflecting them into the browser.
154 slog.Warn("serve: remote provider setup failed", "err", err)
155 http.Error(w, "unable to complete remote Provider setup", status)
156 return
157 }
158 w.WriteHeader(http.StatusNoContent)
159 }
160
161 var errProviderSetupUnavailable = errors.New("provider setup is no longer required")
162 var errProviderSetupAPIKeyRequired = errors.New("API key is required")
163
164 func (s *Server) configureProviderCredential(ctx context.Context, key string) error {
165 s.bindMu.Lock()
166 defer s.bindMu.Unlock()
167
168 setup, ok := s.providerSetupSnapshot()
169 if !ok || !setup.Required || setup.KeyEnv == "" || setup.ModelRef == "" || (!setup.ActivationPending && setup.CredentialRevision == "") {
170 return errProviderSetupUnavailable
171 }
172 if currentModelRef(s.ctl()) != setup.ModelRef {
173 s.refreshProviderSetup(currentModelRef(s.ctl()))
174 return errProviderSetupUnavailable
175 }
176 if setup.ActivationPending {
177 // The first request already committed the secret. Retrying must rebuild the
178 // controller without rewriting the credential or comparing against the now
179 // stale pre-save revision. If another process removed the credential in the
180 // meantime, return to the ordinary missing-key state instead.
181 if !config.CredentialStored(setup.KeyEnv) {
182 s.refreshProviderSetup(currentModelRef(s.ctl()))
183 return errProviderSetupAPIKeyRequired
184 }
185 } else {
186 if key == "" {
187 return errProviderSetupAPIKeyRequired
188 }
189 if _, applied, err := config.SetCredentialIfRevision(setup.KeyEnv, key, setup.CredentialRevision); err != nil {
190 return fmt.Errorf("save remote provider credential: %w", err)
191 } else if !applied {
192 s.refreshProviderSetup(currentModelRef(s.ctl()))
193 return errProviderSetupUnavailable
194 }
195 }
196 if err := s.switchModelLocked(ctx, setup.ModelRef); err != nil {
197 s.providerSetupMu.Lock()
198 s.providerSetup.ActivationPending = true
199 s.providerSetup.Error = "The credential was saved, but the Provider could not be activated. Retry or restart Reasonix Serve."
200 s.providerSetupMu.Unlock()
201 return fmt.Errorf("activate remote provider: %w", err)
202 }
203 return nil
204 }
205
206 func providerSetupHTTPStatus(err error) int {
207 if errors.Is(err, errProviderSetupAPIKeyRequired) {
208 return http.StatusBadRequest
209 }
210 if errors.Is(err, errProviderSetupUnavailable) {
211 return http.StatusConflict
212 }
213 return http.StatusInternalServerError
214 }
215
216 func ensureProviderSetupJSONEOF(dec *json.Decoder) error {
217 var extra any
218 err := dec.Decode(&extra)
219 if err == io.EOF {
220 return nil
221 }
222 if err != nil {
223 return err
224 }
225 return errors.New("multiple JSON values")
226 }
227
227 lines GO