Claude Code v2.1.78 changed metadata.user_id from a template literal
(`user_${id}_account_${uuid}_session_${sid}`) to a JSON-encoded object
(`JSON.stringify({device_id, account_uuid, session_id})`), breaking
session ID extraction via `_session_` substring match.
- Fix extractCCMSessionID to try JSON parse first, fallback to legacy
- Remove subscriptionType/rateLimitTier/isMax from oauthCredentials
(profile state does not belong in auth credentials)
- Add state_path option for persisting profile state across restarts
- Parse account.uuid from /api/oauth/profile response
- Inject account_uuid into forwarded requests when client sends it empty
(happens when using ANTHROPIC_AUTH_TOKEN instead of Claude AI OAuth)
155 lines
3.5 KiB
Go
155 lines
3.5 KiB
Go
package ccm
|
|
|
|
import (
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/sagernet/fswatch"
|
|
E "github.com/sagernet/sing/common/exceptions"
|
|
)
|
|
|
|
const credentialReloadRetryInterval = 2 * time.Second
|
|
|
|
func resolveCredentialFilePath(customPath string) (string, error) {
|
|
if customPath == "" {
|
|
var err error
|
|
customPath, err = getDefaultCredentialsPath()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
if filepath.IsAbs(customPath) {
|
|
return customPath, nil
|
|
}
|
|
return filepath.Abs(customPath)
|
|
}
|
|
|
|
func (c *defaultCredential) ensureCredentialWatcher() error {
|
|
c.watcherAccess.Lock()
|
|
defer c.watcherAccess.Unlock()
|
|
|
|
if c.watcher != nil || c.credentialFilePath == "" {
|
|
return nil
|
|
}
|
|
if !c.watcherRetryAt.IsZero() && time.Now().Before(c.watcherRetryAt) {
|
|
return nil
|
|
}
|
|
|
|
watcher, err := fswatch.NewWatcher(fswatch.Options{
|
|
Path: []string{c.credentialFilePath},
|
|
Logger: c.logger,
|
|
Callback: func(string) {
|
|
err := c.reloadCredentials(true)
|
|
if err != nil {
|
|
c.logger.Warn("reload credentials for ", c.tag, ": ", err)
|
|
}
|
|
},
|
|
})
|
|
if err != nil {
|
|
c.watcherRetryAt = time.Now().Add(credentialReloadRetryInterval)
|
|
return err
|
|
}
|
|
|
|
err = watcher.Start()
|
|
if err != nil {
|
|
c.watcherRetryAt = time.Now().Add(credentialReloadRetryInterval)
|
|
return err
|
|
}
|
|
|
|
c.watcher = watcher
|
|
c.watcherRetryAt = time.Time{}
|
|
return nil
|
|
}
|
|
|
|
func (c *defaultCredential) retryCredentialReloadIfNeeded() {
|
|
c.stateAccess.RLock()
|
|
unavailable := c.state.unavailable
|
|
lastAttempt := c.state.lastCredentialLoadAttempt
|
|
c.stateAccess.RUnlock()
|
|
if !unavailable {
|
|
return
|
|
}
|
|
if !lastAttempt.IsZero() && time.Since(lastAttempt) < credentialReloadRetryInterval {
|
|
return
|
|
}
|
|
|
|
err := c.ensureCredentialWatcher()
|
|
if err != nil {
|
|
c.logger.Debug("start credential watcher for ", c.tag, ": ", err)
|
|
}
|
|
_ = c.reloadCredentials(false)
|
|
}
|
|
|
|
func (c *defaultCredential) reloadCredentials(force bool) error {
|
|
c.reloadAccess.Lock()
|
|
defer c.reloadAccess.Unlock()
|
|
|
|
c.stateAccess.RLock()
|
|
unavailable := c.state.unavailable
|
|
lastAttempt := c.state.lastCredentialLoadAttempt
|
|
c.stateAccess.RUnlock()
|
|
if !force {
|
|
if !unavailable {
|
|
return nil
|
|
}
|
|
if !lastAttempt.IsZero() && time.Since(lastAttempt) < credentialReloadRetryInterval {
|
|
return c.unavailableError()
|
|
}
|
|
}
|
|
|
|
c.stateAccess.Lock()
|
|
c.state.lastCredentialLoadAttempt = time.Now()
|
|
c.stateAccess.Unlock()
|
|
|
|
credentials, err := platformReadCredentials(c.credentialPath)
|
|
if err != nil {
|
|
return c.markCredentialsUnavailable(E.Cause(err, "read credentials"))
|
|
}
|
|
|
|
c.access.Lock()
|
|
c.credentials = credentials
|
|
c.refreshRetryAt = time.Time{}
|
|
c.refreshRetryError = nil
|
|
c.refreshBlocked = false
|
|
c.access.Unlock()
|
|
|
|
c.stateAccess.Lock()
|
|
before := c.statusSnapshotLocked()
|
|
c.state.unavailable = false
|
|
c.state.lastCredentialLoadError = ""
|
|
c.checkTransitionLocked()
|
|
shouldEmit := before != c.statusSnapshotLocked()
|
|
c.stateAccess.Unlock()
|
|
if shouldEmit {
|
|
c.emitStatusUpdate()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *defaultCredential) markCredentialsUnavailable(err error) error {
|
|
c.access.Lock()
|
|
hadCredentials := c.credentials != nil
|
|
c.credentials = nil
|
|
c.access.Unlock()
|
|
|
|
c.stateAccess.Lock()
|
|
before := c.statusSnapshotLocked()
|
|
c.state.unavailable = true
|
|
c.state.lastCredentialLoadError = err.Error()
|
|
c.state.accountType = ""
|
|
c.state.rateLimitTier = ""
|
|
shouldInterrupt := c.checkTransitionLocked()
|
|
shouldEmit := before != c.statusSnapshotLocked()
|
|
c.stateAccess.Unlock()
|
|
|
|
if shouldInterrupt && hadCredentials {
|
|
c.interruptConnections()
|
|
}
|
|
if shouldEmit {
|
|
c.emitStatusUpdate()
|
|
}
|
|
|
|
return err
|
|
}
|