From 4be66935990a957340d475e0d6d0690e360a6a17 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 10 Feb 2026 01:25:14 +0530 Subject: [PATCH] Fix OAuth credential lifecycle and JWT token sync for skills (#82) * Refactor BillingPanel to use updated user usage structure - Changed the usage data source in BillingPanel from activeTeam to user, aligning with the new IUserUsage interface. - Updated the display of token usage percentage and progress bar to reflect the new usage metrics (spentThisCycleUsd and cycleBudgetUsd). - Cleaned up commented-out code and improved layout for better readability and user experience. * Update OAuth credential handling in SkillManager and related state management - Introduced `setSkillOAuthCredential` action to manage OAuth credentials in the Redux store. - Enhanced `SkillManager` to persist and restore OAuth credentials during skill setup and disconnection. - Updated `SkillState` interface to include `oauthCredential` for better state management. - Refactored related components to ensure seamless integration of OAuth handling. - Updated subproject commit reference in the skills directory. * Update connection indicator messages and enhance OAuth credential handling - Changed the default description in the ConnectionIndicator component to refer to "Your device" instead of "Your browser." - Removed hardcoded connection indicator in GetStartedStep to use the updated description. - Improved OAuth credential management by restoring persisted credentials on startup and ensuring they are cleared from the store when revoked. - Updated HTTP client configuration to use native TLS for better compatibility across platforms. - Cleaned up Cargo.lock and Cargo.toml by removing unused dependencies and ensuring proper feature flags for reqwest. * Enhance OAuth credential management in QjsSkillInstance - Removed redundant comment regarding OAuth credential restoration. - Implemented lazy-loading of persisted OAuth credentials before tool calls. - Cleared OAuth credentials from memory upon skill stop and marked as disconnected in the store. - Updated comments for clarity on credential handling during OAuth operations. * Refactor JWT token retrieval to use session token method - Updated the JWT token retrieval in multiple functions to utilize the new `get_session_token` method from `__ops`, enhancing security and consistency in token management. - Added the `get_session_token` function to the operations core for improved session handling. * update bootstrap * Add middleware to sync JWT token with Rust SESSION_SERVICE - Introduced `syncTokenToRust` middleware to synchronize the JWT token with the Rust SESSION_SERVICE whenever the `setToken` action is dispatched or the auth state is rehydrated. - Enhanced the `get_session_token` function in Rust to log the retrieved token for better debugging and monitoring. - Updated Redux store configuration to include the new middleware, improving token management and security. * update skills * Update subproject commit reference in skills directory to indicate a dirty state * Remove unused ConnectionIndicator import from GetStartedStep component --- skills | 2 +- src-tauri/Cargo.toml | 5 +- src-tauri/src/runtime/bridge/net.rs | 13 +- src-tauri/src/runtime/qjs_skill_instance.rs | 74 +++++++++-- .../src/services/quickjs-libs/bootstrap.js | 16 +-- .../services/quickjs-libs/qjs_ops/ops_core.rs | 6 + .../services/quickjs-libs/qjs_ops/ops_net.rs | 15 ++- .../services/quickjs-libs/qjs_ops/types.rs | 4 +- src/components/ConnectionIndicator.tsx | 2 +- .../settings/panels/BillingPanel.tsx | 115 +++++++++--------- src/lib/skills/manager.ts | 19 ++- src/lib/skills/types.ts | 8 ++ src/pages/onboarding/steps/GetStartedStep.tsx | 4 - src/store/index.ts | 35 +++++- src/store/skillsSlice.ts | 9 ++ src/types/api.ts | 12 +- src/utils/desktopDeepLinkListener.ts | 2 +- 17 files changed, 244 insertions(+), 97 deletions(-) diff --git a/skills b/skills index 87d75f69b..26341b529 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit 87d75f69b7e600ab9fc5cee9d861546e01c89fac +Subproject commit 26341b529751b46e9be28bd7c46b1a978b5bbc7d diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bbd2195c1..58aa58f29 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,8 +29,9 @@ tauri-plugin-os = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -# HTTP client (rustls for cross-platform TLS support including Android) -reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "stream"] } +# HTTP client: rustls for cross-platform TLS (Android), native-tls for desktop skill HTTP +# (some APIs/CDNs use TLS configs that rustls can't negotiate — native-tls uses OS TLS stack) +reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "native-tls", "stream"] } # Async runtime tokio = { version = "1", features = ["full", "sync"] } diff --git a/src-tauri/src/runtime/bridge/net.rs b/src-tauri/src/runtime/bridge/net.rs index 532cf9872..b938aa68c 100644 --- a/src-tauri/src/runtime/bridge/net.rs +++ b/src-tauri/src/runtime/bridge/net.rs @@ -51,6 +51,7 @@ fn do_fetch(url: &str, options_json: &str) -> Result { let timeout_secs = options.timeout.unwrap_or(30); let client = reqwest::blocking::Client::builder() + .use_native_tls() .timeout(std::time::Duration::from_secs(timeout_secs)) .build() .map_err(|e| format!("Failed to create HTTP client: {e}"))?; @@ -74,7 +75,17 @@ fn do_fetch(url: &str, options_json: &str) -> Result { req = req.body(body); } - let resp = req.send().map_err(|e| format!("HTTP request failed: {e}"))?; + let resp = req.send().map_err(|e| { + // Walk the full error chain so the caller sees the root cause + // (TLS, DNS, timeout, etc.) not just "error sending request for url". + let mut msg = format!("HTTP request failed: {e}"); + let mut source = std::error::Error::source(&e); + while let Some(cause) = source { + msg.push_str(&format!(" | caused by: {cause}")); + source = std::error::Error::source(cause); + } + msg + })?; let status = resp.status().as_u16(); let headers: HashMap = resp diff --git a/src-tauri/src/runtime/qjs_skill_instance.rs b/src-tauri/src/runtime/qjs_skill_instance.rs index 47aa444df..bbc6e2973 100644 --- a/src-tauri/src/runtime/qjs_skill_instance.rs +++ b/src-tauri/src/runtime/qjs_skill_instance.rs @@ -227,6 +227,8 @@ impl QjsSkillInstance { return; } + restore_oauth_credential(&ctx, &config.skill_id).await; + // Call init() lifecycle if let Err(e) = call_lifecycle(&ctx, "init").await { let mut s = state.write(); @@ -389,6 +391,8 @@ async fn handle_message( ) -> bool { match msg { SkillMessage::CallTool { tool_name, arguments, reply } => { + // Lazy-load persisted OAuth credential before calling the tool + restore_oauth_credential(ctx, skill_id).await; let result = handle_tool_call(ctx, &tool_name, arguments).await; let _ = reply.send(result); } @@ -400,9 +404,23 @@ async fn handle_message( } SkillMessage::Stop { reply } => { let _ = call_lifecycle(ctx, "stop").await; + + // Clear OAuth credential from memory and mark as disconnected in store + let clear_code = r#"(function() { + if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) { + globalThis.oauth.__setCredential(null); + } + if (typeof globalThis.state !== 'undefined' && globalThis.state.set) { + globalThis.state.set('__oauth_credential', ''); + } + })()"#; + ctx.with(|js_ctx| { + let _ = js_ctx.eval::(clear_code.as_bytes()); + }).await; state.write().status = SkillStatus::Stopped; - log::info!("[skill:{}] Stopped", skill_id); + log::info!("[skill:{}] Stopped (OAuth credential cleared)", skill_id); let _ = reply.send(()); + return true; // Signal to stop } SkillMessage::SetupStart { reply } => { @@ -444,23 +462,40 @@ async fn handle_message( SkillMessage::Rpc { method, params, reply } => { let result = match method.as_str() { "oauth/complete" => { - // Set credential on the oauth bridge, then call onOAuthComplete - let set_cred_code = format!( - "if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) {{ globalThis.oauth.__setCredential({}); }}", - serde_json::to_string(¶ms).unwrap_or_else(|_| "null".to_string()) + // Set credential on the oauth bridge + persist to store + let cred_json = serde_json::to_string(¶ms).unwrap_or_else(|_| "null".to_string()); + let code = format!( + r#"(function() {{ + if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) {{ + globalThis.oauth.__setCredential({cred}); + }} + if (typeof globalThis.state !== 'undefined' && globalThis.state.set) {{ + globalThis.state.set('__oauth_credential', {cred}); + }} + }})()"#, + cred = cred_json ); ctx.with(|js_ctx| { - let _ = js_ctx.eval::(set_cred_code.as_bytes()); + let _ = js_ctx.eval::(code.as_bytes()); }).await; + log::info!("[skill:{}] OAuth credential set and persisted to store", skill_id); let params_str = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string()); handle_js_call(ctx, "onOAuthComplete", ¶ms_str).await } "oauth/revoked" => { - // Clear credential on the oauth bridge, then call onOAuthRevoked - let clear_code = "if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) { globalThis.oauth.__setCredential(null); }"; + // Clear credential: set to empty string so it's clearly "disconnected" + let clear_code = r#"(function() { + if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) { + globalThis.oauth.__setCredential(null); + } + if (typeof globalThis.state !== 'undefined' && globalThis.state.set) { + globalThis.state.set('__oauth_credential', ''); + } + })()"#; ctx.with(|js_ctx| { let _ = js_ctx.eval::(clear_code.as_bytes()); }).await; + log::info!("[skill:{}] OAuth credential cleared from store", skill_id); let params_str = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string()); handle_js_void_call(ctx, "onOAuthRevoked", ¶ms_str).await .map(|()| serde_json::json!({ "ok": true })) @@ -749,3 +784,26 @@ async fn handle_js_void_call( .map(|_| ()) }).await } + +/// Load a persisted OAuth credential from the skill's store and inject it +/// into the JS context so tools have access to the credential. +/// An empty string means "disconnected" — only non-empty values are restored. +async fn restore_oauth_credential(ctx: &rquickjs::AsyncContext, skill_id: &str) { + let code = r#"(function() { + if (typeof globalThis.state === 'undefined' || typeof globalThis.oauth === 'undefined') return false; + var cred = globalThis.state.get('__oauth_credential'); + if (cred && cred !== '' && globalThis.oauth.__setCredential) { + globalThis.oauth.__setCredential(cred); + return true; + } + return false; + })()"#; + + let restored = ctx.with(|js_ctx| { + js_ctx.eval::(code.as_bytes()).unwrap_or(false) + }).await; + + if restored { + log::info!("[skill:{}] Restored OAuth credential from store", skill_id); + } +} diff --git a/src-tauri/src/services/quickjs-libs/bootstrap.js b/src-tauri/src/services/quickjs-libs/bootstrap.js index c79999696..fffac251c 100644 --- a/src-tauri/src/services/quickjs-libs/bootstrap.js +++ b/src-tauri/src/services/quickjs-libs/bootstrap.js @@ -807,12 +807,12 @@ globalThis.data = { // OAuth Bridge API (credential management and authenticated proxy) // ============================================================================ (function () { - var __oauthCredential = null; + globalThis.__oauthCredential = null; globalThis.oauth = { /** Get the current OAuth credential, or null if not connected. */ getCredential: function () { - return __oauthCredential; + return globalThis.__oauthCredential; }, /** @@ -820,7 +820,7 @@ globalThis.data = { * Path is relative to manifest's apiBaseUrl. */ fetch: function (path, options) { - if (!__oauthCredential) { + if (!globalThis.__oauthCredential) { return { status: 401, headers: {}, @@ -828,9 +828,9 @@ globalThis.data = { }; } var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz'; - var jwtToken = __platform.env('JWT_TOKEN') || ''; + var jwtToken = __ops.get_session_token() || ''; var cleanPath = path.charAt(0) === '/' ? path.slice(1) : path; - var proxyUrl = backendUrl + '/proxy/by-id/' + __oauthCredential.credentialId + '/' + cleanPath; + var proxyUrl = backendUrl + '/proxy/by-id/' + globalThis.__oauthCredential.credentialId + '/' + cleanPath; var method = (options && options.method) || 'GET'; var headers = { 'Content-Type': 'application/json' }; if (jwtToken) { @@ -856,7 +856,7 @@ globalThis.data = { if (__oauthCredential) { try { var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz'; - var jwtToken = __platform.env('JWT_TOKEN') || ''; + var jwtToken = __ops.get_session_token() || ''; var revokeOpts = JSON.stringify({ method: 'DELETE', headers: { @@ -993,7 +993,7 @@ globalThis.model = { */ generate: function (prompt, options) { var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz'; - var jwtToken = __platform.env('JWT_TOKEN') || ''; + var jwtToken = __ops.get_session_token() || ''; var body = { prompt: prompt }; if (options && options.maxTokens) body.maxTokens = options.maxTokens; if (options && options.temperature) body.temperature = options.temperature; @@ -1023,7 +1023,7 @@ globalThis.model = { */ summarize: function (text, options) { var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz'; - var jwtToken = __platform.env('JWT_TOKEN') || ''; + var jwtToken = __ops.get_session_token() || ''; var body = { text: text }; if (options && options.maxTokens) body.maxTokens = options.maxTokens; var result = __net.fetch(backendUrl + '/api/ai/summarize', JSON.stringify({ diff --git a/src-tauri/src/services/quickjs-libs/qjs_ops/ops_core.rs b/src-tauri/src/services/quickjs-libs/qjs_ops/ops_core.rs index b9c128e78..9d6c17b7f 100644 --- a/src-tauri/src/services/quickjs-libs/qjs_ops/ops_core.rs +++ b/src-tauri/src/services/quickjs-libs/qjs_ops/ops_core.rs @@ -81,6 +81,12 @@ pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, timer_state: Arc String { + let token = crate::commands::auth::SESSION_SERVICE.get_token().unwrap_or_default(); + log::info!("[js] get_session_token: {}", token); + return token; + }))?; + // ======================================================================== // Timers (2) // ======================================================================== diff --git a/src-tauri/src/services/quickjs-libs/qjs_ops/ops_net.rs b/src-tauri/src/services/quickjs-libs/qjs_ops/ops_net.rs index 33c99f386..14863d5f2 100644 --- a/src-tauri/src/services/quickjs-libs/qjs_ops/ops_net.rs +++ b/src-tauri/src/services/quickjs-libs/qjs_ops/ops_net.rs @@ -21,7 +21,10 @@ pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, ws_state: Arc client.get(&url), "POST" => client.post(&url), @@ -42,7 +45,15 @@ pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, ws_state: Arc Result<(), String> { } pub fn js_err(msg: String) -> rquickjs::Error { - rquickjs::Error::new_from_js_message("ops", "Error", msg) + rquickjs::Error::new_from_js_message("skill", "RuntimeError", msg) } diff --git a/src/components/ConnectionIndicator.tsx b/src/components/ConnectionIndicator.tsx index 1871ea55a..7d930154b 100644 --- a/src/components/ConnectionIndicator.tsx +++ b/src/components/ConnectionIndicator.tsx @@ -9,7 +9,7 @@ interface ConnectionIndicatorProps { const ConnectionIndicator = ({ status: overrideStatus, - description = 'Your browser is now connected to the AlphaHuman AI. Keep the app running to keep the connection alive. You can message your assistant with the button below.', + description = 'Your device is now connected to the AlphaHuman AI. Keep the app running to keep the connection alive. You can message your assistant with the button below.', className = '', }: ConnectionIndicatorProps) => { // Use socket store status, but allow override via props diff --git a/src/components/settings/panels/BillingPanel.tsx b/src/components/settings/panels/BillingPanel.tsx index 58b1da445..6ec781155 100644 --- a/src/components/settings/panels/BillingPanel.tsx +++ b/src/components/settings/panels/BillingPanel.tsx @@ -31,7 +31,7 @@ const BillingPanel = () => { const currentTier: PlanTier = activeTeam?.team.subscription?.plan ?? 'FREE'; const hasActive = activeTeam?.team.subscription?.hasActiveSubscription ?? false; const planExpiry = activeTeam?.team.subscription?.planExpiry; - const usage = activeTeam?.team.usage; + const usage = user?.usage; // Local state const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly'); @@ -135,69 +135,66 @@ const BillingPanel = () => { onBack={navigateBack} /> + {/*
*/}
- {/* ── Current plan banner ──────────────────────────────── */} -
-
-

