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