fix(skills): persist OAuth credentials and fix skill auto-start lifecycle (#146)

* refactor(deep-link): streamline OAuth handling and skill setup process

- Removed the RPC call for persisting setup completion, now handled directly in the preferences store.
- Updated comments in the deep link handler to clarify the sequence of operations during OAuth completion.
- Enhanced the `set_setup_complete` function to automatically enable skills upon setup completion, improving user experience during skill activation.

This refactor simplifies the OAuth deep link handling and ensures skills are automatically enabled after setup, enhancing the overall flow.

* feat(skills): enhance SkillSetupModal and snapshot fetching with polling

- Added a mechanism in SkillSetupModal to sync the setup mode when the setup completion status changes, improving user experience during asynchronous loading.
- Updated the useSkillSnapshot and useAllSkillSnapshots hooks to include periodic polling every 3 seconds, ensuring timely updates from the core sidecar and enhancing responsiveness to state changes.

These changes improve the handling of skill setup and snapshot fetching, providing a more seamless user experience.

* fix(ErrorFallbackScreen): update reload button behavior to navigate to home before reloading

- Modified the onClick handler of the reload button to first set the window location hash to '#/home' before reloading the application. This change improves user experience by ensuring users are directed to the home screen upon reloading.

* refactor(intelligence-api): simplify local-only hooks and remove unused code

- Refactored the `useIntelligenceApiFallback` hooks to focus on local-only implementations, removing reliance on backend APIs and mock data.
- Streamlined the `useActionableItems`, `useUpdateActionableItem`, `useSnoozeActionableItem`, and `useChatSession` hooks to operate solely with in-memory data.
- Updated comments for clarity on the local-only nature of the hooks and their intended usage.
- Enhanced the `useIntelligenceStats` hook to derive entity counts from local graph relations instead of fetching from a backend API, improving performance and reliability.
- Removed unused imports and code related to backend interactions, resulting in cleaner and more maintainable code.

* feat(intelligence): add active tab state management for Intelligence component

- Introduced a new `IntelligenceTab` type to manage the active tab state within the Intelligence component.
- Initialized the `activeTab` state to 'memory', enhancing user experience by allowing tab-specific functionality and navigation.

This update lays the groundwork for future enhancements related to tabbed navigation in the Intelligence feature.

* feat(intelligence): implement tab navigation and enhance UI interactions

- Added a tab navigation system to the Intelligence component, allowing users to switch between 'Memory', 'Subconscious', and 'Dreams' tabs.
- Integrated conditional rendering for the 'Analyze Now' button, ensuring it is only displayed when the 'Memory' tab is active.
- Updated the UI to include a 'Coming Soon' label for the 'Subconscious' and 'Dreams' tabs, improving user awareness of upcoming features.
- Enhanced the overall layout and styling for better user experience and interaction.

* refactor(intelligence): streamline UI text and enhance OAuth credential handling

- Simplified text rendering in the Intelligence component for better readability.
- Updated the description for subconscious and dreams sections to provide clearer context on functionality.
- Refactored OAuth credential handling in the QjsSkillInstance to utilize a data directory for persistence, improving credential management and recovery.
- Enhanced logging for OAuth credential restoration and persistence, ensuring better traceability of actions.

* fix(skills): update OAuth credential handling in SkillManager

- Modified the SkillManager to use `credentialId` instead of `integrationId` for OAuth notifications, aligning with the expectations of the JS bootstrap's oauth.fetch.
- Enhanced the parameters passed during the core RPC call to include `grantedScopes` and ensure the provider defaults to "unknown" if not specified, improving the robustness of the skill activation process.

* fix(skills): derive modal mode from snapshot instead of syncing via effect

Avoids the react-hooks/set-state-in-effect lint warning by deriving
the setup/manage mode directly from the snapshot's setup_complete flag.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(ErrorFallbackScreen): format reload button onClick handler for improved readability

- Reformatted the onClick handler of the reload button to enhance code readability by adding line breaks.
- Updated import order in useIntelligenceStats for consistency.
- Improved logging format in event_loop.rs and js_helpers.rs for better traceability of OAuth credential actions.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Steven Enamakel
2026-03-31 16:37:41 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 3369454cbe
commit 58b8a0dd4d
15 changed files with 450 additions and 344 deletions
+18 -4
View File
@@ -100,8 +100,15 @@ impl PreferencesStore {
}
/// Set the setup completion flag for a skill. Persists immediately.
/// When marking setup as complete, also sets `enabled = true` so the skill
/// auto-starts on subsequent app launches.
pub fn set_setup_complete(&self, skill_id: &str, complete: bool) {
self.update(skill_id, |p| p.setup_complete = complete);
self.update(skill_id, |p| {
p.setup_complete = complete;
if complete {
p.enabled = true;
}
});
log::info!(
"[preferences] setup_complete for '{}' set to {}",
skill_id,
@@ -114,10 +121,17 @@ impl PreferencesStore {
self.cache.read().clone()
}
/// Resolve whether a skill should start, considering user preference and manifest default.
/// Resolve whether a skill should start, considering user preference,
/// setup completion, and manifest default.
///
/// A skill with `setup_complete = true` always starts — the user explicitly
/// went through setup/OAuth, so the intent is to have it running.
/// Otherwise fall back to the explicit `enabled` preference, then the manifest default.
pub fn resolve_should_start(&self, skill_id: &str, manifest_auto_start: bool) -> bool {
match self.is_enabled(skill_id) {
Some(enabled) => enabled,
let pref = self.cache.read().get(skill_id).cloned();
match pref {
Some(p) if p.setup_complete => true,
Some(p) => p.enabled,
None => manifest_auto_start,
}
}
@@ -39,6 +39,7 @@ pub(crate) async fn run_event_loop(
timer_state: &Arc<RwLock<qjs_ops::TimerState>>,
ops_state: &Arc<RwLock<qjs_ops::SkillState>>,
memory_client: Option<MemoryClientRef>,
data_dir: &std::path::Path,
) {
// Maximum sleep duration when no timers are pending
const MAX_IDLE_SLEEP: Duration = Duration::from_millis(100);
@@ -76,6 +77,7 @@ pub(crate) async fn run_event_loop(
&mut pending_tool,
&memory_client,
ops_state,
data_dir,
)
.await;
if should_stop {
@@ -212,6 +214,7 @@ async fn handle_message(
pending_tool: &mut Option<PendingToolCall>,
memory_client: &Option<MemoryClientRef>,
ops_state: &Arc<RwLock<qjs_ops::SkillState>>,
data_dir: &std::path::Path,
) -> bool {
match msg {
SkillMessage::CallTool {
@@ -226,7 +229,7 @@ async fn handle_message(
);
// Lazy-load persisted OAuth credential before calling the tool
restore_oauth_credential(ctx, skill_id).await;
restore_oauth_credential(ctx, skill_id, data_dir).await;
log::debug!(
"[skill:{}] event_loop: OAuth credential restored for tool '{}'",
skill_id,
@@ -370,7 +373,7 @@ async fn handle_message(
let result = match method.as_str() {
"oauth/complete" => {
// Set credential on the oauth bridge + persist to store
// Set credential on the oauth bridge + in-memory state
let cred_json =
serde_json::to_string(&params).unwrap_or_else(|_| "null".to_string());
let code = format!(
@@ -388,10 +391,21 @@ async fn handle_message(
let _ = js_ctx.eval::<rquickjs::Value, _>(code.as_bytes());
})
.await;
log::info!(
"[skill:{}] OAuth credential set and persisted to store",
skill_id
);
// Persist credential to disk so it survives restarts
let cred_path = data_dir.join("oauth_credential.json");
if let Err(e) = std::fs::write(&cred_path, &cred_json) {
log::error!(
"[skill:{}] Failed to persist OAuth credential: {e}",
skill_id
);
} else {
log::info!(
"[skill:{}] OAuth credential persisted to {}",
skill_id,
cred_path.display()
);
}
let params_str =
serde_json::to_string(&params).unwrap_or_else(|_| "{}".to_string());
handle_js_call(rt, ctx, "onOAuthComplete", &params_str).await
@@ -445,7 +459,14 @@ async fn handle_message(
let _ = js_ctx.eval::<rquickjs::Value, _>(clear_code.as_bytes());
})
.await;
log::info!("[skill:{}] OAuth credential cleared from store", skill_id);
// Remove persisted credential file
let cred_path = data_dir.join("oauth_credential.json");
let _ = std::fs::remove_file(&cred_path);
log::info!(
"[skill:{}] OAuth credential cleared from store and disk",
skill_id
);
// Fire-and-forget: delete memory for this integration
if let Some(client) = memory_client_opt {
@@ -191,7 +191,7 @@ impl QjsSkillInstance {
return;
}
restore_oauth_credential(&ctx, &config.skill_id).await;
restore_oauth_credential(&ctx, &config.skill_id, &data_dir).await;
// Call init() lifecycle
if let Err(e) = call_lifecycle(&rt, &ctx, "init").await {
@@ -242,6 +242,7 @@ impl QjsSkillInstance {
&timer_state,
&published_state,
_deps.memory_client.clone(),
&data_dir,
)
.await;
})
@@ -77,25 +77,46 @@ pub(crate) fn extract_tools(js_ctx: &rquickjs::Ctx<'_>, state: &Arc<RwLock<Skill
}
}
/// 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.
pub(crate) 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);
/// Load a persisted OAuth credential from the skill's data directory and inject
/// it into the JS context so tools have access to the credential.
///
/// Reads `{data_dir}/oauth_credential.json` which is written by the
/// `oauth/complete` handler and deleted by `oauth/revoked`.
pub(crate) async fn restore_oauth_credential(
ctx: &rquickjs::AsyncContext,
skill_id: &str,
data_dir: &std::path::Path,
) {
let cred_path = data_dir.join("oauth_credential.json");
let cred_json = match std::fs::read_to_string(&cred_path) {
Ok(s) if !s.is_empty() => s,
_ => return,
};
// Inject credential into both oauth bridge and in-memory state
let code = format!(
r#"(function() {{
var cred = {cred};
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);
}}
return true;
}
return false;
})()"#;
}})()"#,
cred = cred_json
);
let restored = ctx
.with(|js_ctx| js_ctx.eval::<bool, _>(code.as_bytes()).unwrap_or(false))
.await;
if restored {
log::info!("[skill:{}] Restored OAuth credential from store", skill_id);
log::info!(
"[skill:{}] Restored OAuth credential from {}",
skill_id,
cred_path.display()
);
}
}
+15
View File
@@ -867,6 +867,21 @@ globalThis.data = {
console.log('[oauth.fetch] ' + method + ' ' + proxyUrl + ' (credentialId=' + globalThis.__oauthCredential.credentialId + ')');
var result = await net.fetch(proxyUrl, fetchOpts);
console.log('[oauth.fetch] response status=' + result.status + ' body_len=' + (result.body ? result.body.length : 0));
// Auto-clear invalid/expired credentials so the user is prompted to re-auth
if (result.status === 401 || result.status === 403) {
console.warn('[oauth.fetch] Got ' + result.status + ' — clearing invalid credential for re-auth');
globalThis.__oauthCredential = null;
if (typeof globalThis.state !== 'undefined' && globalThis.state.set) {
globalThis.state.set('__oauth_credential', '');
globalThis.state.setPartial({
connection_status: 'error',
connection_error: 'Integration token expired or invalid. Please reconnect.',
auth_status: 'not_authenticated',
});
}
}
return result;
},
@@ -7,6 +7,36 @@ use std::time::{Duration, Instant};
use super::types::{TimerEntry, TimerState, ALLOWED_ENV_VARS};
/// Read the session JWT from the on-disk credentials store.
///
/// Returns `None` on any failure so the caller can fall back to env vars.
fn token_from_credentials_store() -> Option<String> {
use crate::openhuman::credentials::{AuthService, APP_SESSION_PROVIDER};
let home = directories::UserDirs::new()?.home_dir().to_path_buf();
let default_dir = home.join(".openhuman");
let state_dir = match std::env::var("OPENHUMAN_WORKSPACE") {
Ok(ws) if !ws.is_empty() => {
let ws_path = std::path::PathBuf::from(&ws);
if ws_path.join("config.toml").exists() {
ws_path
} else {
default_dir
}
}
_ => default_dir,
};
if !state_dir.exists() {
return None;
}
let auth = AuthService::new(&state_dir, true);
let profile = auth.get_profile(APP_SESSION_PROVIDER, None).ok()??;
profile.token.filter(|t| !t.trim().is_empty())
}
pub fn register<'js>(
ctx: &Ctx<'js>,
ops: &Object<'js>,
@@ -115,6 +145,11 @@ pub fn register<'js>(
ops.set(
"get_session_token",
Function::new(ctx.clone(), || -> String {
// Try the on-disk credentials store first (where login actually persists
// the JWT), then fall back to the legacy JWT_TOKEN env var.
if let Some(token) = token_from_credentials_store() {
return token;
}
std::env::var("JWT_TOKEN").unwrap_or_default()
}),
)?;