返回 DeepSeek-Reasonix
credproxy.go
根目录 / internal / remote / bootstrap / credproxy.go
1 package bootstrap
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path"
9 "strconv"
10 "strings"
11
12 "reasonix/internal/config"
13 "reasonix/internal/remote/sftpfs"
14 )
15
16 // TokenEnvName is the remote .env entry read by the installed provider.
17 const TokenEnvName = "REASONIX_PROXY_TOKEN"
18
19 const managedProviderComment = "# managed by the Reasonix desktop credential proxy — safe to delete"
20
21 // managedProviderNamePrefix is the provider-name prefix every desktop proxy
22 // provider carries (desktop/cred_proxy.go credentialProxyProviderName + "-").
23 const managedProviderNamePrefix = "reasonix-desktop-proxy-"
24
25 // tomlAssignmentString parses trimmed as a TOML `key = "value"` line and
26 // returns the unquoted string value. Whitespace around the equals sign and an
27 // optional trailing comment are tolerated: generated blocks use "key = value"
28 // but config normalizers realign to "key = value", and an exact-match parser
29 // would miss those lines and append duplicate provider blocks.
30 func tomlAssignmentString(trimmed, key string) (string, bool) {
31 if !strings.HasPrefix(trimmed, key) {
32 return "", false
33 }
34 rest := strings.TrimLeft(trimmed[len(key):], " \t")
35 if !strings.HasPrefix(rest, "=") {
36 return "", false
37 }
38 rest = strings.TrimLeft(rest[1:], " \t")
39 if !strings.HasPrefix(rest, `"`) {
40 return "", false
41 }
42 i := 1
43 for i < len(rest) {
44 if rest[i] == '\\' {
45 i += 2
46 continue
47 }
48 if rest[i] == '"' {
49 break
50 }
51 i++
52 }
53 if i >= len(rest) {
54 return "", false
55 }
56 quoted := rest[:i+1]
57 tail := strings.TrimLeft(rest[i+1:], " \t")
58 if tail != "" && !strings.HasPrefix(tail, "#") {
59 return "", false
60 }
61 value, err := strconv.Unquote(quoted)
62 if err != nil {
63 return "", false
64 }
65 return value, true
66 }
67
68 // tomlAssignmentIs reports whether trimmed assigns exactly value to key.
69 func tomlAssignmentIs(trimmed, key, value string) bool {
70 got, ok := tomlAssignmentString(trimmed, key)
71 return ok && got == value
72 }
73
74 // isRemoteMissing reports whether err is the SFTP "no such file" condition
75 // (pkg/sftp maps it onto os.ErrNotExist; the text match covers older wraps).
76 func isRemoteMissing(err error) bool {
77 return err != nil && (errors.Is(err, os.ErrNotExist) || strings.Contains(err.Error(), "no such file"))
78 }
79
80 // remoteConfigPath is ~/.reasonix/config.toml on the remote host.
81 func remoteConfigPath(home string) string {
82 return path.Join(home, ".reasonix", "config.toml")
83 }
84
85 // tomlString renders s as a basic TOML string.
86 func tomlString(s string) string {
87 var b strings.Builder
88 b.WriteByte('"')
89 for _, r := range s {
90 switch r {
91 case '\\':
92 b.WriteString(`\\`)
93 case '"':
94 b.WriteString(`\"`)
95 case '\n':
96 b.WriteString(`\n`)
97 case '\r':
98 b.WriteString(`\r`)
99 case '\t':
100 b.WriteString(`\t`)
101 default:
102 b.WriteRune(r)
103 }
104 }
105 b.WriteByte('"')
106 return b.String()
107 }
108
109 // credentialProxyKind normalizes the options' provider kind.
110 func credentialProxyKind(opts *CredentialProxyOptions) string {
111 kind := strings.TrimSpace(opts.Kind)
112 if kind == "" {
113 kind = "openai"
114 }
115 return kind
116 }
117
118 func credentialProxyTokenEnv(opts *CredentialProxyOptions) string {
119 if name := strings.TrimSpace(opts.TokenEnv); name != "" {
120 return name
121 }
122 return TokenEnvName
123 }
124
125 // credentialProviderBlock renders the tunnel-backed remote provider entry.
126 func credentialProviderBlock(opts *CredentialProxyOptions) string {
127 var b strings.Builder
128 b.WriteString("\n[[providers]]\n")
129 b.WriteString(managedProviderComment + "\n")
130 b.WriteString("name = " + tomlString(opts.Provider) + "\n")
131 b.WriteString("kind = " + tomlString(credentialProxyKind(opts)) + "\n")
132 b.WriteString("base_url = " + tomlString(opts.BaseURL) + "\n")
133 b.WriteString("model = " + tomlString(opts.Model) + "\n")
134 b.WriteString("api_key_env = " + tomlString(credentialProxyTokenEnv(opts)) + "\n")
135 return b.String()
136 }
137
138 // CredentialProxyOptions configures local-proxy credential mode: the remote
139 // serve's model calls route back to the desktop over the SSH reverse tunnel,
140 // so the real provider key never leaves the desktop.
141 type CredentialProxyOptions struct {
142 // BaseURL is the loopback URL on the REMOTE host that tunnels back to the
143 // desktop's credential proxy, e.g. http://127.0.0.1:18999.
144 BaseURL string
145 // Token is the scoped virtual token stored in the remote 0600 global .env.
146 Token string
147 // TokenEnv is its workspace-specific environment variable name.
148 TokenEnv string
149 // Provider is the provider name installed into the remote config; the
150 // serve is launched with --model <Provider> so it selects this entry.
151 Provider string
152 // Model is the model name the provider entry carries (the desktop's
153 // current default model, resolved by the caller).
154 Model string
155 // Kind is the provider kind the entry carries ("openai" or "anthropic"):
156 // the serve formats its model requests per kind, so it must match the
157 // desktop provider behind the proxy. Empty reads as "openai".
158 Kind string
159 }
160
161 // EnsureCredentialProvider updates only the managed tunnel-backed provider and
162 // virtual credential on an already connected host. Desktop model switches use
163 // this without restarting Serve; the controller adopts the staged provider via
164 // its ordinary active-work-gated model switch.
165 func EnsureCredentialProvider(ctx context.Context, conn Conn, opts *CredentialProxyOptions) (bool, error) {
166 if conn == nil {
167 return false, fmt.Errorf("bootstrap: remote connection is required")
168 }
169 fs, err := conn.SFTP()
170 if err != nil {
171 return false, err
172 }
173 home, err := fs.RealPath(ctx, "~")
174 if err != nil {
175 return false, fmt.Errorf("bootstrap: resolve remote home: %w", err)
176 }
177 return ensureCredentialProvider(ctx, fs, home, opts)
178 }
179
180 // HealCredentialProvider refreshes the managed provider outside a full Serve
181 // bootstrap round. The desktop watchdog uses it after an SSH reverse-forward
182 // rebind, before asking running Serve processes to reload providers.
183 func HealCredentialProvider(ctx context.Context, conn Conn, opts *CredentialProxyOptions) (bool, error) {
184 return EnsureCredentialProvider(ctx, conn, opts)
185 }
186
187 // ensureCredentialProvider installs or heals the proxy provider and virtual
188 // token. The result reports whether a running serve must reload its config.
189 func ensureCredentialProvider(ctx context.Context, fs *sftpfs.FS, home string, opts *CredentialProxyOptions) (bool, error) {
190 if opts == nil || strings.TrimSpace(opts.BaseURL) == "" || strings.TrimSpace(opts.Token) == "" ||
191 strings.TrimSpace(opts.Provider) == "" || strings.TrimSpace(opts.Model) == "" {
192 return false, fmt.Errorf("bootstrap: credential proxy options are incomplete")
193 }
194 tokenEnv := credentialProxyTokenEnv(opts)
195 if !config.IsValidCredentialKey(tokenEnv) {
196 return false, fmt.Errorf("bootstrap: credential proxy token env %q is invalid", tokenEnv)
197 }
198 kind := credentialProxyKind(opts)
199 cfgPath := remoteConfigPath(home)
200 data, _, _, rerr := fs.ReadFile(ctx, cfgPath, 1<<20)
201 if rerr != nil && !isRemoteMissing(rerr) {
202 return false, fmt.Errorf("bootstrap: read remote config: %w", rerr)
203 }
204 original := string(data)
205 // An explicit providers table replaces built-ins, so materialize a built-in
206 // default before appending ours without rewriting default_model itself.
207 existing := materializeDefaultProvider(original)
208 // Remove duplicate same-name blocks first: the loader resolves duplicates
209 // to the first entry, so an appended copy can never heal the block the
210 // serve actually reads.
211 if deduped, changed := dropDuplicateProviderBlocks(existing, opts.Provider); changed {
212 existing = deduped
213 }
214 existing, _ = rewriteManagedProviderBaseURLs(existing, opts.BaseURL)
215 configChanged := existing != original
216 if idx := providerBlockIndex(existing, opts.Provider); idx >= 0 {
217 if providerBlockHasBaseURL(existing[idx:], opts.BaseURL) && providerBlockHasKind(existing[idx:], kind) && providerBlockHasModel(existing[idx:], opts.Model) {
218 // Config is already current, but the .env token is healed
219 // independently — an unchanged base_url must not skip it.
220 envChanged, err := ensureCredentialToken(ctx, fs, home, tokenEnv, opts.Token)
221 if err != nil {
222 return false, err
223 }
224 if !configChanged {
225 return envChanged, nil
226 }
227 if err := fs.MkdirAll(ctx, path.Dir(cfgPath)); err != nil {
228 return false, err
229 }
230 if err := fs.WriteFileAtomic(ctx, cfgPath, []byte(existing), 0o600); err != nil {
231 return false, err
232 }
233 return true, nil
234 }
235 if !providerBlockHasBaseURL(existing[idx:], opts.BaseURL) {
236 updated, ok := replaceProviderBaseURL(existing, idx, opts.BaseURL)
237 if !ok {
238 return false, fmt.Errorf("bootstrap: remote config provider %q needs a manual base_url update", opts.Provider)
239 }
240 existing = updated
241 }
242 if !providerBlockHasKind(existing[idx:], kind) {
243 updated, ok := replaceProviderKind(existing, idx, kind)
244 if !ok {
245 return false, fmt.Errorf("bootstrap: remote config provider %q needs a manual kind update", opts.Provider)
246 }
247 existing = updated
248 }
249 if !providerBlockHasModel(existing[idx:], opts.Model) {
250 updated, ok := replaceProviderModel(existing, idx, opts.Model)
251 if !ok {
252 return false, fmt.Errorf("bootstrap: remote config provider %q needs a manual model update", opts.Provider)
253 }
254 existing = updated
255 }
256 } else {
257 existing += credentialProviderBlock(opts)
258 }
259 if err := fs.MkdirAll(ctx, path.Dir(cfgPath)); err != nil {
260 return false, err
261 }
262 if err := fs.WriteFileAtomic(ctx, cfgPath, []byte(existing), 0o600); err != nil {
263 return false, err
264 }
265 // Runtime credential resolution reads the global .env file.
266 if _, err := ensureCredentialToken(ctx, fs, home, tokenEnv, opts.Token); err != nil {
267 return false, err
268 }
269 return true, nil
270 }
271
272 // rewriteManagedProviderBaseURLs heals every workspace provider that this
273 // desktop installed. All of them share the host's one reverse-forward port,
274 // which changes together after an SSH reconnect.
275 func rewriteManagedProviderBaseURLs(text, baseURL string) (string, bool) {
276 lines := strings.Split(text, "\n")
277 inProvider, managed, changed := false, false, false
278 for index, line := range lines {
279 trimmed := strings.TrimSpace(line)
280 if strings.HasPrefix(trimmed, "[") {
281 inProvider = trimmed == "[[providers]]"
282 managed = false
283 continue
284 }
285 if !inProvider {
286 continue
287 }
288 if trimmed == managedProviderComment {
289 managed = true
290 continue
291 }
292 // Providers this desktop installed carry the proxy name prefix even
293 // when an older heal wrote the block without the marker comment; both
294 // forms are managed and must follow the tunnel port.
295 if name, ok := tomlAssignmentString(trimmed, "name"); ok && strings.HasPrefix(name, managedProviderNamePrefix) {
296 managed = true
297 continue
298 }
299 if managed && strings.HasPrefix(trimmed, "base_url") && strings.Contains(trimmed, "=") {
300 want := "base_url = " + tomlString(baseURL)
301 if trimmed != want {
302 indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
303 lines[index] = indent + want
304 changed = true
305 }
306 }
307 }
308 if !changed {
309 return text, false
310 }
311 return strings.Join(lines, "\n"), true
312 }
313
314 // ensureCredentialToken idempotently writes the credential-proxy token into
315 // the remote global .env, preserving every other line. Reports whether the
316 // value was written or already current.
317 func ensureCredentialToken(ctx context.Context, fs *sftpfs.FS, home, envName, token string) (bool, error) {
318 envPath := path.Join(home, ".reasonix", ".env")
319 data, _, _, rerr := fs.ReadFile(ctx, envPath, 1<<20)
320 if rerr != nil && !isRemoteMissing(rerr) {
321 return false, fmt.Errorf("bootstrap: read remote .env: %w", rerr)
322 }
323 lines := strings.Split(string(data), "\n")
324 prefix := envName + "="
325 for i, line := range lines {
326 if strings.HasPrefix(strings.TrimSpace(line), prefix) {
327 if strings.TrimSpace(line) == prefix+token {
328 return false, nil
329 }
330 lines[i] = prefix + token
331 updated := strings.Join(lines, "\n")
332 return true, fs.WriteFileAtomic(ctx, envPath, []byte(updated), 0o600)
333 }
334 }
335 // Append (creating the file when missing). Keep the trailing-newline
336 // convention so later manual edits stay clean.
337 content := string(data)
338 if content != "" && !strings.HasSuffix(content, "\n") {
339 content += "\n"
340 }
341 content += prefix + token + "\n"
342 return true, fs.WriteFileAtomic(ctx, envPath, []byte(content), 0o600)
343 }
344
345 // providerBlockIndex finds the start of the [[providers]] block whose name
346 // equals provider, or -1. Blocks are scanned line-wise; a block ends at the
347 // next table header.
348 func providerBlockIndex(text, provider string) int {
349 lines := strings.Split(text, "\n")
350 offset := 0
351 inBlock := false
352 for _, line := range lines {
353 trimmed := strings.TrimSpace(line)
354 if strings.HasPrefix(trimmed, "[[") || strings.HasPrefix(trimmed, "[") {
355 inBlock = strings.HasPrefix(trimmed, "[[providers]]")
356 } else if inBlock && tomlAssignmentIs(trimmed, "name", provider) {
357 return offset
358 }
359 offset += len(line) + 1
360 }
361 return -1
362 }
363
364 // dropDuplicateProviderBlocks removes every [[providers]] block after the
365 // first whose name equals provider. Duplicates arise when an older heal
366 // appended a fresh block instead of updating an aligned-format existing one;
367 // the config loader resolves duplicate names to the first entry, so later
368 // copies are dead weight that must not survive a heal.
369 func dropDuplicateProviderBlocks(text, provider string) (string, bool) {
370 lines := strings.Split(text, "\n")
371 var matchedHeaders []int
372 inProvider := false
373 curHeader := -1
374 curMatched := false
375 closeBlock := func() {
376 if inProvider && curMatched {
377 matchedHeaders = append(matchedHeaders, curHeader)
378 }
379 }
380 for i, line := range lines {
381 trimmed := strings.TrimSpace(line)
382 if strings.HasPrefix(trimmed, "[") {
383 closeBlock()
384 inProvider = trimmed == "[[providers]]"
385 curHeader = i
386 curMatched = false
387 continue
388 }
389 if !inProvider {
390 continue
391 }
392 if tomlAssignmentIs(trimmed, "name", provider) {
393 curMatched = true
394 }
395 }
396 closeBlock()
397 if len(matchedHeaders) <= 1 {
398 return text, false
399 }
400 drop := make(map[int]bool)
401 for index, header := range matchedHeaders {
402 if index == 0 {
403 continue
404 }
405 end := len(lines) - 1
406 for i := header + 1; i < len(lines); i++ {
407 if strings.HasPrefix(strings.TrimSpace(lines[i]), "[") {
408 end = i - 1
409 break
410 }
411 }
412 for i := header; i <= end; i++ {
413 drop[i] = true
414 }
415 }
416 out := make([]string, 0, len(lines))
417 for i, line := range lines {
418 if !drop[i] {
419 out = append(out, line)
420 }
421 }
422 return strings.Join(out, "\n"), true
423 }
424
425 // providerBlockHasBaseURL reports whether the block starting at idx contains
426 // the given base_url assignment before its next table header.
427 func providerBlockHasBaseURL(block, baseURL string) bool {
428 for line := range strings.SplitSeq(block, "\n") {
429 trimmed := strings.TrimSpace(line)
430 if strings.HasPrefix(trimmed, "[") {
431 return false
432 }
433 if tomlAssignmentIs(trimmed, "base_url", baseURL) {
434 return true
435 }
436 }
437 return false
438 }
439
440 // replaceProviderBaseURL swaps the base_url line inside the block starting at
441 // idx, preserving everything else byte-for-byte.
442 func replaceProviderBaseURL(text string, idx int, baseURL string) (string, bool) {
443 rest := text[idx:]
444 lines := strings.Split(rest, "\n")
445 for i, line := range lines {
446 trimmed := strings.TrimSpace(line)
447 if i > 0 && strings.HasPrefix(trimmed, "[") {
448 break
449 }
450 if strings.HasPrefix(trimmed, "base_url") && strings.Contains(trimmed, "=") {
451 indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
452 lines[i] = indent + "base_url = " + tomlString(baseURL)
453 return text[:idx] + strings.Join(lines, "\n"), true
454 }
455 }
456 return text, false
457 }
458
459 // providerBlockHasKind reports whether the block starting at idx contains the
460 // given kind assignment before its next table header.
461 func providerBlockHasKind(block, kind string) bool {
462 for line := range strings.SplitSeq(block, "\n") {
463 trimmed := strings.TrimSpace(line)
464 if strings.HasPrefix(trimmed, "[") {
465 return false
466 }
467 if tomlAssignmentIs(trimmed, "kind", kind) {
468 return true
469 }
470 }
471 return false
472 }
473
474 // replaceProviderKind swaps the kind line inside the block starting at idx,
475 // preserving everything else byte-for-byte.
476 func replaceProviderKind(text string, idx int, kind string) (string, bool) {
477 rest := text[idx:]
478 lines := strings.Split(rest, "\n")
479 for i, line := range lines {
480 trimmed := strings.TrimSpace(line)
481 if i > 0 && strings.HasPrefix(trimmed, "[") {
482 break
483 }
484 if strings.HasPrefix(trimmed, "kind") && strings.Contains(trimmed, "=") {
485 indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
486 lines[i] = indent + "kind = " + tomlString(kind)
487 return text[:idx] + strings.Join(lines, "\n"), true
488 }
489 }
490 return text, false
491 }
492
493 func providerBlockHasModel(block, model string) bool {
494 for line := range strings.SplitSeq(block, "\n") {
495 trimmed := strings.TrimSpace(line)
496 if strings.HasPrefix(trimmed, "[") {
497 return false
498 }
499 if tomlAssignmentIs(trimmed, "model", model) {
500 return true
501 }
502 }
503 return false
504 }
505
506 func replaceProviderModel(text string, idx int, model string) (string, bool) {
507 rest := text[idx:]
508 lines := strings.Split(rest, "\n")
509 for i, line := range lines {
510 trimmed := strings.TrimSpace(line)
511 if i > 0 && strings.HasPrefix(trimmed, "[") {
512 break
513 }
514 if strings.HasPrefix(trimmed, "model") && strings.Contains(trimmed, "=") {
515 indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
516 lines[i] = indent + "model = " + tomlString(model)
517 return text[:idx] + strings.Join(lines, "\n"), true
518 }
519 }
520 return text, false
521 }
522
523 // materializeDefaultProvider appends an explicit [[providers]] entry for the
524 // provider the top-level default_model refers to when that provider currently
525 // resolves only through the built-in defaults. Returns the text unchanged
526 // when default_model is absent, already defined in the file, or not a
527 // builtin. default_model itself is never rewritten — the remote's model
528 // choice stays exactly as the user configured it.
529 func materializeDefaultProvider(existing string) string {
530 name := defaultModelProvider(existing)
531 if name == "" || providerBlockIndex(existing, name) >= 0 {
532 return existing
533 }
534 entry, ok := config.BuiltinProviderEntry(name)
535 if !ok {
536 return existing
537 }
538 return existing + providerEntryBlock(entry)
539 }
540
541 // defaultModelProvider extracts the provider part of the top-level
542 // default_model assignment: "deepseek-flash" → "deepseek-flash",
543 // "deepseek/deepseek-v4-flash" → "deepseek". Empty when absent. Scanning
544 // stops at the first table header — default_model is only meaningful at the
545 // top of the file.
546 func defaultModelProvider(text string) string {
547 for line := range strings.SplitSeq(text, "\n") {
548 trimmed := strings.TrimSpace(line)
549 if strings.HasPrefix(trimmed, "[") {
550 return ""
551 }
552 after, ok := strings.CutPrefix(trimmed, "default_model")
553 if !ok || !strings.HasPrefix(strings.TrimSpace(after), "=") {
554 continue
555 }
556 value := firstQuoted(after)
557 if value == "" {
558 return ""
559 }
560 provider, _, _ := strings.Cut(value, "/")
561 return strings.TrimSpace(provider)
562 }
563 return ""
564 }
565
566 // firstQuoted returns the first double-quoted substring of s, skipping a
567 // trailing inline comment.
568 func firstQuoted(s string) string {
569 i := strings.Index(s, `"`)
570 if i < 0 {
571 return ""
572 }
573 j := strings.Index(s[i+1:], `"`)
574 if j < 0 {
575 return ""
576 }
577 return s[i+1 : i+1+j]
578 }
579
580 // providerEntryBlock renders a builtin ProviderEntry as a TOML block with the
581 // connection fields the serve needs (name/kind/base_url/model/api_key_env,
582 // plus the models list form); secrets stay in api_key_env as everywhere else.
583 func providerEntryBlock(p config.ProviderEntry) string {
584 var b strings.Builder
585 b.WriteString("\n[[providers]]\n")
586 b.WriteString("# materialized from the built-in defaults by the desktop credential proxy — safe to delete\n")
587 fmt.Fprintf(&b, "name = %s\n", tomlString(p.Name))
588 fmt.Fprintf(&b, "kind = %s\n", tomlString(p.Kind))
589 fmt.Fprintf(&b, "base_url = %s\n", tomlString(p.BaseURL))
590 if p.Model != "" {
591 fmt.Fprintf(&b, "model = %s\n", tomlString(p.Model))
592 }
593 if len(p.Models) > 0 {
594 quoted := make([]string, len(p.Models))
595 for i, m := range p.Models {
596 quoted[i] = tomlString(m)
597 }
598 fmt.Fprintf(&b, "models = [%s]\n", strings.Join(quoted, ", "))
599 if p.Default != "" {
600 fmt.Fprintf(&b, "default = %s\n", tomlString(p.Default))
601 }
602 }
603 if p.APIKeyEnv != "" {
604 fmt.Fprintf(&b, "api_key_env = %s\n", tomlString(p.APIKeyEnv))
605 }
606 if p.BalanceURL != "" {
607 fmt.Fprintf(&b, "balance_url = %s\n", tomlString(p.BalanceURL))
608 }
609 return b.String()
610 }
611
611 lines GO