Your Current Plan {currentTier}

- {usage && ( - - {Math.round( - ((usage.dailyTokenLimit - usage.remainingTokens) / usage.dailyTokenLimit) * 100 +
+
+
+

+ Your Current Plan {currentTier} +

+ {usage && ( + + {Math.round((usage.spentThisCycleUsd / usage.cycleBudgetUsd) * 100)}% used + + )} +
+ + {hasActive && ( +
+ {planExpiry && ( +

+ Renews{' '} + {new Date(planExpiry).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + })} +

)} - % used - + +
+ )} + {/* Renewal date (for non-active subscriptions) */} + {!hasActive && planExpiry && ( +

+ Renews{' '} + {new Date(planExpiry).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + })} +

+ )} + {usage && ( +
+
+
)}
- - {hasActive && ( -
- {planExpiry && ( -

- Renews{' '} - {new Date(planExpiry).toLocaleDateString('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric', - })} -

- )} - -
- )} - - {/* Renewal date (for non-active subscriptions) */} - {!hasActive && planExpiry && ( -

- Renews{' '} - {new Date(planExpiry).toLocaleDateString('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric', - })} -

- )} - - {/* Token usage progress bar */} - {usage && ( -
-
-
- )}
{/* ── Interval toggle ──────────────────────────────────── */} diff --git a/src/lib/skills/manager.ts b/src/lib/skills/manager.ts index 925670681..23938cb43 100644 --- a/src/lib/skills/manager.ts +++ b/src/lib/skills/manager.ts @@ -22,6 +22,7 @@ import { setSkillStatus, setSkillError, setSkillSetupComplete, + setSkillOAuthCredential, setSkillTools, setSkillState, } from "../../store/skillsSlice"; @@ -98,6 +99,15 @@ class SkillManager { if (setupRequired) { store.dispatch(setSkillStatus({ skillId, status: "setup_required" })); } else { + // Re-inject persisted OAuth credential if available + const oauthCred = skillState?.oauthCredential; + if (oauthCred) { + try { + await runtime.oauthComplete(oauthCred); + } catch (err) { + console.warn(`[SkillManager] Failed to restore OAuth credential for ${skillId}:`, err); + } + } // Skill is ready — list tools await this.activateSkill(skillId); } @@ -251,12 +261,16 @@ class SkillManager { const manifest = store.getState().skills.skills[skillId]?.manifest; - await runtime.oauthComplete({ + const credential = { credentialId: integrationId, provider: provider ?? manifest?.setup?.oauth?.provider ?? "unknown", grantedScopes: manifest?.setup?.oauth?.scopes ?? [], - }); + }; + await runtime.oauthComplete(credential); + + // Persist credential so it survives app restarts + store.dispatch(setSkillOAuthCredential({ skillId, credential })); // Mark setup as complete and activate store.dispatch(setSkillSetupComplete({ skillId, complete: true })); await this.activateSkill(skillId); @@ -298,6 +312,7 @@ class SkillManager { async disconnectSkill(skillId: string): Promise { await this.stopSkill(skillId); store.dispatch(setSkillSetupComplete({ skillId, complete: false })); + store.dispatch(setSkillOAuthCredential({ skillId, credential: undefined })); store.dispatch(setSkillState({ skillId, state: {} })); } diff --git a/src/lib/skills/types.ts b/src/lib/skills/types.ts index 5ae8f418b..16f6ab73d 100644 --- a/src/lib/skills/types.ts +++ b/src/lib/skills/types.ts @@ -182,11 +182,19 @@ export interface SkillHostConnectionState { // Redux skill state shape // --------------------------------------------------------------------------- +export interface OAuthCredential { + credentialId: string; + provider: string; + grantedScopes?: string[]; +} + export interface SkillState { manifest: SkillManifest; status: SkillStatus; error?: string; setupComplete: boolean; tools: SkillToolDefinition[]; + /** Persisted OAuth credential so it survives app restarts. */ + oauthCredential?: OAuthCredential; } diff --git a/src/pages/onboarding/steps/GetStartedStep.tsx b/src/pages/onboarding/steps/GetStartedStep.tsx index 538f37571..58ca7374d 100644 --- a/src/pages/onboarding/steps/GetStartedStep.tsx +++ b/src/pages/onboarding/steps/GetStartedStep.tsx @@ -1,7 +1,5 @@ import { useState } from 'react'; -import ConnectionIndicator from '../../../components/ConnectionIndicator'; - interface GetStartedStepProps { onComplete: () => void | Promise; } @@ -32,8 +30,6 @@ const GetStartedStep = ({ onComplete }: GetStartedStepProps) => {

- - {error &&

{error}

}