/ src / bridge / bridgeEnabled.ts
bridgeEnabled.ts
  1  import { feature } from 'bun:bundle'
  2  import {
  3    checkGate_CACHED_OR_BLOCKING,
  4    getDynamicConfig_CACHED_MAY_BE_STALE,
  5    getFeatureValue_CACHED_MAY_BE_STALE,
  6  } from '../services/analytics/growthbook.js'
  7  // Namespace import breaks the bridgeEnabled → auth → config → bridgeEnabled
  8  // cycle — authModule.foo is a live binding, so by the time the helpers below
  9  // call it, auth.js is fully loaded. Previously used require() for the same
 10  // deferral, but require() hits a CJS cache that diverges from the ESM
 11  // namespace after mock.module() (daemon/auth.test.ts), breaking spyOn.
 12  import * as authModule from '../utils/auth.js'
 13  import { isEnvTruthy } from '../utils/envUtils.js'
 14  import { lt } from '../utils/semver.js'
 15  
 16  /**
 17   * Runtime check for bridge mode entitlement.
 18   *
 19   * Remote Control requires a claude.ai subscription (the bridge auths to CCR
 20   * with the claude.ai OAuth token). isClaudeAISubscriber() excludes
 21   * Bedrock/Vertex/Foundry, apiKeyHelper/gateway deployments, env-var API keys,
 22   * and Console API logins — none of which have the OAuth token CCR needs.
 23   * See github.com/deshaw/anthropic-issues/issues/24.
 24   *
 25   * The `feature('BRIDGE_MODE')` guard ensures the GrowthBook string literal
 26   * is only referenced when bridge mode is enabled at build time.
 27   */
 28  export function isBridgeEnabled(): boolean {
 29    // Positive ternary pattern — see docs/feature-gating.md.
 30    // Negative pattern (if (!feature(...)) return) does not eliminate
 31    // inline string literals from external builds.
 32    return feature('BRIDGE_MODE')
 33      ? isClaudeAISubscriber() &&
 34          getFeatureValue_CACHED_MAY_BE_STALE('tengu_ccr_bridge', false)
 35      : false
 36  }
 37  
 38  /**
 39   * Blocking entitlement check for Remote Control.
 40   *
 41   * Returns cached `true` immediately (fast path). If the disk cache says
 42   * `false` or is missing, awaits GrowthBook init and fetches the fresh
 43   * server value (slow path, max ~5s), then writes it to disk.
 44   *
 45   * Use at entitlement gates where a stale `false` would unfairly block access.
 46   * For user-facing error paths, prefer `getBridgeDisabledReason()` which gives
 47   * a specific diagnostic. For render-body UI visibility checks, use
 48   * `isBridgeEnabled()` instead.
 49   */
 50  export async function isBridgeEnabledBlocking(): Promise<boolean> {
 51    return feature('BRIDGE_MODE')
 52      ? isClaudeAISubscriber() &&
 53          (await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge'))
 54      : false
 55  }
 56  
 57  /**
 58   * Diagnostic message for why Remote Control is unavailable, or null if
 59   * it's enabled. Call this instead of a bare `isBridgeEnabledBlocking()`
 60   * check when you need to show the user an actionable error.
 61   *
 62   * The GrowthBook gate targets on organizationUUID, which comes from
 63   * config.oauthAccount — populated by /api/oauth/profile during login.
 64   * That endpoint requires the user:profile scope. Tokens without it
 65   * (setup-token, CLAUDE_CODE_OAUTH_TOKEN env var, or pre-scope-expansion
 66   * logins) leave oauthAccount unpopulated, so the gate falls back to
 67   * false and users see a dead-end "not enabled" message with no hint
 68   * that re-login would fix it. See CC-1165 / gh-33105.
 69   */
 70  export async function getBridgeDisabledReason(): Promise<string | null> {
 71    if (feature('BRIDGE_MODE')) {
 72      if (!isClaudeAISubscriber()) {
 73        return 'Remote Control requires a claude.ai subscription. Run `claude auth login` to sign in with your claude.ai account.'
 74      }
 75      if (!hasProfileScope()) {
 76        return 'Remote Control requires a full-scope login token. Long-lived tokens (from `claude setup-token` or CLAUDE_CODE_OAUTH_TOKEN) are limited to inference-only for security reasons. Run `claude auth login` to use Remote Control.'
 77      }
 78      if (!getOauthAccountInfo()?.organizationUuid) {
 79        return 'Unable to determine your organization for Remote Control eligibility. Run `claude auth login` to refresh your account information.'
 80      }
 81      if (!(await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge'))) {
 82        return 'Remote Control is not yet enabled for your account.'
 83      }
 84      return null
 85    }
 86    return 'Remote Control is not available in this build.'
 87  }
 88  
 89  // try/catch: main.tsx:5698 calls isBridgeEnabled() while defining the Commander
 90  // program, before enableConfigs() runs. isClaudeAISubscriber() → getGlobalConfig()
 91  // throws "Config accessed before allowed" there. Pre-config, no OAuth token can
 92  // exist anyway — false is correct. Same swallow getFeatureValue_CACHED_MAY_BE_STALE
 93  // already does at growthbook.ts:775-780.
 94  function isClaudeAISubscriber(): boolean {
 95    try {
 96      return authModule.isClaudeAISubscriber()
 97    } catch {
 98      return false
 99    }
100  }
101  function hasProfileScope(): boolean {
102    try {
103      return authModule.hasProfileScope()
104    } catch {
105      return false
106    }
107  }
108  function getOauthAccountInfo(): ReturnType<
109    typeof authModule.getOauthAccountInfo
110  > {
111    try {
112      return authModule.getOauthAccountInfo()
113    } catch {
114      return undefined
115    }
116  }
117  
118  /**
119   * Runtime check for the env-less (v2) REPL bridge path.
120   * Returns true when the GrowthBook flag `tengu_bridge_repl_v2` is enabled.
121   *
122   * This gates which implementation initReplBridge uses — NOT whether bridge
123   * is available at all (see isBridgeEnabled above). Daemon/print paths stay
124   * on the env-based implementation regardless of this gate.
125   */
126  export function isEnvLessBridgeEnabled(): boolean {
127    return feature('BRIDGE_MODE')
128      ? getFeatureValue_CACHED_MAY_BE_STALE('tengu_bridge_repl_v2', false)
129      : false
130  }
131  
132  /**
133   * Kill-switch for the `cse_*` → `session_*` client-side retag shim.
134   *
135   * The shim exists because compat/convert.go:27 validates TagSession and the
136   * claude.ai frontend routes on `session_*`, while v2 worker endpoints hand out
137   * `cse_*`. Once the server tags by environment_kind and the frontend accepts
138   * `cse_*` directly, flip this to false to make toCompatSessionId a no-op.
139   * Defaults to true — the shim stays active until explicitly disabled.
140   */
141  export function isCseShimEnabled(): boolean {
142    return feature('BRIDGE_MODE')
143      ? getFeatureValue_CACHED_MAY_BE_STALE(
144          'tengu_bridge_repl_v2_cse_shim_enabled',
145          true,
146        )
147      : true
148  }
149  
150  /**
151   * Returns an error message if the current CLI version is below the
152   * minimum required for the v1 (env-based) Remote Control path, or null if the
153   * version is fine. The v2 (env-less) path uses checkEnvLessBridgeMinVersion()
154   * in envLessBridgeConfig.ts instead — the two implementations have independent
155   * version floors.
156   *
157   * Uses cached (non-blocking) GrowthBook config. If GrowthBook hasn't
158   * loaded yet, the default '0.0.0' means the check passes — a safe fallback.
159   */
160  export function checkBridgeMinVersion(): string | null {
161    // Positive pattern — see docs/feature-gating.md.
162    // Negative pattern (if (!feature(...)) return) does not eliminate
163    // inline string literals from external builds.
164    if (feature('BRIDGE_MODE')) {
165      const config = getDynamicConfig_CACHED_MAY_BE_STALE<{
166        minVersion: string
167      }>('tengu_bridge_min_version', { minVersion: '0.0.0' })
168      if (config.minVersion && lt(MACRO.VERSION, config.minVersion)) {
169        return `Your version of Claude Code (${MACRO.VERSION}) is too old for Remote Control.\nVersion ${config.minVersion} or higher is required. Run \`claude update\` to update.`
170      }
171    }
172    return null
173  }
174  
175  /**
176   * Default for remoteControlAtStartup when the user hasn't explicitly set it.
177   * When the CCR_AUTO_CONNECT build flag is present (ant-only) and the
178   * tengu_cobalt_harbor GrowthBook gate is on, all sessions connect to CCR by
179   * default — the user can still opt out by setting remoteControlAtStartup=false
180   * in config (explicit settings always win over this default).
181   *
182   * Defined here rather than in config.ts to avoid a direct
183   * config.ts → growthbook.ts import cycle (growthbook.ts → user.ts → config.ts).
184   */
185  export function getCcrAutoConnectDefault(): boolean {
186    return feature('CCR_AUTO_CONNECT')
187      ? getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_harbor', false)
188      : false
189  }
190  
191  /**
192   * Opt-in CCR mirror mode — every local session spawns an outbound-only
193   * Remote Control session that receives forwarded events. Separate from
194   * getCcrAutoConnectDefault (bidirectional Remote Control). Env var wins for
195   * local opt-in; GrowthBook controls rollout.
196   */
197  export function isCcrMirrorEnabled(): boolean {
198    return feature('CCR_MIRROR')
199      ? isEnvTruthy(process.env.CLAUDE_CODE_CCR_MIRROR) ||
200          getFeatureValue_CACHED_MAY_BE_STALE('tengu_ccr_mirror', false)
201      : false
202  }