Skip to main content

ai_agent/bridge/
bridge_enabled.rs

1//! Runtime check for bridge mode entitlement.
2//!
3//! Translated from openclaudecode/src/bridge/bridgeEnabled.ts
4//!
5//! Remote Control requires a claude.ai subscription. isClaudeAISubscriber()
6//! excludes Bedrock/Vertex/Foundry, apiKeyHelper/gateway deployments, env-var
7//! API keys, and Console API logins.
8
9use std::env;
10use std::sync::OnceLock;
11
12// =============================================================================
13// STATIC GETTERS (for dependency injection)
14// =============================================================================
15
16/// Gate check function: (gate_name: &str) -> bool
17pub type GateFn = Box<dyn Fn(&str) -> bool + Send + Sync>;
18
19/// Dynamic config getter: (key: &str) -> Option<Value>
20pub type DynamicConfigFn = Box<dyn Fn(&str) -> Option<serde_json::Value> + Send + Sync>;
21
22/// Version checker: (current: &str, min: &str) -> bool
23pub type VersionCheckFn = Box<dyn Fn(&str, &str) -> bool + Send + Sync>;
24
25/// Subscriber check: () -> bool
26pub type SubscriberCheckFn = Box<dyn Fn() -> bool + Send + Sync>;
27
28/// Profile scope check: () -> bool
29pub type ProfileScopeFn = Box<dyn Fn() -> bool + Send + Sync>;
30
31/// OAuth account info getter: () -> Option<OAuthAccountInfo>
32pub type OauthAccountFn = Box<dyn Fn() -> Option<OAuthAccountInfo> + Send + Sync>;
33
34/// Env truthy check: (key: &str) -> bool
35pub type EnvTruthyFn = Box<dyn Fn(&str) -> bool + Send + Sync>;
36
37static GATE_GETTER: OnceLock<GateFn> = OnceLock::new();
38static DYNAMIC_CONFIG_GETTER: OnceLock<DynamicConfigFn> = OnceLock::new();
39static VERSION_CHECK_GETTER: OnceLock<VersionCheckFn> = OnceLock::new();
40static SUBSCRIBER_CHECK: OnceLock<SubscriberCheckFn> = OnceLock::new();
41static PROFILE_SCOPE_CHECK: OnceLock<ProfileScopeFn> = OnceLock::new();
42static OAUTH_ACCOUNT_GETTER: OnceLock<OauthAccountFn> = OnceLock::new();
43static ENV_TRUTHY_CHECK: OnceLock<EnvTruthyFn> = OnceLock::new();
44
45// Build-time feature flags (these would be set at compile time)
46static BRIDGE_MODE: OnceLock<bool> = OnceLock::new();
47static CCR_AUTO_CONNECT: OnceLock<bool> = OnceLock::new();
48static CCR_MIRROR: OnceLock<bool> = OnceLock::new();
49
50// =============================================================================
51// TYPES
52// =============================================================================
53
54#[derive(Debug, Clone, Default)]
55pub struct OAuthAccountInfo {
56    pub organization_uuid: Option<String>,
57}
58
59// =============================================================================
60// INITIALIZATION
61// =============================================================================
62
63/// Register the gate check function (from GrowthBook).
64pub fn register_gate_check(gate: impl Fn(&str) -> bool + Send + Sync + 'static) {
65    let _ = GATE_GETTER.set(Box::new(gate));
66}
67
68/// Register the dynamic config getter function.
69pub fn register_dynamic_config(
70    getter: impl Fn(&str) -> Option<serde_json::Value> + Send + Sync + 'static,
71) {
72    let _ = DYNAMIC_CONFIG_GETTER.set(Box::new(getter));
73}
74
75/// Register the version checker function.
76pub fn register_version_check(checker: impl Fn(&str, &str) -> bool + Send + Sync + 'static) {
77    let _ = VERSION_CHECK_GETTER.set(Box::new(checker));
78}
79
80/// Register the subscriber check function.
81pub fn register_subscriber_check(check: impl Fn() -> bool + Send + Sync + 'static) {
82    let _ = SUBSCRIBER_CHECK.set(Box::new(check));
83}
84
85/// Register the profile scope check function.
86pub fn register_profile_scope_check(check: impl Fn() -> bool + Send + Sync + 'static) {
87    let _ = PROFILE_SCOPE_CHECK.set(Box::new(check));
88}
89
90/// Register the OAuth account info getter.
91pub fn register_oauth_account_getter(
92    getter: impl Fn() -> Option<OAuthAccountInfo> + Send + Sync + 'static,
93) {
94    let _ = OAUTH_ACCOUNT_GETTER.set(Box::new(getter));
95}
96
97/// Register the env truthy check function.
98pub fn register_env_truthy_check(check: impl Fn(&str) -> bool + Send + Sync + 'static) {
99    let _ = ENV_TRUTHY_CHECK.set(Box::new(check));
100}
101
102/// Set build-time feature flags.
103pub fn set_bridge_mode(enabled: bool) {
104    let _ = BRIDGE_MODE.set(enabled);
105}
106
107pub fn set_ccr_auto_connect(enabled: bool) {
108    let _ = CCR_AUTO_CONNECT.set(enabled);
109}
110
111pub fn set_ccr_mirror(enabled: bool) {
112    let _ = CCR_MIRROR.set(enabled);
113}
114
115// =============================================================================
116// GATE CHECK HELPERS
117// =============================================================================
118
119fn get_gate(gate_name: &str) -> bool {
120    GATE_GETTER
121        .get()
122        .map(|gate| gate(gate_name))
123        .unwrap_or(false)
124}
125
126fn get_dynamic_config(key: &str) -> Option<serde_json::Value> {
127    DYNAMIC_CONFIG_GETTER.get().and_then(|getter| getter(key))
128}
129
130fn check_version(current: &str, min: &str) -> bool {
131    VERSION_CHECK_GETTER
132        .get()
133        .map(|check| check(current, min))
134        .unwrap_or_else(|| {
135            // Simple fallback: compare as strings for common cases
136            // In real usage, semver would be used
137            current >= min
138        })
139}
140
141fn is_claude_ai_subscriber() -> bool {
142    SUBSCRIBER_CHECK.get().map(|check| check()).unwrap_or(false)
143}
144
145fn has_profile_scope() -> bool {
146    PROFILE_SCOPE_CHECK
147        .get()
148        .map(|check| check())
149        .unwrap_or(false)
150}
151
152fn get_oauth_account_info() -> Option<OAuthAccountInfo> {
153    OAUTH_ACCOUNT_GETTER.get().and_then(|getter| getter())
154}
155
156fn is_env_truthy(key: &str) -> bool {
157    ENV_TRUTHY_CHECK
158        .get()
159        .map(|check| check(key))
160        .unwrap_or_else(|| {
161            env::var(key)
162                .map(|v| v == "1" || v.to_lowercase() == "true")
163                .unwrap_or(false)
164        })
165}
166
167fn bridge_mode_enabled() -> bool {
168    BRIDGE_MODE.get().copied().unwrap_or(false)
169}
170
171fn ccr_auto_connect_enabled() -> bool {
172    CCR_AUTO_CONNECT.get().copied().unwrap_or(false)
173}
174
175fn ccr_mirror_enabled() -> bool {
176    CCR_MIRROR.get().copied().unwrap_or(false)
177}
178
179// =============================================================================
180// PUBLIC API
181// =============================================================================
182
183/// Runtime check for bridge mode entitlement.
184/// Returns true when both the build flag and GrowthBook gate are enabled.
185pub fn is_bridge_enabled() -> bool {
186    if !bridge_mode_enabled() {
187        return false;
188    }
189
190    // In production, we'd check both conditions
191    // For SDK, we default to true if gate not set
192    get_gate("tengu_ccr_bridge")
193}
194
195/// Blocking entitlement check for Remote Control.
196/// Currently just returns the same as is_bridge_enabled.
197pub fn is_bridge_enabled_blocking() -> bool {
198    is_bridge_enabled()
199}
200
201/// Diagnostic message for why Remote Control is unavailable, or None if
202/// it's enabled.
203pub fn get_bridge_disabled_reason() -> Option<String> {
204    if !bridge_mode_enabled() {
205        return Some("Remote Control is not available in this build.".to_string());
206    }
207
208    if !is_claude_ai_subscriber() {
209        return Some(
210            "Remote Control requires a claude.ai subscription. Run `claude auth login` to sign in \
211             with your claude.ai account."
212                .to_string(),
213        );
214    }
215
216    if !has_profile_scope() {
217        return Some(
218            "Remote Control requires a full-scope login token. Long-lived tokens (from `claude \
219             setup-token` or AI_CODE_OAUTH_TOKEN) are limited to inference-only for security \
220             reasons. Run `claude auth login` to use Remote Control."
221                .to_string(),
222        );
223    }
224
225    let account = get_oauth_account_info();
226    if account.is_none()
227        || account
228            .as_ref()
229            .and_then(|a| a.organization_uuid.as_ref())
230            .is_none()
231    {
232        return Some(
233            "Unable to determine your organization for Remote Control eligibility. Run \
234             `claude auth login` to refresh your account information."
235                .to_string(),
236        );
237    }
238
239    if !get_gate("tengu_ccr_bridge") {
240        return Some("Remote Control is not yet enabled for your account.".to_string());
241    }
242
243    None
244}
245
246/// Runtime check for the env-less (v2) REPL bridge path.
247/// Returns true when the GrowthBook flag is enabled.
248pub fn is_env_less_bridge_enabled() -> bool {
249    if !bridge_mode_enabled() {
250        return false;
251    }
252
253    get_gate("tengu_bridge_repl_v2")
254}
255
256/// Kill-switch for the cse_ -> session_ client-side retag shim.
257/// Defaults to true — the shim stays active until explicitly disabled.
258pub fn is_cse_shim_enabled() -> bool {
259    if !bridge_mode_enabled() {
260        return true;
261    }
262
263    // Get the feature value, default to true
264    get_dynamic_config("tengu_bridge_repl_v2_cse_shim_enabled")
265        .and_then(|v| v.as_bool())
266        .unwrap_or(true)
267}
268
269/// Check if the current CLI version meets the minimum required for Remote Control.
270/// Returns an error message if version is too old, or None if OK.
271pub fn check_bridge_min_version(current_version: &str) -> Option<String> {
272    if !bridge_mode_enabled() {
273        return None;
274    }
275
276    let config = get_dynamic_config("tengu_bridge_min_version");
277    let min_version = config
278        .and_then(|c| c.get("minVersion").cloned())
279        .and_then(|v| v.as_str().map(|s| s.to_string()))
280        .unwrap_or_else(|| "0.0.0".to_string());
281
282    if !check_version(current_version, &min_version) {
283        return Some(format!(
284            "Your version of Claude Code ({}) is too old for Remote Control.\nVersion {} or \
285             higher is required. Run `claude update` to update.",
286            current_version, min_version
287        ));
288    }
289
290    None
291}
292
293/// Default for remoteControlAtStartup when the user hasn't explicitly set it.
294/// When the CCR_AUTO_CONNECT build flag is present and the GrowthBook gate
295/// is on, all sessions connect to CCR by default.
296pub fn get_ccr_auto_connect_default() -> bool {
297    if !ccr_auto_connect_enabled() {
298        return false;
299    }
300
301    get_gate("tengu_cobalt_harbor")
302}
303
304/// Opt-in CCR mirror mode — every local session spawns an outbound-only
305/// Remote Control session that receives forwarded events.
306pub fn is_ccr_mirror_enabled() -> bool {
307    if !ccr_mirror_enabled() {
308        return false;
309    }
310
311    is_env_truthy("AI_CODE_CCR_MIRROR") || get_gate("tengu_ccr_mirror")
312}
313
314// =============================================================================
315// CSE SHIM GATE REGISTRATION
316// =============================================================================
317
318/// Register the CSE shim gate with the session_id_compat module.
319pub fn register_cse_shim_gate() {
320    use crate::bridge::session_id_compat::set_cse_shim_gate;
321    set_cse_shim_gate(is_cse_shim_enabled);
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn test_bridge_disabled_without_build_flag() {
330        // Without setting bridge mode, should be disabled
331        assert!(!is_bridge_enabled());
332    }
333
334    #[test]
335    fn test_env_less_bridge_default() {
336        assert!(!is_env_less_bridge_enabled());
337    }
338
339    #[test]
340    fn test_cse_shim_default() {
341        // Default to true when not in bridge mode
342        assert!(is_cse_shim_enabled());
343    }
344
345    #[test]
346    fn test_check_bridge_min_version() {
347        let result = check_bridge_min_version("1.0.0");
348        // Without config set, should pass (default min is 0.0.0)
349        assert!(result.is_none());
350    }
351}