| import { feature } from 'bun:bundle' | |
| import { | |
| checkGate_CACHED_OR_BLOCKING, | |
| getDynamicConfig_CACHED_MAY_BE_STALE, | |
| getFeatureValue_CACHED_MAY_BE_STALE, | |
| } from '../services/analytics/growthbook.js' | |
| // Namespace import breaks the bridgeEnabled β auth β config β bridgeEnabled | |
| // cycle β authModule.foo is a live binding, so by the time the helpers below | |
| // call it, auth.js is fully loaded. Previously used require() for the same | |
| // deferral, but require() hits a CJS cache that diverges from the ESM | |
| // namespace after mock.module() (daemon/auth.test.ts), breaking spyOn. | |
| import * as authModule from '../utils/auth.js' | |
| import { isEnvTruthy } from '../utils/envUtils.js' | |
| import { lt } from '../utils/semver.js' | |
| /** | |
| * Runtime check for bridge mode entitlement. | |
| * | |
| * Remote Control requires a claude.ai subscription (the bridge auths to CCR | |
| * with the claude.ai OAuth token). isClaudeAISubscriber() excludes | |
| * Bedrock/Vertex/Foundry, apiKeyHelper/gateway deployments, env-var API keys, | |
| * and Console API logins β none of which have the OAuth token CCR needs. | |
| * See github.com/deshaw/anthropic-issues/issues/24. | |
| * | |
| * The `feature('BRIDGE_MODE')` guard ensures the GrowthBook string literal | |
| * is only referenced when bridge mode is enabled at build time. | |
| */ | |
| export function isBridgeEnabled(): boolean { | |
| // Positive ternary pattern β see docs/feature-gating.md. | |
| // Negative pattern (if (!feature(...)) return) does not eliminate | |
| // inline string literals from external builds. | |
| return feature('BRIDGE_MODE') | |
| ? isClaudeAISubscriber() && | |
| getFeatureValue_CACHED_MAY_BE_STALE('tengu_ccr_bridge', false) | |
| : false | |
| } | |
| /** | |
| * Blocking entitlement check for Remote Control. | |
| * | |
| * Returns cached `true` immediately (fast path). If the disk cache says | |
| * `false` or is missing, awaits GrowthBook init and fetches the fresh | |
| * server value (slow path, max ~5s), then writes it to disk. | |
| * | |
| * Use at entitlement gates where a stale `false` would unfairly block access. | |
| * For user-facing error paths, prefer `getBridgeDisabledReason()` which gives | |
| * a specific diagnostic. For render-body UI visibility checks, use | |
| * `isBridgeEnabled()` instead. | |
| */ | |
| export async function isBridgeEnabledBlocking(): Promise<boolean> { | |
| return feature('BRIDGE_MODE') | |
| ? isClaudeAISubscriber() && | |
| (await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge')) | |
| : false | |
| } | |
| /** | |
| * Diagnostic message for why Remote Control is unavailable, or null if | |
| * it's enabled. Call this instead of a bare `isBridgeEnabledBlocking()` | |
| * check when you need to show the user an actionable error. | |
| * | |
| * The GrowthBook gate targets on organizationUUID, which comes from | |
| * config.oauthAccount β populated by /api/oauth/profile during login. | |
| * That endpoint requires the user:profile scope. Tokens without it | |
| * (setup-token, CLAUDE_CODE_OAUTH_TOKEN env var, or pre-scope-expansion | |
| * logins) leave oauthAccount unpopulated, so the gate falls back to | |
| * false and users see a dead-end "not enabled" message with no hint | |
| * that re-login would fix it. See CC-1165 / gh-33105. | |
| */ | |
| export async function getBridgeDisabledReason(): Promise<string | null> { | |
| if (feature('BRIDGE_MODE')) { | |
| if (!isClaudeAISubscriber()) { | |
| return 'Remote Control requires a claude.ai subscription. Run `claude auth login` to sign in with your claude.ai account.' | |
| } | |
| if (!hasProfileScope()) { | |
| 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.' | |
| } | |
| if (!getOauthAccountInfo()?.organizationUuid) { | |
| return 'Unable to determine your organization for Remote Control eligibility. Run `claude auth login` to refresh your account information.' | |
| } | |
| if (!(await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge'))) { | |
| return 'Remote Control is not yet enabled for your account.' | |
| } | |
| return null | |
| } | |
| return 'Remote Control is not available in this build.' | |
| } | |
| // try/catch: main.tsx:5698 calls isBridgeEnabled() while defining the Commander | |
| // program, before enableConfigs() runs. isClaudeAISubscriber() β getGlobalConfig() | |
| // throws "Config accessed before allowed" there. Pre-config, no OAuth token can | |
| // exist anyway β false is correct. Same swallow getFeatureValue_CACHED_MAY_BE_STALE | |
| // already does at growthbook.ts:775-780. | |
| function isClaudeAISubscriber(): boolean { | |
| try { | |
| return authModule.isClaudeAISubscriber() | |
| } catch { | |
| return false | |
| } | |
| } | |
| function hasProfileScope(): boolean { | |
| try { | |
| return authModule.hasProfileScope() | |
| } catch { | |
| return false | |
| } | |
| } | |
| function getOauthAccountInfo(): ReturnType< | |
| typeof authModule.getOauthAccountInfo | |
| > { | |
| try { | |
| return authModule.getOauthAccountInfo() | |
| } catch { | |
| return undefined | |
| } | |
| } | |
| /** | |
| * Runtime check for the env-less (v2) REPL bridge path. | |
| * Returns true when the GrowthBook flag `tengu_bridge_repl_v2` is enabled. | |
| * | |
| * This gates which implementation initReplBridge uses β NOT whether bridge | |
| * is available at all (see isBridgeEnabled above). Daemon/print paths stay | |
| * on the env-based implementation regardless of this gate. | |
| */ | |
| export function isEnvLessBridgeEnabled(): boolean { | |
| return feature('BRIDGE_MODE') | |
| ? getFeatureValue_CACHED_MAY_BE_STALE('tengu_bridge_repl_v2', false) | |
| : false | |
| } | |
| /** | |
| * Kill-switch for the `cse_*` β `session_*` client-side retag shim. | |
| * | |
| * The shim exists because compat/convert.go:27 validates TagSession and the | |
| * claude.ai frontend routes on `session_*`, while v2 worker endpoints hand out | |
| * `cse_*`. Once the server tags by environment_kind and the frontend accepts | |
| * `cse_*` directly, flip this to false to make toCompatSessionId a no-op. | |
| * Defaults to true β the shim stays active until explicitly disabled. | |
| */ | |
| export function isCseShimEnabled(): boolean { | |
| return feature('BRIDGE_MODE') | |
| ? getFeatureValue_CACHED_MAY_BE_STALE( | |
| 'tengu_bridge_repl_v2_cse_shim_enabled', | |
| true, | |
| ) | |
| : true | |
| } | |
| /** | |
| * Returns an error message if the current CLI version is below the | |
| * minimum required for the v1 (env-based) Remote Control path, or null if the | |
| * version is fine. The v2 (env-less) path uses checkEnvLessBridgeMinVersion() | |
| * in envLessBridgeConfig.ts instead β the two implementations have independent | |
| * version floors. | |
| * | |
| * Uses cached (non-blocking) GrowthBook config. If GrowthBook hasn't | |
| * loaded yet, the default '0.0.0' means the check passes β a safe fallback. | |
| */ | |
| export function checkBridgeMinVersion(): string | null { | |
| // Positive pattern β see docs/feature-gating.md. | |
| // Negative pattern (if (!feature(...)) return) does not eliminate | |
| // inline string literals from external builds. | |
| if (feature('BRIDGE_MODE')) { | |
| const config = getDynamicConfig_CACHED_MAY_BE_STALE<{ | |
| minVersion: string | |
| }>('tengu_bridge_min_version', { minVersion: '0.0.0' }) | |
| if (config.minVersion && lt(MACRO.VERSION, config.minVersion)) { | |
| 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.` | |
| } | |
| } | |
| return null | |
| } | |
| /** | |
| * Default for remoteControlAtStartup when the user hasn't explicitly set it. | |
| * When the CCR_AUTO_CONNECT build flag is present (ant-only) and the | |
| * tengu_cobalt_harbor GrowthBook gate is on, all sessions connect to CCR by | |
| * default β the user can still opt out by setting remoteControlAtStartup=false | |
| * in config (explicit settings always win over this default). | |
| * | |
| * Defined here rather than in config.ts to avoid a direct | |
| * config.ts β growthbook.ts import cycle (growthbook.ts β user.ts β config.ts). | |
| */ | |
| export function getCcrAutoConnectDefault(): boolean { | |
| return feature('CCR_AUTO_CONNECT') | |
| ? getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_harbor', false) | |
| : false | |
| } | |
| /** | |
| * Opt-in CCR mirror mode β every local session spawns an outbound-only | |
| * Remote Control session that receives forwarded events. Separate from | |
| * getCcrAutoConnectDefault (bidirectional Remote Control). Env var wins for | |
| * local opt-in; GrowthBook controls rollout. | |
| */ | |
| export function isCcrMirrorEnabled(): boolean { | |
| return feature('CCR_MIRROR') | |
| ? isEnvTruthy(process.env.CLAUDE_CODE_CCR_MIRROR) || | |
| getFeatureValue_CACHED_MAY_BE_STALE('tengu_ccr_mirror', false) | |
| : false | |
| } | |