返回 DeepSeek-Reasonix
model_credential_commit.go
根目录 / internal / config / model_credential_commit.go
1 package config
2
3 import (
4 "crypto/hmac"
5 "crypto/rand"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "fmt"
10 "os"
11 "path/filepath"
12 "slices"
13 "strings"
14 "time"
15
16 "reasonix/internal/fileutil"
17 )
18
19 const modelCredentialCommitSchema = 1
20
21 type modelCredentialCommitJournal struct {
22 Schema int `json:"schema"`
23 TransactionID string `json:"transactionId"`
24 RequestID string `json:"requestId,omitempty"`
25 RequestDigest string `json:"requestDigest,omitempty"`
26 ConfigPath string `json:"configPath"`
27 BeforeRevision string `json:"beforeRevision"`
28 AfterRevision string `json:"afterRevision,omitempty"`
29 ResultRevision string `json:"resultRevision,omitempty"`
30 Slots []string `json:"slots"`
31 // SlotDigests: slot -> keyed digest of the staged value; removal needs the store to still hold it.
32 SlotDigests map[string]string `json:"slotDigests,omitempty"`
33 Rotations []credentialRotation `json:"rotations,omitempty"`
34 Phase string `json:"phase"`
35 UpdatedAt string `json:"updatedAt"`
36 journalPath string
37 }
38
39 // ModelSettingsReceipt is durable evidence that a request crossed the config
40 // publication point. It intentionally contains no credential value or config
41 // snapshot.
42 type ModelSettingsReceipt struct {
43 Schema int `json:"schema"`
44 RequestID string `json:"requestId"`
45 RequestDigest string `json:"requestDigest"`
46 ConfigPath string `json:"configPath"`
47 BeforeRevision string `json:"beforeRevision"`
48 AfterRevision string `json:"afterRevision"`
49 ResultRevision string `json:"resultRevision,omitempty"`
50 CommittedAt string `json:"committedAt"`
51 }
52
53 func modelCredentialTransactionDir() string {
54 home := ReasonixHomeDir()
55 if strings.TrimSpace(home) == "" {
56 return ""
57 }
58 return filepath.Join(home, "transactions", "model-credentials")
59 }
60
61 func modelSettingsReceiptDir() string {
62 home := ReasonixHomeDir()
63 if strings.TrimSpace(home) == "" {
64 return ""
65 }
66 return filepath.Join(home, "transactions", "model-settings-receipts")
67 }
68
69 func modelSettingsReceiptPath(requestID string) string {
70 sum := sha256.Sum256([]byte(strings.TrimSpace(requestID)))
71 return filepath.Join(modelSettingsReceiptDir(), hex.EncodeToString(sum[:])+".json")
72 }
73
74 func fileContentRevision(path string) string {
75 raw, err := os.ReadFile(path)
76 if os.IsNotExist(err) {
77 return "missing"
78 }
79 if err != nil {
80 return "unreadable"
81 }
82 revision, err := modelConfigContentRevision(raw)
83 if err != nil {
84 return "unreadable"
85 }
86 return revision
87 }
88
89 func modelSettingsDigestKey() ([]byte, error) {
90 dir := modelSettingsReceiptDir()
91 if dir == "" {
92 return nil, fmt.Errorf("receipt store unavailable")
93 }
94 if err := os.MkdirAll(dir, 0700); err != nil {
95 return nil, err
96 }
97 path := filepath.Join(dir, "request-digest.key")
98 key, err := os.ReadFile(path)
99 if os.IsNotExist(err) {
100 key = make([]byte, 32)
101 if _, err := rand.Read(key); err != nil {
102 return nil, err
103 }
104 if err := fileutil.AtomicCreateFile(path, key, 0600); err != nil && !os.IsExist(err) {
105 return nil, err
106 }
107 key, err = os.ReadFile(path)
108 }
109 if err != nil {
110 return nil, err
111 }
112 if len(key) != 32 {
113 return nil, fmt.Errorf("invalid receipt digest key")
114 }
115 return key, nil
116 }
117
118 func modelConfigContentRevision(raw []byte) (string, error) {
119 key, err := modelSettingsDigestKey()
120 if err != nil {
121 return "", err
122 }
123 mac := hmac.New(sha256.New, key)
124 _, _ = mac.Write(raw)
125 return "hmac-sha256:" + hex.EncodeToString(mac.Sum(nil)), nil
126 }
127
128 // ModelSettingsRequestDigest survives process restarts without exposing an
129 // unkeyed digest of user-entered secrets in durable receipts.
130 func ModelSettingsRequestDigest(raw []byte) (string, error) {
131 key, err := modelSettingsDigestKey()
132 if err != nil {
133 return "", err
134 }
135 mac := hmac.New(sha256.New, key)
136 _, _ = mac.Write(raw)
137 return "hmac-v1:" + hex.EncodeToString(mac.Sum(nil)), nil
138 }
139
140 // Persist the exact publication candidate before touching the config. Recovery
141 // must never derive a success revision from whatever an external editor left.
142 func (c *Config) publishModelConfigBytes(path string, raw []byte, perm os.FileMode) error {
143 if j := c.modelCredentialCommit; j != nil {
144 if fileContentRevision(j.ConfigPath) != j.BeforeRevision {
145 return fmt.Errorf("model settings changed before publication")
146 }
147 revision, err := modelConfigContentRevision(raw)
148 if err != nil {
149 return err
150 }
151 j.AfterRevision = revision
152 j.Phase = "config_prepared"
153 if err := writeModelCredentialJournal(j); err != nil {
154 return err
155 }
156 }
157 return fileutil.AtomicWriteFileStrict(path, raw, perm)
158 }
159
160 func (c *Config) writeModelConfigResolved(path, body string, perm os.FileMode) error {
161 if c.modelCredentialCommit == nil {
162 return writeConfigFileResolved(path, body, perm)
163 }
164 if err := finalizeOpenCodeGoJournal(path); err != nil {
165 return err
166 }
167 return c.publishModelConfigBytes(path, []byte(body), perm)
168 }
169
170 func newModelCredentialTransactionID() (string, error) {
171 var id [16]byte
172 if _, err := rand.Read(id[:]); err != nil {
173 return "", err
174 }
175 return hex.EncodeToString(id[:]), nil
176 }
177
178 func writeModelCredentialJournal(j *modelCredentialCommitJournal) error {
179 if j == nil || strings.TrimSpace(j.journalPath) == "" {
180 return fmt.Errorf("model credential transaction store unavailable")
181 }
182 j.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
183 raw, err := json.Marshal(j)
184 if err != nil {
185 return err
186 }
187 dir := filepath.Dir(j.journalPath)
188 if err := os.MkdirAll(dir, 0o700); err != nil {
189 return err
190 }
191 if err := os.Chmod(dir, 0o700); err != nil {
192 return err
193 }
194 return fileutil.AtomicWriteFileStrict(j.journalPath, append(raw, '\n'), 0o600)
195 }
196
197 // BeginModelCredentialCommitLocked starts a crash-recoverable connection edit.
198 // The caller must hold the config lock followed by the credential lock.
199 func (c *Config) BeginModelCredentialCommitLocked(configPath, requestID string, requestDigest ...string) error {
200 if c == nil {
201 return fmt.Errorf("begin model credential commit: nil config")
202 }
203 dir := modelCredentialTransactionDir()
204 if dir == "" {
205 return fmt.Errorf("model credential transaction store unavailable")
206 }
207 if err := RecoverModelCredentialCommitsLocked(configPath); err != nil {
208 return err
209 }
210 id, err := newModelCredentialTransactionID()
211 if err != nil {
212 return err
213 }
214 digest := ""
215 if len(requestDigest) > 0 {
216 digest = strings.TrimSpace(requestDigest[0])
217 }
218 c.modelCredentialCommit = &modelCredentialCommitJournal{
219 Schema: modelCredentialCommitSchema, TransactionID: id, RequestID: strings.TrimSpace(requestID),
220 RequestDigest: digest,
221 ConfigPath: filepath.Clean(configPath), BeforeRevision: fileContentRevision(configPath), Phase: "prepared",
222 journalPath: filepath.Join(dir, id+".json"),
223 }
224 return writeModelCredentialJournal(c.modelCredentialCommit)
225 }
226
227 // StageModelCredential creates a private reference before the config commit.
228 // Callers hold both config and credential edit locks and defer cleanup
229 // through validation and persistence. No existing credential is overwritten.
230 func (c *Config) StageModelCredentialLocked(value string) (string, error) {
231 var id [16]byte
232 if _, err := rand.Read(id[:]); err != nil {
233 return "", err
234 }
235 return c.stageModelCredentialLocked(fmt.Sprintf("REASONIX_CONNECTION_%X_KEY", id), value)
236 }
237
238 func (c *Config) stageModelCredentialLocked(key, value string) (string, error) {
239 value = strings.TrimSpace(value)
240 if strings.ContainsAny(value, "\r\n") {
241 return "", fmt.Errorf("credential value contains a newline")
242 }
243 if j := c.modelCredentialCommit; j != nil {
244 digest, err := stagedCredentialDigest(value)
245 if err != nil {
246 return "", err
247 }
248 if j.SlotDigests == nil {
249 j.SlotDigests = map[string]string{}
250 }
251 j.Slots = append(j.Slots, key)
252 j.SlotDigests[key] = digest
253 j.Phase = "prepared"
254 if err := writeModelCredentialJournal(j); err != nil {
255 j.Slots = j.Slots[:len(j.Slots)-1]
256 delete(j.SlotDigests, key)
257 return "", err
258 }
259 }
260 if _, err := storeCredentialAssignmentsLocked(map[string]string{key: value}); err != nil {
261 return "", err
262 }
263 c.stagedModelCredentials = append(c.stagedModelCredentials, key)
264 if c.modelCredentialCommit != nil {
265 c.modelCredentialCommit.Phase = "credential_written"
266 if err := writeModelCredentialJournal(c.modelCredentialCommit); err != nil {
267 return "", err
268 }
269 }
270 return key, nil
271 }
272
273 // MarkModelCredentialConfigCommittedLocked records the config publication
274 // point. CompleteModelCredentialCommitLocked removes the recovery evidence only
275 // after the caller has reread and validated the saved connection.
276 func (c *Config) MarkModelCredentialConfigCommittedLocked(path string, resultRevision ...string) error {
277 if c == nil || c.modelCredentialCommit == nil {
278 return nil
279 }
280 actual := fileContentRevision(path)
281 if c.modelCredentialCommit.AfterRevision == "" || actual != c.modelCredentialCommit.AfterRevision {
282 return fmt.Errorf("config publication could not be confirmed")
283 }
284 if len(resultRevision) > 0 {
285 c.modelCredentialCommit.ResultRevision = strings.TrimSpace(resultRevision[0])
286 }
287 c.modelCredentialCommit.Phase = "config_committed"
288 return writeModelCredentialJournal(c.modelCredentialCommit)
289 }
290
291 func (c *Config) CompleteModelCredentialCommitLocked() error {
292 if c == nil || c.modelCredentialCommit == nil {
293 return nil
294 }
295 j := c.modelCredentialCommit
296 path := j.journalPath
297 if j.Phase == "config_committed" && j.RequestID != "" && j.RequestDigest != "" {
298 if err := persistModelSettingsReceipt(j); err != nil {
299 return err
300 }
301 }
302 if len(j.Rotations) > 0 {
303 if err := dropRotationBackups(j); err != nil {
304 return err
305 }
306 }
307 c.modelCredentialCommit = nil
308 c.stagedModelCredentials = nil
309 if path == "" {
310 return nil
311 }
312 if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
313 return err
314 }
315 return nil
316 }
317
318 // CleanupStagedModelCredentials only inspects references minted by this edit.
319 // Both edit locks still belong to the caller. An uncertain read or failed
320 // cleanup conservatively leaves an orphan; it never damages the old connection.
321 func (c *Config) CleanupStagedModelCredentialsLocked(path string) {
322 if c == nil {
323 return
324 }
325 if j := c.modelCredentialCommit; j != nil && (j.Phase == "config_committed" || fileContentRevision(path) != j.BeforeRevision) {
326 return // Publication or external edit: neither slots nor evidence are ours to remove.
327 }
328 if j := c.modelCredentialCommit; j != nil && len(j.Rotations) > 0 {
329 if restoreRotations(j) != nil || writeModelCredentialJournal(j) != nil {
330 return // Keep the journal so recovery can finish the restore.
331 }
332 }
333 if len(c.stagedModelCredentials) == 0 {
334 if c.modelCredentialCommit != nil && len(c.modelCredentialCommit.Slots) == 0 {
335 _ = os.Remove(c.modelCredentialCommit.journalPath)
336 c.modelCredentialCommit = nil
337 }
338 return
339 }
340 referenced := false
341 for _, key := range c.stagedModelCredentials {
342 digest := ""
343 if c.modelCredentialCommit != nil {
344 digest = c.modelCredentialCommit.SlotDigests[key]
345 }
346 if configReferencesCredential(path, key) {
347 referenced = true
348 } else if !stagedValueUnchanged(key, digest) {
349 continue // Another writer owns the slot now.
350 } else {
351 if err := removeCredentialFromFile(UserCredentialsPath(), key); err == nil {
352 _ = os.Unsetenv(key)
353 } else {
354 return // Keep the journal and staged list so cleanup can be retried.
355 }
356 }
357 }
358 c.stagedModelCredentials = nil
359 if c.modelCredentialCommit != nil && !referenced {
360 _ = os.Remove(c.modelCredentialCommit.journalPath)
361 c.modelCredentialCommit = nil
362 }
363 }
364
365 func persistModelSettingsReceipt(j *modelCredentialCommitJournal) error {
366 if j == nil || j.RequestID == "" || j.RequestDigest == "" || j.AfterRevision == "" {
367 return nil
368 }
369 dir := modelSettingsReceiptDir()
370 if dir == "" {
371 return fmt.Errorf("model settings receipt store unavailable")
372 }
373 if err := os.MkdirAll(dir, 0o700); err != nil {
374 return err
375 }
376 if err := os.Chmod(dir, 0o700); err != nil {
377 return err
378 }
379 receipt := ModelSettingsReceipt{
380 Schema: modelCredentialCommitSchema, RequestID: j.RequestID, RequestDigest: j.RequestDigest,
381 ConfigPath: j.ConfigPath, BeforeRevision: j.BeforeRevision, AfterRevision: j.AfterRevision,
382 ResultRevision: j.ResultRevision,
383 CommittedAt: time.Now().UTC().Format(time.RFC3339Nano),
384 }
385 raw, err := json.Marshal(receipt)
386 if err != nil {
387 return err
388 }
389 return fileutil.AtomicWriteFileStrict(modelSettingsReceiptPath(j.RequestID), append(raw, '\n'), 0o600)
390 }
391
392 // LookupModelSettingsReceipt returns durable commit evidence for requestID.
393 // A malformed or mismatched file is treated as unavailable evidence.
394 func LookupModelSettingsReceipt(requestID string) (ModelSettingsReceipt, bool) {
395 requestID = strings.TrimSpace(requestID)
396 if requestID == "" {
397 return ModelSettingsReceipt{}, false
398 }
399 raw, err := os.ReadFile(modelSettingsReceiptPath(requestID))
400 if err != nil {
401 return ModelSettingsReceipt{}, false
402 }
403 var receipt ModelSettingsReceipt
404 if json.Unmarshal(raw, &receipt) != nil || receipt.Schema != modelCredentialCommitSchema || receipt.RequestID != requestID || receipt.RequestDigest == "" || receipt.AfterRevision == "" {
405 return ModelSettingsReceipt{}, false
406 }
407 return receipt, true
408 }
409
410 // RecoverModelSettingsReceipt is the unlocked host query entry point. Receipt
411 // queries after a restart must finish provable publications before returning
412 // unknown_result; callers already holding edit locks use Lookup directly.
413 func RecoverModelSettingsReceipt(requestID string) (ModelSettingsReceipt, bool) {
414 if receipt, ok := LookupModelSettingsReceipt(requestID); ok {
415 return receipt, true
416 }
417 entries, err := os.ReadDir(modelCredentialTransactionDir())
418 if err != nil {
419 return ModelSettingsReceipt{}, false
420 }
421 for _, entry := range entries {
422 if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
423 continue
424 }
425 raw, err := os.ReadFile(filepath.Join(modelCredentialTransactionDir(), entry.Name()))
426 if err != nil {
427 continue
428 }
429 var j modelCredentialCommitJournal
430 if json.Unmarshal(raw, &j) != nil || j.Schema != modelCredentialCommitSchema || j.RequestID != strings.TrimSpace(requestID) || j.ConfigPath == "" {
431 continue
432 }
433 func() {
434 unlock, err := LockConfigFileEdits(j.ConfigPath)
435 if err != nil {
436 return
437 }
438 defer unlock()
439 unlockCredentials, err := LockUserCredentialEdits()
440 if err != nil {
441 return
442 }
443 defer unlockCredentials()
444 _ = RecoverModelCredentialCommitsLocked(j.ConfigPath)
445 }()
446 }
447 return LookupModelSettingsReceipt(requestID)
448 }
449
450 func committedModelCredentialSlots(configPath string, slots []string) (bool, error) {
451 if len(slots) == 0 {
452 return true, nil
453 }
454 cfg, err := LoadForEditReadOnlyStrict(configPath)
455 if err != nil {
456 return false, err
457 }
458 referenced := make(map[string]bool, len(slots))
459 for _, provider := range cfg.Providers {
460 referenced[strings.TrimSpace(provider.APIKeyEnv)] = true
461 }
462 credentialPath := UserCredentialsPath()
463 for _, slot := range slots {
464 slot = strings.TrimSpace(slot)
465 if slot == "" || !referenced[slot] {
466 return false, nil
467 }
468 if _, exists := envFileValue(credentialPath, slot); !exists && !envFileHasClearedKey(credentialPath, slot) {
469 return false, nil
470 }
471 }
472 return true, nil
473 }
474
475 // RecoverModelCredentialCommitsLocked resolves interrupted edits for one
476 // config target without replaying writes or overwriting newer config content.
477 func RecoverModelCredentialCommitsLocked(configPath string) error {
478 dir := modelCredentialTransactionDir()
479 if dir == "" {
480 return nil
481 }
482 entries, err := os.ReadDir(dir)
483 if os.IsNotExist(err) {
484 return nil
485 }
486 if err != nil {
487 return err
488 }
489 configPath = filepath.Clean(configPath)
490 for _, entry := range entries {
491 if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
492 continue
493 }
494 journalPath := filepath.Join(dir, entry.Name())
495 raw, readErr := os.ReadFile(journalPath)
496 if readErr != nil {
497 return readErr
498 }
499 var j modelCredentialCommitJournal
500 if json.Unmarshal(raw, &j) != nil || j.Schema != modelCredentialCommitSchema || filepath.Clean(j.ConfigPath) != configPath {
501 continue
502 }
503 if _, statErr := os.Stat(configPath); statErr != nil && !os.IsNotExist(statErr) {
504 continue
505 }
506 anyReferenced := slices.ContainsFunc(j.Slots, func(slot string) bool { return configReferencesCredential(configPath, slot) })
507 committed := false
508 if anyReferenced || j.AfterRevision != "" {
509 var committedErr error
510 committed, committedErr = committedModelCredentialSlots(configPath, j.Slots)
511 if committedErr != nil {
512 continue
513 }
514 }
515 if j.AfterRevision != "" {
516 if fileContentRevision(configPath) != j.AfterRevision || !committed {
517 if fileContentRevision(configPath) != j.BeforeRevision {
518 continue
519 }
520 } else {
521 if err := persistModelSettingsReceipt(&j); err != nil {
522 return err
523 }
524 if err := dropRotationBackups(&j); err != nil {
525 return err
526 }
527 if err := os.Remove(journalPath); err != nil && !os.IsNotExist(err) {
528 return err
529 }
530 continue
531 }
532 }
533 if anyReferenced {
534 // A partial or unparseable reference is ambiguous. Preserve both the
535 // journal and slots for explicit diagnosis instead of deleting data.
536 continue
537 }
538 if fileContentRevision(configPath) != j.BeforeRevision {
539 continue
540 }
541 if err := removeUnpublishedSlots(&j); err != nil {
542 return err
543 }
544 if err := restoreRotations(&j); err != nil {
545 return err
546 }
547 if err := os.Remove(journalPath); err != nil && !os.IsNotExist(err) {
548 return err
549 }
550 }
551 return nil
552 }
553
554 // removeUnpublishedSlots skips a slot whose value is no longer the staged one:
555 // another writer owns it now.
556 func removeUnpublishedSlots(j *modelCredentialCommitJournal) error {
557 for _, slot := range j.Slots {
558 if !stagedValueUnchanged(slot, j.SlotDigests[slot]) {
559 continue
560 }
561 if err := removeCredentialFromFile(UserCredentialsPath(), slot); err != nil {
562 return err
563 }
564 _ = os.Unsetenv(slot)
565 }
566 return nil
567 }
568
568 lines GO