diff --git a/.gitmodules b/.gitmodules index 3c583d071..0bd529473 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "skills"] path = skills - url = git@github.com:vezuresdotxyz/alphahuman-skills.git + url = https://github.com/vezuresdotxyz/alphahuman-skills.git diff --git a/README.md b/README.md index dfcdb8cc1..a5ba635d9 100644 --- a/README.md +++ b/README.md @@ -54,16 +54,16 @@ AlphaHuman is designed to be simpler to deploy, cheaper to run, and more intelli > **Early Beta** — AlphaHuman is under active development. Expect rough edges. -| Platform | Variant | Download | -| ----------- | --------------------------- | ------------------------------------------------------------------------------------------------------------- | +| Platform | Variant | Download | +| ----------- | --------------------------- | -------------------------------------------------------------------------------------------------------------- | | **macOS** | Apple Silicon (M1/M2/M3/M4) | [`.dmg` (aarch64)](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_aarch64.dmg) | | **macOS** | Intel | [`.dmg` (x64)](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_x64.dmg) | | **Windows** | x64 | [`.msi`](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_x64_en-US.msi) | | **Linux** | Debian / Ubuntu | [`.deb` (amd64)](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_amd64.deb) | | **Linux** | Fedora / RHEL | [`.rpm` (x86_64)](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_x86_64.rpm) | | **Linux** | Universal | [`.AppImage`](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_amd64.AppImage) | -| **Android** | — | Coming soon | -| **iOS** | — | Coming soon | +| **Android** | — | Coming soon | +| **iOS** | — | Coming soon | Browse all releases: [github.com/alphahumanai/alphahuman/releases](https://github.com/alphahumanai/alphahuman/releases) diff --git a/SECURITY.md b/SECURITY.md index 56044d378..81195e3c1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,11 +4,11 @@ We provide security updates for the following versions of AlphaHuman: -| Version | Supported | -| ------- | ------------------ | -| Latest | :white_check_mark: | -| Previous minor | :white_check_mark: | -| Older | :x: | +| Version | Supported | +| -------------- | ------------------ | +| Latest | :white_check_mark: | +| Previous minor | :white_check_mark: | +| Older | :x: | We recommend always running the [latest release](https://github.com/alphahumanxyz/alphahuman/releases/latest). AlphaHuman is in early beta; older versions may not receive patches. diff --git a/docs/src/03-services.md b/docs/src/03-services.md index 711209aa8..0ec728b38 100644 --- a/docs/src/03-services.md +++ b/docs/src/03-services.md @@ -170,6 +170,10 @@ const socket = io(BACKEND_URL, { }); ``` +### Socket event contract (Tauri) + +In Tauri mode, the Rust socket forwards server events to the frontend via the `server:event` Tauri event. The frontend listens and can emit back via `emitViaRustSocket` (see `utils/tauriSocket.ts`). Encryption key is handled via the API, not the socket. + ## MTProto Service (`services/mtprotoService.ts`) Telegram MTProto client singleton. diff --git a/package.json b/package.json index dd7460265..2053c7362 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,12 @@ "prepare": "husky" }, "dependencies": { + "@noble/hashes": "^2.0.1", + "@noble/secp256k1": "^2.0.0", "@heroicons/react": "^2.2.0", "@reduxjs/toolkit": "^2.11.2", + "@scure/bip32": "^1.5.1", + "@scure/bip39": "^2.0.1", "@sentry/react": "^10.38.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-deep-link": "^2", diff --git a/skills b/skills index 66ad4d7e3..66ec30016 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit 66ad4d7e3fd00122f24532e474d8b664b62bbd30 +Subproject commit 66ec30016fe3811a5d945a24f3b6e06e165069d4 diff --git a/src-tauri/src/runtime/ping_scheduler.rs b/src-tauri/src/runtime/ping_scheduler.rs index 9eaf4ecd8..4ef822427 100644 --- a/src-tauri/src/runtime/ping_scheduler.rs +++ b/src-tauri/src/runtime/ping_scheduler.rs @@ -296,7 +296,7 @@ impl PingScheduler { "skillId": skill_id, "state": state_map, }); - let _ = handle.emit("skill-state-changed", &payload); + let _ = handle.emit_to("main", "skill-state-changed", payload); } } } diff --git a/src-tauri/src/runtime/qjs_engine.rs b/src-tauri/src/runtime/qjs_engine.rs index b52e76d14..0f9cc6d68 100644 --- a/src-tauri/src/runtime/qjs_engine.rs +++ b/src-tauri/src/runtime/qjs_engine.rs @@ -15,7 +15,7 @@ use crate::runtime::ping_scheduler::PingScheduler; use crate::runtime::preferences::PreferencesStore; use crate::runtime::skill_registry::SkillRegistry; use crate::runtime::socket_manager::SocketManager; -use crate::runtime::types::{events, SkillSnapshot, SkillStatus, ToolResult}; +use crate::runtime::types::{events, SkillMessage, SkillSnapshot, SkillStatus, ToolResult}; use crate::runtime::qjs_skill_instance::{BridgeDeps, QjsSkillInstance}; use crate::services::quickjs_libs::storage::IdbStorage; @@ -489,7 +489,27 @@ impl RuntimeEngine { use crate::runtime::types::SkillMessage; match method { - "skill/load" => Ok(serde_json::json!({ "ok": true })), + "skill/load" => { + // Extract load params (exclude manifest and dataDir) and send to skill + let load_params: serde_json::Map = params + .as_object() + .map(|obj| { + obj.iter() + .filter(|(k, _)| k.as_str() != "manifest" && k.as_str() != "dataDir") + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + }) + .unwrap_or_default(); + if !load_params.is_empty() { + let msg = SkillMessage::LoadParams { + params: serde_json::Value::Object(load_params), + }; + if let Err(e) = self.registry.send_message(skill_id, msg) { + log::warn!("[runtime] Failed to send LoadParams to skill '{}': {}", skill_id, e); + } + } + Ok(serde_json::json!({ "ok": true })) + } "setup/start" => { log::info!("[runtime] setup/start for '{}'", skill_id); diff --git a/src-tauri/src/runtime/qjs_skill_instance.rs b/src-tauri/src/runtime/qjs_skill_instance.rs index c65f3e0b0..5a00717f2 100644 --- a/src-tauri/src/runtime/qjs_skill_instance.rs +++ b/src-tauri/src/runtime/qjs_skill_instance.rs @@ -387,16 +387,16 @@ async fn run_event_loop( .collect(); state.write().published_state = new_map.clone(); - // Emit Tauri event so the frontend picks up the change + // Emit Tauri event to the main window so the frontend receives it (Tauri 2: target explicitly) if let Some(handle) = app_handle { use tauri::Emitter; - let _ = handle.emit( - "skill-state-changed", - serde_json::json!({ - "skillId": skill_id, - "state": new_map, - }), - ); + let payload = serde_json::json!({ + "skillId": skill_id, + "state": new_map, + }); + if let Err(e) = handle.emit_to("main", "skill-state-changed", payload) { + log::warn!("[skill:{}] Failed to emit skill-state-changed to main: {}", skill_id, e); + } } } } @@ -533,6 +533,12 @@ async fn handle_message( let result = handle_js_void_call(rt, ctx, "onTick", "{}").await; let _ = reply.send(result); } + SkillMessage::LoadParams { params } => { + let params_str = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string()); + if let Err(e) = handle_js_void_call(rt, ctx, "onLoad", ¶ms_str).await { + log::warn!("[skill:{}] onLoad failed (skill may not export it): {}", skill_id, e); + } + } SkillMessage::Error { error_type, message, source, recoverable } => { let args = serde_json::json!({ "type": error_type, diff --git a/src-tauri/src/runtime/types.rs b/src-tauri/src/runtime/types.rs index 77bf94edf..df5e38340 100644 --- a/src-tauri/src/runtime/types.rs +++ b/src-tauri/src/runtime/types.rs @@ -100,6 +100,11 @@ pub enum SkillMessage { params: serde_json::Value, reply: tokio::sync::oneshot::Sender>, }, + /// Load params from frontend (e.g. wallet address for wallet skill). + /// Delivered after skill/load RPC; skill may export onLoad(params) to receive them. + LoadParams { + params: serde_json::Value, + }, } /// Result of executing a tool. @@ -180,6 +185,9 @@ pub struct UnifiedSkillEntry { pub description: String, /// Tools exposed by this skill. pub tools: Vec, + /// Setup configuration from manifest.json (e.g. whether setup is required). + #[serde(skip_serializing_if = "Option::is_none")] + pub setup: Option, } /// Standardized result returned by any skill execution in the unified registry. diff --git a/src-tauri/src/services/quickjs-libs/qjs_ops/types.rs b/src-tauri/src/services/quickjs-libs/qjs_ops/types.rs index 742340e2d..3113f53f1 100644 --- a/src-tauri/src/services/quickjs-libs/qjs_ops/types.rs +++ b/src-tauri/src/services/quickjs-libs/qjs_ops/types.rs @@ -136,6 +136,26 @@ pub fn check_telegram_skill(skill_id: &str) -> Result<(), String> { } } -pub fn js_err(msg: String) -> rquickjs::Error { - rquickjs::Error::new_from_js_message("skill", "RuntimeError", msg) +/// Sanitize error message for use with QuickJS/rquickjs. +/// Some messages (e.g. from SQLite "Invalid symbol 45, offset 19") can trigger +/// "Invalid symbol" when rquickjs creates the JS exception — avoid comma and hyphen. +fn sanitize_error_message(msg: &str) -> String { + msg.chars() + .map(|c| { + if c == ',' || c == '-' { + ' ' + } else if c.is_ascii() && !c.is_ascii_control() { + c + } else if c == '\n' || c == '\r' || c == '\t' { + ' ' + } else { + '?' + } + }) + .collect() +} + +pub fn js_err(msg: impl AsRef) -> rquickjs::Error { + let sanitized = sanitize_error_message(msg.as_ref()); + rquickjs::Error::new_from_js_message("skill", "RuntimeError", sanitized) } diff --git a/src-tauri/src/unified_skills/mod.rs b/src-tauri/src/unified_skills/mod.rs index 5e9380c26..87137f2f1 100644 --- a/src-tauri/src/unified_skills/mod.rs +++ b/src-tauri/src/unified_skills/mod.rs @@ -91,6 +91,7 @@ impl UnifiedSkillRegistry { version: manifest.version.clone().unwrap_or_else(|| "0.1.0".to_string()), description: manifest.description.clone().unwrap_or_default(), tools, + setup: manifest.setup.clone(), }); } } @@ -126,6 +127,7 @@ impl UnifiedSkillRegistry { version: skill.version.clone(), description: skill.description.clone(), tools, + setup: None, }); } @@ -208,6 +210,7 @@ impl UnifiedSkillRegistry { version: "1.0.0".to_string(), description: spec.description, tools: vec![], + setup: None, }) } "openclaw" => { @@ -221,6 +224,7 @@ impl UnifiedSkillRegistry { version: "1.0.0".to_string(), description: spec.description, tools: vec![], + setup: None, }) } other => Err(format!("Unknown skill_type: '{other}'. Use 'alphahuman' or 'openclaw'.")), diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index 46a58e40b..51a5f873c 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -10,19 +10,28 @@ import Home from './pages/Home'; import Intelligence from './pages/Intelligence'; import Invites from './pages/Invites'; import Login from './pages/Login'; +import Mnemonic from './pages/Mnemonic'; import Onboarding from './pages/onboarding/Onboarding'; import Settings from './pages/Settings'; import Welcome from './pages/Welcome'; -import { selectIsOnboarded } from './store/authSelectors'; +import { selectHasEncryptionKey, selectIsOnboarded } from './store/authSelectors'; import { useAppSelector } from './store/hooks'; import { isTauri } from './utils/tauriCommands'; const OnboardingRoute = () => { const isOnboarded = useAppSelector(selectIsOnboarded); + const hasEncryptionKey = useAppSelector(selectHasEncryptionKey); + if (isOnboarded && !hasEncryptionKey) return ; if (isOnboarded) return ; return ; }; +const MnemonicRoute = () => { + const hasEncryptionKey = useAppSelector(selectHasEncryptionKey); + if (hasEncryptionKey) return ; + return ; +}; + /** * Home route wrapper: shows Home by default. * Only redirects to onboarding when user profile is loaded and onboarding is not done. @@ -30,6 +39,7 @@ const OnboardingRoute = () => { const HomeRoute = () => { const user = useAppSelector(state => state.user.user); const isOnboarded = useAppSelector(selectIsOnboarded); + const hasEncryptionKey = useAppSelector(selectHasEncryptionKey); // While user profile is still loading, show Home (avoid flash to onboarding) if (!user) return ; @@ -37,6 +47,9 @@ const HomeRoute = () => { // User loaded but onboarding not done → redirect to onboarding if (!isOnboarded) return ; + // Onboarded but no encryption key → redirect to mnemonic page + if (!hasEncryptionKey) return ; + return ; }; @@ -80,6 +93,14 @@ const AppRoutes = () => { } /> + + + + } + /> ): SkillListEntry { - const setup = e.setup as Record | undefined; + const setup = e.setup as { required?: boolean; oauth?: unknown } | undefined; + // Treat both interactive setup steps and OAuth-only flows as "has setup" + // so that clicking a skill (e.g. Gmail) opens the connection/setup wizard + // instead of jumping straight to the management panel. + const hasSetup = + !!setup && + (setup.required === true || + // OAuth config means we still need a connection step in the wizard + !!setup.oauth); + return { id: e.id as string, name: - (e.name as string) || - (e.id as string).charAt(0).toUpperCase() + (e.id as string).slice(1), + (e.name as string) || (e.id as string).charAt(0).toUpperCase() + (e.id as string).slice(1), description: (e.description as string) || '', icon: SKILL_ICONS[e.id as string], ignoreInProduction: (e.ignoreInProduction as boolean) ?? false, - hasSetup: !!(setup && setup.required), + hasSetup, skill_type: (e.skill_type as 'alphahuman' | 'openclaw') ?? 'alphahuman', }; } @@ -47,9 +55,7 @@ function SkillTypeBadge({ type }: { type?: string }) { return ( {type} @@ -149,14 +155,19 @@ export default function SkillsGrid() { const processed: SkillListEntry[] = manifests .filter(m => !(m.id as string).includes('_')) .map(m => { - const setup = m.setup as Record | undefined; + const setup = m.setup as { required?: boolean; oauth?: unknown } | undefined; + const hasSetup = + !!setup && + (setup.required === true || + // OAuth-only skills still need a setup/connect flow + !!setup.oauth); return { id: m.id as string, name: (m.name as string) || (m.id as string), description: (m.description as string) || '', icon: SKILL_ICONS[m.id as string], ignoreInProduction: (m.ignoreInProduction as boolean) ?? false, - hasSetup: !!(setup && setup.required), + hasSetup, skill_type: 'alphahuman' as const, }; }) @@ -183,7 +194,6 @@ export default function SkillsGrid() { }; detectMobile(); refreshSkills(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Sort skills by connection status (connected first) @@ -270,11 +280,7 @@ export default function SkillsGrid() { }} className="text-xs text-primary-400 hover:text-primary-300 transition-colors flex items-center gap-1"> {/* Sparkle / robot icon */} - + Generating… ) : ( <> - + setSelfEvolveOpen(false)} - onSkillCreated={refreshSkills} - /> + setSelfEvolveOpen(false)} onSkillCreated={refreshSkills} /> )} {/* Skills Management Modal */} diff --git a/src/components/WalletInfoSection.tsx b/src/components/WalletInfoSection.tsx new file mode 100644 index 000000000..273ad2830 --- /dev/null +++ b/src/components/WalletInfoSection.tsx @@ -0,0 +1,237 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useUser } from '../hooks/useUser'; +import { useSkillConnectionStatus } from '../lib/skills/hooks'; +import { skillManager } from '../lib/skills/manager'; +import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue'; +import { useAppSelector } from '../store/hooks'; + +function truncateAddress(address: string): string { + if (!address || address.length < 12) return address; + return `${address.slice(0, 6)}…${address.slice(-4)}`; +} + +export default function WalletInfoSection() { + const walletStatus = useSkillConnectionStatus('wallet'); + const { user } = useUser(); + const primaryAddress = useAppSelector(state => + user?._id ? state.auth.primaryWalletAddressByUser[user._id] : undefined + ); + + const [networkName, setNetworkName] = useState(null); + const [balance, setBalance] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const isConnected = walletStatus === 'connected' && !!primaryAddress; + const cancelledRef = useRef(false); + const retryTimerRef = useRef | null>(null); + const fetchWalletInfoRef = useRef<(address: string, attempt?: number) => Promise>(null!); + + const fetchWalletInfo = useCallback(async (address: string, attempt = 0): Promise => { + if (cancelledRef.current) return; + + if (!skillManager.isSkillRunning('wallet')) { + if (attempt < 5 && !cancelledRef.current) { + retryTimerRef.current = setTimeout( + () => fetchWalletInfoRef.current(address, attempt + 1), + 1500 + ); + return; + } + if (!cancelledRef.current) { + setLoading(false); + setNetworkName(null); + setBalance(null); + setError(null); + } + return; + } + + try { + // Wallet skill only supports Ethereum Mainnet (chain_id "1") + const listRes = await skillManager.callTool('wallet', 'list_networks', {}); + if (cancelledRef.current) return; + const listText = listRes.content?.[0]?.text; + if (!listText || listRes.isError) { + if (!cancelledRef.current) { + setError('Could not load networks'); + setLoading(false); + } + return; + } + const listData = JSON.parse(listText) as { + networks?: Array<{ chain_id?: string; name?: string; chain_type?: string }>; + }; + const networks = Array.isArray(listData.networks) ? listData.networks : []; + // Prefer Ethereum Mainnet (skill always has it); else first EVM, else first network + const ethMainnet = networks.find(n => n?.chain_id === '1' && n?.chain_type === 'evm'); + const firstEvm = networks.find(n => n && n.chain_type === 'evm'); + const chosen = ethMainnet ?? firstEvm ?? networks.find(Boolean); + if (!chosen || cancelledRef.current) { + if (!cancelledRef.current) setLoading(false); + return; + } + + const networkNameVal = chosen.name ?? chosen.chain_id ?? 'Unknown'; + if (!cancelledRef.current) setNetworkName(networkNameVal); + + const chainId = chosen.chain_id ?? ''; + if (!chainId) { + if (!cancelledRef.current) { + setBalance('—'); + setLoading(false); + } + return; + } + + const balanceRes = await skillManager.callTool('wallet', 'get_balance', { + address, + chain_id: chainId, + chain_type: chosen.chain_type ?? 'evm', + }); + if (cancelledRef.current) return; + const balanceText = balanceRes.content?.[0]?.text; + if (!balanceText || balanceRes.isError) { + if (!cancelledRef.current) { + setBalance('—'); + setLoading(false); + } + return; + } + const balanceData = JSON.parse(balanceText) as { + balance_eth?: string; + symbol?: string; + error?: string; + }; + if (balanceData.error) { + if (!cancelledRef.current) { + setBalance('—'); + setLoading(false); + } + return; + } + const eth = balanceData.balance_eth ?? '0'; + const symbol = balanceData.symbol ?? 'ETH'; + const parsed = parseFloat(eth); + const value = Number.isFinite(parsed) ? parsed : 0; + const display = value < 0.0001 ? '0' : value.toFixed(4); + if (!cancelledRef.current) { + setBalance(`${display} ${symbol}`); + setLoading(false); + } + } catch (e) { + if (!cancelledRef.current) { + const msg = e instanceof Error ? e.message : String(e); + const isTransient = + msg.includes('not running') || msg.includes('not started') || msg.includes('transport'); + if (isTransient && attempt < 3) { + retryTimerRef.current = setTimeout( + () => fetchWalletInfoRef.current(address, attempt + 1), + 2000 + ); + return; + } + console.error('[WalletInfoSection] Failed to load wallet info:', e); + enqueueError({ + id: crypto.randomUUID(), + timestamp: Date.now(), + source: 'manual', + title: 'Failed to load wallet info', + message: e instanceof Error ? e.message : String(e), + sentryEvent: buildManualSentryEvent( + { type: 'WalletInfoLoadError', value: e instanceof Error ? e.message : String(e) }, + { component: 'WalletInfoSection' } + ), + originalError: e instanceof Error ? e : new Error(String(e)), + }); + setError('Failed to load wallet info'); + setBalance(null); + setNetworkName(null); + setLoading(false); + } + } + }, []); + + fetchWalletInfoRef.current = fetchWalletInfo; + + useEffect(() => { + cancelledRef.current = false; + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } + + if (!isConnected || !primaryAddress) { + setLoading(false); + setNetworkName(null); + setBalance(null); + setError(null); + return; + } + + setLoading(true); + setError(null); + + fetchWalletInfo(primaryAddress); + + return () => { + cancelledRef.current = true; + if (retryTimerRef.current) clearTimeout(retryTimerRef.current); + }; + }, [isConnected, primaryAddress, fetchWalletInfo]); + + if (!isConnected) return null; + + return ( +
+
+ + + + Web3 Wallet +
+
+
+ Address + + {primaryAddress ? truncateAddress(primaryAddress) : '—'} + +
+
+ Network + + {loading ? ( + Loading… + ) : error ? ( + {error} + ) : ( + (networkName ?? '—') + )} + +
+
+ Balance + + {loading ? ( + Loading… + ) : error ? ( + '—' + ) : ( + (balance ?? '—') + )} + +
+
+
+ ); +} diff --git a/src/components/settings/panels/AgentChatPanel.tsx b/src/components/settings/panels/AgentChatPanel.tsx index 5dbc2f035..25f4c9b01 100644 --- a/src/components/settings/panels/AgentChatPanel.tsx +++ b/src/components/settings/panels/AgentChatPanel.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react'; +import { alphahumanAgentChat } from '../../../utils/tauriCommands'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; -import { alphahumanAgentChat } from '../../../utils/tauriCommands'; type ChatMessage = { role: 'user' | 'agent'; text: string }; @@ -46,12 +46,7 @@ const AgentChatPanel = () => { }, []); useEffect(() => { - const payload = { - messages, - providerOverride, - modelOverride, - temperature, - }; + const payload = { messages, providerOverride, modelOverride, temperature }; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); } catch { @@ -65,7 +60,7 @@ const AgentChatPanel = () => { setError(''); setSending(true); setInput(''); - setMessages((prev) => [...prev, { role: 'user', text }]); + setMessages(prev => [...prev, { role: 'user', text }]); try { const response = await alphahumanAgentChat( text, @@ -73,7 +68,7 @@ const AgentChatPanel = () => { modelOverride.trim() ? modelOverride : undefined, Number.isFinite(Number(temperature)) ? Number(temperature) : undefined ); - setMessages((prev) => [...prev, { role: 'agent', text: response.result }]); + setMessages(prev => [...prev, { role: 'agent', text: response.result }]); } catch (err) { const message = err instanceof Error ? err.message : String(err); setError(message); @@ -96,7 +91,7 @@ const AgentChatPanel = () => { className="input input-bordered w-full text-slate-900 bg-white" placeholder="openai" value={providerOverride} - onChange={(event) => setProviderOverride(event.target.value)} + onChange={event => setProviderOverride(event.target.value)} /> @@ -139,8 +134,7 @@ const AgentChatPanel = () => {
+ }`}> {message.text}
@@ -151,7 +145,7 @@ const AgentChatPanel = () => { className="textarea textarea-bordered w-full min-h-[140px] text-slate-900 bg-white" placeholder="Ask the agent anything..." value={input} - onChange={(event) => setInput(event.target.value)} + onChange={event => setInput(event.target.value)} /> + + {/* Import existing link */} + + + {/* Warning */} +
+ + + +

+ Never share your recovery phrase with anyone. Anyone with these words can access + your encrypted data. AlphaHuman will never ask for your recovery phrase. +

+
+ + {/* Confirmation Checkbox */} + + + ) : ( + <> +
+

Import Recovery Phrase

+

+ Enter your existing 24-word recovery phrase below. You can also paste the full + phrase into the first field. +

+
+ + {/* Import Word Inputs Grid */} +
+
+ {importWords.map((word, index) => ( +
+ + {index + 1}. + + { + inputRefs.current[index] = el; + }} + type="text" + value={word} + onChange={e => handleImportWordChange(index, e.target.value)} + onKeyDown={e => handleImportKeyDown(index, e)} + autoComplete="off" + spellCheck={false} + className={`w-full font-mono text-sm font-medium px-2 py-1.5 rounded-lg border bg-white/60 outline-none transition-colors ${ + importValid === false && word.trim() + ? 'border-coral-300 focus:border-coral-400' + : importValid === true + ? 'border-sage-300 focus:border-sage-400' + : 'border-stone-200 focus:border-primary-400' + }`} + /> +
+ ))} +
+
+ + {/* Validation status */} + {importValid === true && ( +
+ + + + Valid recovery phrase +
+ )} + + {/* Back to generate link */} + + + )} + + {error &&

{error}

} + + {/* Continue Button */} + + + + + ); +}; + +export default Mnemonic; diff --git a/src/pages/onboarding/Onboarding.tsx b/src/pages/onboarding/Onboarding.tsx index 26399b758..7d53f9060 100644 --- a/src/pages/onboarding/Onboarding.tsx +++ b/src/pages/onboarding/Onboarding.tsx @@ -48,7 +48,7 @@ const Onboarding = () => { if (user?._id) { dispatch(setOnboardedForUser({ userId: user._id, value: true })); } - navigate('/home'); + navigate('/mnemonic'); }; const renderStep = () => { diff --git a/src/providers/SkillProvider.tsx b/src/providers/SkillProvider.tsx index 602e7d9dd..66a8d9be1 100644 --- a/src/providers/SkillProvider.tsx +++ b/src/providers/SkillProvider.tsx @@ -8,9 +8,16 @@ import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { type ReactNode, useEffect, useRef } from 'react'; +import { + type GmailProfileLike, + syncGmailMetadataToBackend, +} from '../lib/gmail/services/metadataSync'; +import { syncNotionMetadataToBackend } from '../lib/notion/services/metadataSync'; import { skillManager } from '../lib/skills/manager'; import type { SkillManifest } from '../lib/skills/types'; import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue'; +import { type GmailProfile, setGmailProfile } from '../store/gmailSlice'; +import { setNotionProfile, type NotionUserProfile } from '../store/notionSlice'; import { useAppDispatch, useAppSelector } from '../store/hooks'; import { setSkillError, setSkillState, setSkillStatus } from '../store/skillsSlice'; import { DEV_AUTO_LOAD_SKILL, IS_DEV } from '../utils/config'; @@ -53,19 +60,102 @@ async function discoverSkills(): Promise { // Provider // --------------------------------------------------------------------------- +/** Normalize event payload: Rust emits { skillId, state }; ensure we get both. */ +function parseSkillStatePayload( + payload: unknown +): { skillId: string; state: Record } | null { + if (payload == null || typeof payload !== 'object') return null; + const raw = payload as Record; + const skillId = raw.skillId as string | undefined; + const state = (raw.state ?? raw) as Record | undefined; + if (!skillId || state == null || typeof state !== 'object') return null; + return { skillId, state }; +} + +/** Sync profile and emails from gmail skill state into gmailSlice and send to backend via socket. */ +function syncGmailStateToSlice( + gmailState: Record | undefined, + dispatch: ReturnType +): void { + if (!gmailState || typeof gmailState !== 'object') return; + dispatch( + setGmailProfile( + gmailState.profile !== undefined && gmailState.profile != null + ? (gmailState.profile as GmailProfile) + : null + ) + ); + syncGmailMetadataToBackend(gmailState.profile as GmailProfileLike); +} + +async function syncNotionUserOnConnect( + dispatch: ReturnType +): Promise { + try { + const toolResult = await skillManager.callTool('notion', 'get-user', { user_id: 'me' }); + if (!toolResult || toolResult.isError || toolResult.content.length === 0) { + return; + } + const first = toolResult.content[0]; + const raw = first?.text; + if (!raw) return; + + const parsed = JSON.parse(raw) as NotionUserProfile | { error?: string }; + if ('error' in parsed && parsed.error) { + return; + } + + const profile = parsed as NotionUserProfile; + dispatch(setNotionProfile(profile)); + syncNotionMetadataToBackend(profile); + } catch (e) { + console.error('[SkillProvider] Failed to call Notion get-user tool after connect:', e); + } +} + export default function SkillProvider({ children }: { children: ReactNode }) { const { token } = useAppSelector(state => state.auth); const skillsState = useAppSelector(state => state.skills.skills); + const skillStates = useAppSelector(state => state.skills.skillStates); const dispatch = useAppDispatch(); const initRef = useRef(false); + const lastNotionConnectionStatusRef = useRef(undefined); + + // Keep gmailSlice in sync with skills.skillStates.gmail (event handler + rehydration) + const gmailSkillState = skillStates?.gmail as Record | undefined; + useEffect(() => { + if (!gmailSkillState) return; + syncGmailStateToSlice(gmailSkillState, dispatch); + }, [gmailSkillState, dispatch]); + + // When Notion connection_status transitions to "connected", fetch the current user + // via the notion get-user tool, store it in notionSlice, and sync metadata to backend. + const notionSkillState = skillStates?.notion as Record | undefined; + useEffect(() => { + if (!notionSkillState || typeof notionSkillState !== 'object') return; + const connectionStatus = notionSkillState.connection_status as string | undefined; + const prev = lastNotionConnectionStatusRef.current; + lastNotionConnectionStatusRef.current = connectionStatus; + + if (connectionStatus === 'connected' && prev !== 'connected') { + void syncNotionUserOnConnect(dispatch); + } + }, [notionSkillState, dispatch]); // Listen for skill state changes emitted from the Rust runtime event loop useEffect(() => { let unlisten: (() => void) | undefined; listen<{ skillId: string; state: Record }>('skill-state-changed', event => { - const { skillId, state: newState } = event.payload; + const parsed = parseSkillStatePayload(event.payload); + if (!parsed) return; + const { skillId, state: newState } = parsed; dispatch(setSkillState({ skillId, state: newState })); + + // Transfer Gmail skill state to gmail store (also synced by effect from skillStates.gmail) + if (skillId === 'gmail') { + syncGmailStateToSlice(newState, dispatch); + } }) .then(fn => { unlisten = fn; @@ -79,6 +169,25 @@ export default function SkillProvider({ children }: { children: ReactNode }) { }; }, [dispatch]); + // Fallback: when gmail skill is ready, fetch state from backend (covers events missed before listener attached) + const gmailStatus = skillsState?.gmail?.status; + useEffect(() => { + if (gmailStatus !== 'ready') return; + const timeoutId = window.setTimeout(() => { + invoke<{ state?: Record } | null>('runtime_get_skill_state', { + skillId: 'gmail', + }) + .then(snapshot => { + if (snapshot?.state && typeof snapshot.state === 'object') { + dispatch(setSkillState({ skillId: 'gmail', state: snapshot.state })); + syncGmailStateToSlice(snapshot.state, dispatch); + } + }) + .catch(() => {}); + }, 800); + return () => window.clearTimeout(timeoutId); + }, [gmailStatus, dispatch]); + // Listen for skill runtime errors and surface them in the error notification useEffect(() => { let unlisten: (() => void) | undefined; @@ -134,17 +243,11 @@ export default function SkillProvider({ children }: { children: ReactNode }) { if (DEV_AUTO_LOAD_SKILL) { const autoLoadManifest = manifests.find(m => m.id === DEV_AUTO_LOAD_SKILL); if (autoLoadManifest) { - console.log(`[SkillProvider] Auto-loading skill from env: ${DEV_AUTO_LOAD_SKILL}`); try { await skillManager.startSkill(autoLoadManifest); - console.log(`[SkillProvider] Successfully auto-loaded skill: ${DEV_AUTO_LOAD_SKILL}`); } catch (err) { console.error(`[SkillProvider] Failed to auto-load skill ${DEV_AUTO_LOAD_SKILL}:`, err); } - } else { - console.warn( - `[SkillProvider] DEV_AUTO_LOAD_SKILL="${DEV_AUTO_LOAD_SKILL}" specified but skill not found in discovered skills` - ); } } diff --git a/src/services/api/authApi.ts b/src/services/api/authApi.ts index d84879ea7..822affc3a 100644 --- a/src/services/api/authApi.ts +++ b/src/services/api/authApi.ts @@ -5,6 +5,11 @@ interface ConsumeLoginTokenResponse { data: { jwtToken: string }; } +interface IntegrationTokensResponse { + success: boolean; + data?: { encrypted: string }; +} + /** * Consume a verified login token and return the JWT. * Works for both Telegram and OAuth login tokens. @@ -22,3 +27,17 @@ export async function consumeLoginToken(loginToken: string): Promise { } return response.data.jwtToken; } + +/** + * Fetch encrypted OAuth tokens for an integration using a client-provided key. + * POST /auth/integrations/:integrationId/tokens (auth required) + */ +export async function fetchIntegrationTokens( + integrationId: string, + key: string +): Promise { + return apiClient.post( + `/auth/integrations/${encodeURIComponent(integrationId)}/tokens`, + { key } + ); +} diff --git a/src/services/api/userApi.ts b/src/services/api/userApi.ts index 419b182d0..56285a635 100644 --- a/src/services/api/userApi.ts +++ b/src/services/api/userApi.ts @@ -22,9 +22,6 @@ export const userApi = { * POST /settings/onboarding-complete */ onboardingComplete: async (): Promise => { - await apiClient.post<{ success: boolean; data: unknown }>( - '/settings/onboarding-complete', - {} - ); + await apiClient.post<{ success: boolean; data: unknown }>('/settings/onboarding-complete', {}); }, }; diff --git a/src/store/authSelectors.ts b/src/store/authSelectors.ts index cfa13e7f1..4986f46c2 100644 --- a/src/store/authSelectors.ts +++ b/src/store/authSelectors.ts @@ -5,3 +5,9 @@ export const selectIsOnboarded = (state: RootState): boolean => { if (!userId) return false; return state.auth.isOnboardedByUser[userId] ?? false; }; + +export const selectHasEncryptionKey = (state: RootState): boolean => { + const userId = state.user.user?._id; + if (!userId) return false; + return !!state.auth.encryptionKeyByUser[userId]; +}; diff --git a/src/store/authSlice.ts b/src/store/authSlice.ts index 02b60af31..f90e50d33 100644 --- a/src/store/authSlice.ts +++ b/src/store/authSlice.ts @@ -9,12 +9,18 @@ export interface AuthState { isOnboardedByUser: Record; /** Analytics consent per user id (opt-in during onboarding) */ isAnalyticsEnabledByUser: Record; + /** AES encryption key (hex) derived from mnemonic, per user id */ + encryptionKeyByUser: Record; + /** Primary EVM wallet address (0x...) derived from mnemonic, per user id */ + primaryWalletAddressByUser: Record; } const initialState: AuthState = { token: null, isOnboardedByUser: {}, isAnalyticsEnabledByUser: {}, + encryptionKeyByUser: {}, + primaryWalletAddressByUser: {}, }; const authSlice = createSlice({ @@ -26,6 +32,8 @@ const authSlice = createSlice({ }, _clearToken: state => { state.token = null; + state.encryptionKeyByUser = {}; + state.primaryWalletAddressByUser = {}; }, setOnboardedForUser: (state, action: PayloadAction<{ userId: string; value: boolean }>) => { const { userId, value } = action.payload; @@ -35,6 +43,17 @@ const authSlice = createSlice({ const { userId, enabled } = action.payload; state.isAnalyticsEnabledByUser[userId] = enabled; }, + setEncryptionKeyForUser: (state, action: PayloadAction<{ userId: string; key: string }>) => { + const { userId, key } = action.payload; + state.encryptionKeyByUser[userId] = key; + }, + setPrimaryWalletAddressForUser: ( + state, + action: PayloadAction<{ userId: string; address: string }> + ) => { + const { userId, address } = action.payload; + state.primaryWalletAddressByUser[userId] = address; + }, }, }); @@ -45,5 +64,11 @@ export const clearToken = createAsyncThunk('auth/clearToken', async (_, { dispat dispatch(clearTeamState()); }); -export const { setToken, setOnboardedForUser, setAnalyticsForUser } = authSlice.actions; +export const { + setToken, + setOnboardedForUser, + setAnalyticsForUser, + setEncryptionKeyForUser, + setPrimaryWalletAddressForUser, +} = authSlice.actions; export default authSlice.reducer; diff --git a/src/store/gmailSlice.ts b/src/store/gmailSlice.ts new file mode 100644 index 000000000..2622132f2 --- /dev/null +++ b/src/store/gmailSlice.ts @@ -0,0 +1,49 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; + +export interface GmailEmailSummary { + id: string; + threadId: string; + snippet?: string; + subject?: string; + from?: string; + date?: string; +} + +export interface GmailProfile { + email_address: string; + messages_total: number; + threads_total: number; + history_id: string; +} + +interface GmailState { + /** Emails fetched after OAuth connection (from Gmail skill) */ + emails: GmailEmailSummary[]; + /** Profile of the connected Gmail user (from Gmail skill) */ + profile: GmailProfile | null; +} + +const initialState: GmailState = { emails: [], profile: null }; + +const gmailSlice = createSlice({ + name: 'gmail', + initialState, + reducers: { + setGmailEmails(state, action: PayloadAction) { + state.emails = action.payload; + }, + clearGmailEmails(state) { + state.emails = []; + }, + setGmailProfile(state, action: PayloadAction) { + state.profile = action.payload; + }, + clearGmailProfile(state) { + state.profile = null; + }, + }, +}); + +export const { setGmailEmails, clearGmailEmails, setGmailProfile, clearGmailProfile } = + gmailSlice.actions; +export default gmailSlice.reducer; diff --git a/src/store/index.ts b/src/store/index.ts index c80a1128a..3d642173a 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -18,7 +18,9 @@ import { storeSession } from '../utils/tauriCommands'; import aiReducer from './aiSlice'; import authReducer, { setOnboardedForUser, setToken } from './authSlice'; import daemonReducer from './daemonSlice'; +import gmailReducer from './gmailSlice'; import inviteReducer from './inviteSlice'; +import notionReducer from './notionSlice'; import skillsReducer from './skillsSlice'; import socketReducer from './socketSlice'; import teamReducer from './teamSlice'; @@ -29,7 +31,13 @@ import userReducer from './userSlice'; const authPersistConfig = { key: 'auth', storage, - whitelist: ['token', 'isOnboardedByUser', 'isAnalyticsEnabledByUser'], + whitelist: [ + 'token', + 'isOnboardedByUser', + 'isAnalyticsEnabledByUser', + 'encryptionKeyByUser', + 'primaryWalletAddressByUser', + ], }; // Persist config for AI state (config only) @@ -42,7 +50,7 @@ const skillsPersistConfig = { key: 'skills', storage, whitelist: ['skills'] }; const threadPersistConfig = { key: 'thread', storage, - whitelist: ['panelWidth', 'lastViewedAt', 'threads', 'messagesByThreadId', 'selectedThreadId'] + whitelist: ['panelWidth', 'lastViewedAt', 'threads', 'messagesByThreadId', 'selectedThreadId'], }; const persistedAuthReducer = persistReducer(authPersistConfig, authReducer); @@ -89,9 +97,11 @@ export const store = configureStore({ daemon: daemonReducer, ai: persistedAiReducer, skills: persistedSkillsReducer, + gmail: gmailReducer, team: teamReducer, thread: persistedThreadReducer, invite: inviteReducer, + notion: notionReducer, }, middleware: getDefaultMiddleware => { const middleware = getDefaultMiddleware({ diff --git a/src/store/notionSlice.ts b/src/store/notionSlice.ts new file mode 100644 index 000000000..d824c6d7c --- /dev/null +++ b/src/store/notionSlice.ts @@ -0,0 +1,33 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; + +export interface NotionUserProfile { + id: string; + name?: string | null; + email?: string | null; + type?: string | null; + avatar_url?: string | null; +} + +interface NotionState { + /** Profile of the connected Notion user (from Notion skill) */ + profile: NotionUserProfile | null; +} + +const initialState: NotionState = { profile: null }; + +const notionSlice = createSlice({ + name: 'notion', + initialState, + reducers: { + setNotionProfile(state, action: PayloadAction) { + state.profile = action.payload; + }, + clearNotionProfile(state) { + state.profile = null; + }, + }, +}); + +export const { setNotionProfile, clearNotionProfile } = notionSlice.actions; +export default notionSlice.reducer; + diff --git a/src/store/skillsSlice.ts b/src/store/skillsSlice.ts index fcf3b2c7a..d93e871e2 100644 --- a/src/store/skillsSlice.ts +++ b/src/store/skillsSlice.ts @@ -79,7 +79,14 @@ const skillsSlice = createSlice({ state, action: PayloadAction<{ skillId: string; state: Record }> ) { - state.skillStates[action.payload.skillId] = action.payload.state; + const { skillId, state: incomingState } = action.payload; + const existing = state.skillStates[skillId] as Record | undefined; + // Preserve frontend-only keys (e.g. oauthTokens from deep link) that the skill never sends + const preserved = + existing && typeof existing.oauthTokens === 'object' && existing.oauthTokens !== null + ? { oauthTokens: existing.oauthTokens } + : {}; + state.skillStates[skillId] = { ...incomingState, ...preserved }; }, removeSkill(state, action: PayloadAction) { diff --git a/src/store/threadSlice.ts b/src/store/threadSlice.ts index 8d002f45c..a5bf15b0a 100644 --- a/src/store/threadSlice.ts +++ b/src/store/threadSlice.ts @@ -17,8 +17,8 @@ interface ThreadState { messages: ThreadMessage[]; // Keep these API states (NOT persisted) - isLoadingMessages: boolean; // For AI response waiting - messagesError: string | null; // For send API errors + isLoadingMessages: boolean; // For AI response waiting + messagesError: string | null; // For send API errors sendStatus: 'idle' | 'loading' | 'success' | 'error'; sendError: string | null; deleteStatus: 'idle' | 'loading' | 'success' | 'error'; @@ -194,9 +194,7 @@ const threadSlice = createSlice({ // CRITICAL FIX: Ensure the preceding user message is also persisted // Find the last user message that might not be in persistent storage yet - const lastUserMessage = state.messages - .filter(m => m.sender === 'user') - .pop(); + const lastUserMessage = state.messages.filter(m => m.sender === 'user').pop(); if (lastUserMessage) { const persistedMessages = state.messagesByThreadId[state.selectedThreadId]; @@ -233,7 +231,10 @@ const threadSlice = createSlice({ state.lastViewedAt[action.payload] = ts; }, // Local thread management - createThreadLocal: (state, action: { payload: { id: string; title: string; createdAt: string } }) => { + createThreadLocal: ( + state, + action: { payload: { id: string; title: string; createdAt: string } } + ) => { const newThread: Thread = { id: action.payload.id, title: action.payload.title, @@ -269,7 +270,10 @@ const threadSlice = createSlice({ state.selectedThreadId = null; } }, - updateMessagesForThread: (state, action: { payload: { threadId: string; messages: ThreadMessage[] } }) => { + updateMessagesForThread: ( + state, + action: { payload: { threadId: string; messages: ThreadMessage[] } } + ) => { const { threadId, messages } = action.payload; state.messagesByThreadId[threadId] = messages; @@ -277,10 +281,11 @@ const threadSlice = createSlice({ const thread = state.threads.find(t => t.id === threadId); if (thread) { thread.messageCount = messages.length; - thread.lastMessageAt = messages.length > 0 ? messages[messages.length - 1].createdAt : thread.createdAt; + thread.lastMessageAt = + messages.length > 0 ? messages[messages.length - 1].createdAt : thread.createdAt; } }, - clearAllThreads: (state) => { + clearAllThreads: state => { state.threads = []; state.messagesByThreadId = {}; state.selectedThreadId = null; diff --git a/src/utils/cryptoKeys.ts b/src/utils/cryptoKeys.ts new file mode 100644 index 000000000..2869932bd --- /dev/null +++ b/src/utils/cryptoKeys.ts @@ -0,0 +1,70 @@ +import { pbkdf2 } from '@noble/hashes/pbkdf2.js'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { keccak_256 } from '@noble/hashes/sha3.js'; +import { bytesToHex } from '@noble/hashes/utils.js'; +import { getPublicKey } from '@noble/secp256k1'; +import { HDKey } from '@scure/bip32'; +import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english.js'; + +/** + * Generate a 24-word BIP39 mnemonic phrase (256-bit entropy). + */ +export function generateMnemonicPhrase(): string { + return generateMnemonic(wordlist, 256); +} + +/** + * Validate a BIP39 mnemonic phrase. + */ +export function validateMnemonicPhrase(mnemonic: string): boolean { + return validateMnemonic(mnemonic, wordlist); +} + +/** + * Derive a 256-bit AES encryption key from a mnemonic phrase. + * Uses BIP39 seed derivation followed by PBKDF2-SHA256. + * Returns the key as a hex string. + */ +export function deriveAesKeyFromMnemonic(mnemonic: string): string { + // Get the BIP39 seed (512-bit) from the mnemonic + const seed = mnemonicToSeedSync(mnemonic); + + // Derive a 256-bit AES key using PBKDF2 with the seed + const salt = new TextEncoder().encode('alphahuman-aes-key-v1'); + const derivedKey = pbkdf2(sha256, seed, salt, { c: 100000, dkLen: 32 }); + + return bytesToHex(derivedKey); +} + +/** BIP44 path for first Ethereum account: m/44'/60'/0'/0/0 */ +const EVM_DERIVATION_PATH = "m/44'/60'/0'/0/0"; + +/** + * Derive the first EVM wallet address (Ethereum BIP44) from a mnemonic phrase. + * Uses path m/44'/60'/0'/0/0. Returns a checksummed 0x-prefixed address. + */ +export function deriveEvmAddressFromMnemonic(mnemonic: string): string { + const seed = mnemonicToSeedSync(mnemonic); + const hdkey = HDKey.fromMasterSeed(seed); + const derived = hdkey.derive(EVM_DERIVATION_PATH); + const privateKey = derived.privateKey; + if (!privateKey) throw new Error('Failed to derive private key'); + // Ethereum address = keccak256(uncompressed public key without 0x04)[12:] + const pubKey = getPublicKey(privateKey, false); // uncompressed, 65 bytes + const hash = keccak_256(pubKey.slice(1)); + const addressBytes = hash.slice(-20); + const hex = bytesToHex(addressBytes); + return toChecksumAddress('0x' + hex); +} + +/** Simple checksum: lowercase with 0x, then capitalize by hash. */ +function toChecksumAddress(address: string): string { + const a = address.replace(/^0x/i, '').toLowerCase(); + const hash = bytesToHex(keccak_256(new TextEncoder().encode(a))); + let result = '0x'; + for (let i = 0; i < 40; i++) { + result += parseInt(hash[i], 16) >= 8 ? a[i].toUpperCase() : a[i]; + } + return result; +} diff --git a/src/utils/desktopDeepLinkListener.ts b/src/utils/desktopDeepLinkListener.ts index 7e21306d9..4a0c55406 100644 --- a/src/utils/desktopDeepLinkListener.ts +++ b/src/utils/desktopDeepLinkListener.ts @@ -1,10 +1,39 @@ import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core'; import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link'; -import { skillManager } from '../lib/skills/manager'; -import { consumeLoginToken } from '../services/api/authApi'; +import { consumeLoginToken, fetchIntegrationTokens } from '../services/api/authApi'; +import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue'; import { store } from '../store'; import { setToken } from '../store/authSlice'; +import { setSkillState } from '../store/skillsSlice'; +import { skillManager } from '../lib/skills/manager'; +import { + decryptIntegrationTokens, + hexToBase64, + type IntegrationTokensPayload, +} from './integrationTokensCrypto'; + +function getCurrentUserId(): string | null { + const state = store.getState(); + const explicitId = state.user.user?._id; + if (explicitId) return explicitId; + + const token = state.auth.token; + if (!token) return null; + + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + const payloadBase64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padLen = (4 - (payloadBase64.length % 4)) % 4; + const padded = padLen ? payloadBase64 + '='.repeat(padLen) : payloadBase64; + const payloadJson = atob(padded); + const payload = JSON.parse(payloadJson); + return payload.tgUserId || payload.userId || payload.sub || null; + } catch { + return null; + } +} /** * Handle an `alphahuman://auth?token=...` deep link for login. @@ -54,9 +83,7 @@ const handlePaymentDeepLink = async (parsed: URL) => { console.log('[DeepLink] Payment success, session_id:', sessionId); // Broadcast to the app so billing components can react - window.dispatchEvent( - new CustomEvent('payment:success', { detail: { sessionId } }), - ); + window.dispatchEvent(new CustomEvent('payment:success', { detail: { sessionId } })); // Navigate to billing settings to show confirmation window.location.hash = '/settings/billing'; @@ -95,7 +122,118 @@ const handleOAuthDeepLink = async (parsed: URL) => { console.log(`[DeepLink] OAuth success for skill=${skillId} integration=${integrationId}`); try { - await skillManager.notifyOAuthComplete(skillId, integrationId); + const state = store.getState(); + const userId = getCurrentUserId(); + if (!userId) { + console.warn('[DeepLink] Cannot fetch integration tokens: no current user id'); + return; + } + + const encryptionKeyHex = state.auth.encryptionKeyByUser[userId]; + if (!encryptionKeyHex || typeof encryptionKeyHex !== 'string') { + console.warn( + '[DeepLink] Cannot fetch integration tokens: no encryption key found for user', + userId + ); + return; + } + + const trimmedHex = encryptionKeyHex.trim().replace(/^0x/i, ''); + if (!trimmedHex || trimmedHex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(trimmedHex)) { + const msg = + '[DeepLink] Cannot fetch integration tokens: encryption key must be non-empty hex (even length, [0-9a-fA-F])'; + console.error(msg, { userId, encryptionKeyHex }); + enqueueError({ + id: crypto.randomUUID(), + timestamp: Date.now(), + source: 'manual', + title: 'Deep link integration tokens: invalid encryption key', + message: msg, + sentryEvent: buildManualSentryEvent( + { type: 'DeepLinkIntegrationTokensInvalidKey', value: msg }, + { component: 'desktopDeepLinkListener', userId, encryptionKeyHex } + ), + }); + return; + } + + let keyForBackend: string; + try { + keyForBackend = hexToBase64(trimmedHex); + } catch (e) { + const msg = '[DeepLink] Cannot fetch integration tokens: encryption key conversion failed'; + console.error(msg, { userId, encryptionKeyHex, error: e }); + const err = e instanceof Error ? e : new Error(String(e)); + enqueueError({ + id: crypto.randomUUID(), + timestamp: Date.now(), + source: 'manual', + title: 'Deep link integration tokens: encryption key conversion failed', + message: err.message, + sentryEvent: buildManualSentryEvent( + { type: 'DeepLinkIntegrationTokensHexToBase64Error', value: err.message }, + { component: 'desktopDeepLinkListener', userId, encryptionKeyHex } + ), + originalError: err, + }); + return; + } + if (!keyForBackend) { + const msg = + '[DeepLink] Cannot fetch integration tokens: encryption key produced empty base64'; + console.error(msg, { userId, encryptionKeyHex }); + enqueueError({ + id: crypto.randomUUID(), + timestamp: Date.now(), + source: 'manual', + title: 'Deep link integration tokens: empty key for backend', + message: msg, + sentryEvent: buildManualSentryEvent( + { type: 'DeepLinkIntegrationTokensEmptyKey', value: msg }, + { component: 'desktopDeepLinkListener', userId, encryptionKeyHex } + ), + }); + return; + } + + const response = await fetchIntegrationTokens(integrationId, keyForBackend); + if (!response.success || !response.data?.encrypted) { + console.warn( + '[DeepLink] Integration tokens response missing encrypted payload for integration', + integrationId + ); + return; + } + + const existingState = state.skills.skillStates[skillId] ?? {}; + store.dispatch( + setSkillState({ + skillId, + state: { + ...existingState, + oauthTokens: { + ...(existingState.oauthTokens as Record | undefined), + [integrationId]: { encrypted: response.data.encrypted }, + }, + }, + }) + ); + + // For OAuth-capable skills (e.g. Gmail, Notion), pass decrypted access token so the + // skill can use it directly when supported instead of always going through the proxy. + let extraCredential: { accessToken?: string } | undefined; + + try { + const decryptedJson = await decryptIntegrationTokens(response.data.encrypted, trimmedHex); + const payload = JSON.parse(decryptedJson) as IntegrationTokensPayload; + if (payload.accessToken) { + extraCredential = { accessToken: payload.accessToken }; + } + } catch (e) { + console.warn('[DeepLink] Could not decrypt integration token for skill:', e); + } + + await skillManager.notifyOAuthComplete(skillId, integrationId, undefined, extraCredential); } catch (err) { console.error('[DeepLink] Failed to notify OAuth complete:', err); } @@ -173,9 +311,8 @@ export const setupDesktopDeepLinkListener = async () => { if (typeof window !== 'undefined') { // window.__simulateDeepLink('alphahuman://auth?token=1234567890') // window.__simulateDeepLink('alphahuman://oauth/success?integrationId=6989ef9c8e8bf1b6d991a08c&skillId=notion') - ( - window as Window & { __simulateDeepLink?: (url: string) => Promise } - ).__simulateDeepLink = (url: string) => handleDeepLinkUrls([url]); + const win = window as Window & { __simulateDeepLink?: (url: string) => Promise }; + win.__simulateDeepLink = (url: string) => handleDeepLinkUrls([url]); } } catch (err) { console.error('[DeepLink] Setup failed:', err); diff --git a/src/utils/integrationTokensCrypto.ts b/src/utils/integrationTokensCrypto.ts new file mode 100644 index 000000000..3c62dfee2 --- /dev/null +++ b/src/utils/integrationTokensCrypto.ts @@ -0,0 +1,171 @@ +/** + * Helpers for encrypting/decrypting OAuth integration tokens. + * Matches backend format: IV (16 bytes) + AuthTag (16 bytes) + EncryptedData. + * IV is derived from SHA-256(message) for encryption (deterministic). + */ + +export type IntegrationTokensPayload = { + accessToken: string; + refreshToken: string; + /** ISO timestamp string */ + expiresAt: string; +}; + +/** Stored value: encrypted blob only; decrypt with key when needed */ +export type StoredIntegrationTokens = { encrypted: string }; + +const HEX_REGEX = /^[0-9a-fA-F]*$/; + +export function hexToBytes(hex: string): Uint8Array { + const cleanHex = hex.trim().replace(/^0x/i, ''); + if (!cleanHex) return new Uint8Array(); + if (cleanHex.length % 2 !== 0) { + throw new TypeError(`hexToBytes: hex string must have even length (got ${cleanHex.length})`); + } + if (!HEX_REGEX.test(cleanHex)) { + throw new TypeError('hexToBytes: hex string must contain only [0-9a-fA-F] characters'); + } + const bytes = new Uint8Array(cleanHex.length / 2); + for (let i = 0; i < cleanHex.length; i += 2) { + bytes[i / 2] = parseInt(cleanHex.slice(i, i + 2), 16); + } + return bytes; +} + +export function hexToBase64(hex: string): string { + const bytes = hexToBytes(hex); + if (bytes.length === 0) return ''; + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +export function base64ToBytes(b64: string): Uint8Array { + let normalized = b64.replace(/-/g, '+').replace(/_/g, '/'); + const pad = normalized.length % 4; + if (pad === 2) normalized += '=='; + else if (pad === 3) normalized += '='; + + const binary = atob(normalized); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +/** + * Decrypt an encrypted tokens payload (base64) using a 32-byte key (hex). + * Backend format: IV (16) + AuthTag (16) + EncryptedData. + */ +export async function decryptIntegrationTokens( + encryptedPayload: string, + keyHex: string +): Promise { + if (typeof crypto === 'undefined' || !crypto.subtle) { + throw new Error('Web Crypto API is not available for decryption'); + } + + const keyBytes = hexToBytes(keyHex); + if (keyBytes.length !== 32) { + throw new Error('Invalid encryption key: expected 32-byte AES-GCM key'); + } + + const combined = base64ToBytes(encryptedPayload); + if (combined.length <= 32) { + throw new Error('Encrypted payload too short'); + } + + const iv = combined.slice(0, 16); + const authTag = combined.slice(16, 32); + const encryptedData = combined.slice(32); + const ciphertextWithTag = new Uint8Array(encryptedData.length + authTag.length); + ciphertextWithTag.set(encryptedData, 0); + ciphertextWithTag.set(authTag, encryptedData.length); + + const cryptoKey = await crypto.subtle.importKey( + 'raw', + keyBytes as unknown as BufferSource, + { name: 'AES-GCM' }, + false, + ['decrypt'] + ); + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv, tagLength: 128 }, + cryptoKey, + ciphertextWithTag as unknown as BufferSource + ); + + return new TextDecoder().decode(decrypted); +} + +/** + * Encrypt a plaintext string (e.g. JSON) using a 32-byte key (hex). + * Matches backend: deterministic IV = first 16 bytes of SHA-256(message). + * Returns base64(IV + AuthTag + EncryptedData). + */ +export async function encryptIntegrationTokens(plaintext: string, keyHex: string): Promise { + if (typeof crypto === 'undefined' || !crypto.subtle) { + throw new Error('Web Crypto API is not available for encryption'); + } + + const keyBytes = hexToBytes(keyHex); + if (keyBytes.length !== 32) { + throw new Error('Invalid encryption key: expected 32-byte AES-GCM key'); + } + + try { + const payload = JSON.parse(plaintext) as Record; + if (typeof payload.expiresAt !== 'string' || !payload.expiresAt.trim()) { + throw new Error( + 'Payload must include a non-empty expiresAt field when using deterministic IV' + ); + } + } catch (e) { + if (e instanceof SyntaxError) { + throw new Error( + 'Plaintext must be JSON with a non-empty expiresAt field when using deterministic IV' + ); + } + throw e; + } + + const messageBytes = new TextEncoder().encode(plaintext); + + // Deterministic IV for backend compatibility. TODO: Prefer a random 12-byte IV + // (crypto.getRandomValues) prepended to ciphertext; update decrypt to handle both formats. + const hashBuffer = await crypto.subtle.digest('SHA-256', messageBytes); + const iv = new Uint8Array(hashBuffer).slice(0, 16); + + const cryptoKey = await crypto.subtle.importKey( + 'raw', + keyBytes as unknown as BufferSource, + { name: 'AES-GCM' }, + false, + ['encrypt'] + ); + + const encryptedBuffer = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv, tagLength: 128 }, + cryptoKey, + messageBytes + ); + + const encryptedArray = new Uint8Array(encryptedBuffer); + const authTag = encryptedArray.slice(-16); + const encryptedData = encryptedArray.slice(0, -16); + + const combined = new Uint8Array(iv.length + authTag.length + encryptedData.length); + combined.set(iv, 0); + combined.set(authTag, iv.length); + combined.set(encryptedData, iv.length + authTag.length); + + let binary = ''; + for (let i = 0; i < combined.length; i++) { + binary += String.fromCharCode(combined[i]); + } + return btoa(binary); +} diff --git a/src/utils/tauriCommands.ts b/src/utils/tauriCommands.ts index 79fcfe713..01f74e7fc 100644 --- a/src/utils/tauriCommands.ts +++ b/src/utils/tauriCommands.ts @@ -186,11 +186,7 @@ export interface SkillSnapshot { skill_id: string; name: string; status: unknown; - tools: Array<{ - name: string; - description: string; - input_schema?: unknown; - }>; + tools: Array<{ name: string; description: string; input_schema?: unknown }>; error?: string | null; state?: Record; } @@ -308,7 +304,11 @@ export interface TunnelConfig { cloudflare?: { token: string } | null; tailscale?: { funnel?: boolean; hostname?: string | null } | null; ngrok?: { auth_token: string; domain?: string | null } | null; - custom?: { start_command: string; health_url?: string | null; url_pattern?: string | null } | null; + custom?: { + start_command: string; + health_url?: string | null; + url_pattern?: string | null; + } | null; } export async function alphahumanGetConfig(): Promise> { @@ -405,9 +405,7 @@ export async function alphahumanAgentChat( }); } -export async function alphahumanEncryptSecret( - plaintext: string -): Promise> { +export async function alphahumanEncryptSecret(plaintext: string): Promise> { if (!isTauri()) { throw new Error('Not running in Tauri'); } @@ -437,10 +435,7 @@ export async function alphahumanDoctorModels( if (!isTauri()) { throw new Error('Not running in Tauri'); } - return await invoke('alphahuman_doctor_models', { - providerOverride, - useCache, - }); + return await invoke('alphahuman_doctor_models', { providerOverride, useCache }); } export async function alphahumanListIntegrations(): Promise> { @@ -476,10 +471,7 @@ export async function alphahumanMigrateOpenclaw( if (!isTauri()) { throw new Error('Not running in Tauri'); } - return await invoke('alphahuman_migrate_openclaw', { - sourceWorkspace, - dryRun, - }); + return await invoke('alphahuman_migrate_openclaw', { sourceWorkspace, dryRun }); } export async function alphahumanHardwareDiscover(): Promise> { diff --git a/src/utils/tauriSocket.ts b/src/utils/tauriSocket.ts index 717f78837..aa52f9348 100644 --- a/src/utils/tauriSocket.ts +++ b/src/utils/tauriSocket.ts @@ -27,7 +27,14 @@ export const isTauri = (): boolean => { const isTauriEnv = coreIsTauri(); const windowTauri = typeof window !== 'undefined' ? !!window.__TAURI__ : 'undefined'; const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'undefined'; - console.log('[TauriSocket] isTauri() check:', isTauriEnv, 'window.__TAURI__:', windowTauri, 'userAgent:', userAgent); + console.log( + '[TauriSocket] isTauri() check:', + isTauriEnv, + 'window.__TAURI__:', + windowTauri, + 'userAgent:', + userAgent + ); return isTauriEnv; }; @@ -174,8 +181,8 @@ export async function setupTauriSocketListeners(): Promise { // Listen for forwarded server events console.log('[TauriSocket] Setting up server:event listener'); unlistenServerEvent = await listen<{ event: string; data: unknown }>('server:event', event => { - console.log('[TauriSocket] Server event:', event.payload.event, event.payload.data); - // Future: dispatch to specific handlers based on event type + const { event: eventName, data } = event.payload; + console.log('[TauriSocket] Server event:', eventName, data); }); console.log('[TauriSocket] server:event listener setup complete'); diff --git a/yarn.lock b/yarn.lock index 78625c9b9..a753bc100 100644 --- a/yarn.lock +++ b/yarn.lock @@ -928,6 +928,28 @@ outvariant "^1.4.3" strict-event-emitter "^0.5.1" +"@noble/curves@~1.9.0": + version "1.9.7" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.9.7.tgz#79d04b4758a43e4bca2cbdc62e7771352fa6b951" + integrity sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw== + dependencies: + "@noble/hashes" "1.8.0" + +"@noble/hashes@1.8.0", "@noble/hashes@~1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" + integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== + +"@noble/hashes@2.0.1", "@noble/hashes@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.0.1.tgz#fc1a928061d1232b0a52bb754393c37a5216c89e" + integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw== + +"@noble/secp256k1@^2.0.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-2.3.0.tgz#ddfe6e853472fb88cba4d5e59b7067adc1e64adf" + integrity sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" @@ -1157,6 +1179,33 @@ resolved "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz" integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== +"@scure/base@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.0.0.tgz#ba6371fddf92c2727e88ad6ab485db6e624f9a98" + integrity sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w== + +"@scure/base@~1.2.5": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.2.6.tgz#ca917184b8231394dd8847509c67a0be522e59f6" + integrity sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg== + +"@scure/bip32@^1.5.1": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.7.0.tgz#b8683bab172369f988f1589640e53c4606984219" + integrity sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw== + dependencies: + "@noble/curves" "~1.9.0" + "@noble/hashes" "~1.8.0" + "@scure/base" "~1.2.5" + +"@scure/bip39@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-2.0.1.tgz#47a6dc15e04faf200041239d46ae3bb7c3c96add" + integrity sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg== + dependencies: + "@noble/hashes" "2.0.1" + "@scure/base" "2.0.0" + "@sec-ant/readable-stream@^0.4.1": version "0.4.1" resolved "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz"