package service import ( "context" "fmt" "io" "net/http" "sync" "time" claude "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" ) // backgroundSimulator simulates the real Claude Code CLI's background network behavior. // Real CLI performs bootstrap, GrowthBook feature-flag polling, and policy_limits polling. // Missing these creates a behavioral correlation gap detectable by Anthropic. type backgroundSimulator struct { mu sync.Mutex called map[int64]*accountBackgroundState client *http.Client baseURL string } type accountBackgroundState struct { bootstrapAt time.Time growthbookAt time.Time policyLimitsAt time.Time // Timers for periodic polling — stopped when account goes idle growthbookTimer *time.Timer policyLimitsTimer *time.Timer exitTimer *time.Timer // fires tengu_exit after idle timeout accessToken string accountID int64 } const ( bootstrapCooldown = 1 * time.Hour growthbookInterval = 20 * time.Minute policyLimitsInterval = 1 * time.Hour sessionIdleTimeout = 10 * time.Minute // fire tengu_exit after no requests for 10min ) var globalBgSim = &backgroundSimulator{ called: make(map[int64]*accountBackgroundState), client: &http.Client{Timeout: 5 * time.Second}, } // SetBootstrapBaseURL configures the API base URL for background simulation calls. func SetBootstrapBaseURL(baseURL string) { globalBgSim.baseURL = baseURL } // TriggerBootstrapIfNeeded fires background simulation calls for the given OAuth account. // On first call per account: bootstrap + GrowthBook + policy_limits + start periodic timers. // On subsequent calls: refresh idle timer (delays tengu_exit). func TriggerBootstrapIfNeeded(accountID int64, accessToken string) { bg := globalBgSim bg.mu.Lock() state, exists := bg.called[accountID] if !exists { // First time: create state, fire all startup calls state = &accountBackgroundState{ accessToken: accessToken, accountID: accountID, } bg.called[accountID] = state bg.mu.Unlock() // Fire-and-forget startup sequence (matches real CLI order) go bg.doBootstrap(state) go bg.doGrowthBookFetch(state) go bg.doPolicyLimitsFetch(state) bg.startPeriodicPolling(state) bg.resetExitTimer(state) return } // Update token (may have been refreshed) state.accessToken = accessToken // Bootstrap: 1 hour cooldown if time.Since(state.bootstrapAt) >= bootstrapCooldown { state.bootstrapAt = time.Now() bg.mu.Unlock() go bg.doBootstrap(state) } else { bg.mu.Unlock() } // Reset idle timer (user is active) bg.resetExitTimer(state) } func (bg *backgroundSimulator) getBaseURL() string { if bg.baseURL != "" { return bg.baseURL } return "https://api.anthropic.com" } // ─── Bootstrap ─────────────────────────────────────────── func (bg *backgroundSimulator) doBootstrap(state *accountBackgroundState) { state.bootstrapAt = time.Now() endpoint := bg.getBaseURL() + "/api/claude_cli/bootstrap" ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) if err != nil { return } // Source: extracted/src/services/api/bootstrap.ts:85-91 req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", fmt.Sprintf("claude-code/%s", claude.DefaultCLIVersion)) req.Header.Set("Authorization", "Bearer "+state.accessToken) req.Header.Set("anthropic-beta", claude.BetaOAuth) resp, err := bg.client.Do(req) if err != nil { logger.LegacyPrintf("service.bootstrap", "Bootstrap preflight failed: %v", err) return } io.Copy(io.Discard, resp.Body) resp.Body.Close() logger.LegacyPrintf("service.bootstrap", "Bootstrap completed: account=%d status=%d", state.accountID, resp.StatusCode) } // ─── GrowthBook Feature Flags ──────────────────────────── func (bg *backgroundSimulator) doGrowthBookFetch(state *accountBackgroundState) { state.growthbookAt = time.Now() // Real CLI uses GrowthBook SDK with remoteEval: true // SDK key for external users: sdk-zAZezfDKGoZuXXKe // Endpoint: GET {apiHost}/sub/features/{clientKey} // Source: extracted/src/services/analytics/growthbook.ts:503-555 endpoint := bg.getBaseURL() + "/sub/features/sdk-zAZezfDKGoZuXXKe" ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) if err != nil { return } req.Header.Set("Authorization", "Bearer "+state.accessToken) req.Header.Set("anthropic-beta", claude.BetaOAuth) req.Header.Set("User-Agent", fmt.Sprintf("claude-code/%s", claude.DefaultCLIVersion)) resp, err := bg.client.Do(req) if err != nil { logger.LegacyPrintf("service.bootstrap", "GrowthBook fetch failed: %v", err) return } io.Copy(io.Discard, resp.Body) resp.Body.Close() logger.LegacyPrintf("service.bootstrap", "GrowthBook fetch completed: account=%d status=%d", state.accountID, resp.StatusCode) } // ─── Policy Limits ─────────────────────────────────────── func (bg *backgroundSimulator) doPolicyLimitsFetch(state *accountBackgroundState) { state.policyLimitsAt = time.Now() // Source: extracted/src/services/policyLimits/index.ts:127 endpoint := bg.getBaseURL() + "/api/claude_code/policy_limits" ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) if err != nil { return } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", fmt.Sprintf("claude-code/%s", claude.DefaultCLIVersion)) req.Header.Set("Authorization", "Bearer "+state.accessToken) req.Header.Set("anthropic-beta", claude.BetaOAuth) resp, err := bg.client.Do(req) if err != nil { logger.LegacyPrintf("service.bootstrap", "Policy limits fetch failed: %v", err) return } io.Copy(io.Discard, resp.Body) resp.Body.Close() logger.LegacyPrintf("service.bootstrap", "Policy limits fetch completed: account=%d status=%d", state.accountID, resp.StatusCode) } // ─── Periodic Polling ──────────────────────────────────── func (bg *backgroundSimulator) startPeriodicPolling(state *accountBackgroundState) { // GrowthBook: every 20 minutes // Source: growthbook.ts setupPeriodicGrowthBookRefresh() go func() { // Add jitter to avoid all accounts polling at the same time jitter := time.Duration(state.accountID%300) * time.Second time.Sleep(growthbookInterval + jitter) for { bg.doGrowthBookFetch(state) time.Sleep(growthbookInterval + time.Duration(state.accountID%60)*time.Second) } }() // Policy limits: every hour // Source: policyLimits/index.ts refreshPolicyLimits() go func() { jitter := time.Duration(state.accountID%600) * time.Second time.Sleep(policyLimitsInterval + jitter) for { bg.doPolicyLimitsFetch(state) time.Sleep(policyLimitsInterval + time.Duration(state.accountID%120)*time.Second) } }() } // ─── tengu_exit Event ──────────────────────────────────── func (bg *backgroundSimulator) resetExitTimer(state *accountBackgroundState) { bg.mu.Lock() defer bg.mu.Unlock() // Cancel existing timer if state.exitTimer != nil { state.exitTimer.Stop() } // Set new timer: fire tengu_exit after idle timeout state.exitTimer = time.AfterFunc(sessionIdleTimeout, func() { bg.fireExitEvent(state) }) } func (bg *backgroundSimulator) fireExitEvent(state *accountBackgroundState) { // tengu_exit is sent via the 1P event_logging/batch endpoint // Source: extracted/src/services/analytics/firstPartyEventLogger.ts // We use proxy.js's sendTelemetryEvents path (same endpoint), but since // proxy.js runs per-request and this is idle-based, we fire directly here. // The event is a lightweight signal — just needs to exist in Anthropic's logs. // Real CLI sends it on process exit; we simulate on idle timeout. logger.LegacyPrintf("service.bootstrap", "Session idle timeout, would fire tengu_exit: account=%d", state.accountID) // Clean up the state to allow fresh bootstrap on next request bg.mu.Lock() delete(bg.called, state.accountID) bg.mu.Unlock() }