feat(welcome): split onboarding tool + detect gmail/webview logins (#894)

This commit is contained in:
Steven Enamakel
2026-04-24 17:01:41 -07:00
committed by GitHub
parent 215a776eea
commit 080deae117
17 changed files with 1150 additions and 684 deletions
+20
View File
@@ -716,6 +716,26 @@ pub fn run() {
core_run_mode,
);
std::env::set_var("OPENHUMAN_CORE_RPC_URL", core_handle.rpc_url());
// Expose the shared CEF cookies SQLite path to the core sidecar
// so `check_onboarding_status` can detect which webview
// providers (gmail, whatsapp, slack, …) already have a live
// session cookie. Best-effort — if we can't resolve the path
// the core treats every provider as logged_out.
if let Ok(cache_dir) = app.path().app_cache_dir() {
let cookies_db = cache_dir.join("cef").join("Default").join("Cookies");
log::debug!("[webview_accounts] exposing cookies DB path to core");
std::env::set_var("OPENHUMAN_CEF_COOKIES_DB", &cookies_db);
} else {
// Clear any inherited value so the core can't pick up a
// stale path from a previous run or the parent shell.
std::env::remove_var("OPENHUMAN_CEF_COOKIES_DB");
log::warn!(
"[webview_accounts] could not resolve app_cache_dir — core \
will report all webview providers as logged_out"
);
}
app.manage(core_handle.clone());
let app_handle_for_update = app.handle().clone();
tauri::async_runtime::spawn(async move {
+5 -1
View File
@@ -371,7 +371,11 @@ mod tests {
assert_eq!(def.sandbox_mode, SandboxMode::ReadOnly);
match &def.tools {
ToolScope::Named(tools) => {
assert_eq!(tools.len(), 3, "welcome should have exactly three tools");
assert_eq!(tools.len(), 4, "welcome should have exactly four tools");
assert!(
tools.iter().any(|t| t == "check_onboarding_status"),
"welcome needs check_onboarding_status"
);
assert!(
tools.iter().any(|t| t == "complete_onboarding"),
"welcome needs complete_onboarding"
+10 -2
View File
@@ -25,6 +25,14 @@ omit_memory_md = false
hint = "agentic"
[tools]
# complete_onboarding: check setup status and mark onboarding done.
# check_onboarding_status: read-only snapshot of setup state (auth, channels,
# Composio toolkits, webview logins, exchange count, ready-to-complete flag).
# complete_onboarding: finalize the welcome flow once ready_to_complete is true.
# memory_recall: pull additional user details beyond injected context.
named = ["complete_onboarding", "memory_recall", "composio_authorize"]
# composio_authorize: start an OAuth flow for a toolkit (e.g. gmail).
named = [
"check_onboarding_status",
"complete_onboarding",
"memory_recall",
"composio_authorize",
]
+26 -15
View File
@@ -11,12 +11,12 @@ You are the **Welcome** agent — the first agent a new user talks to in OpenHum
## Tools you must use correctly
You have `complete_onboarding`, `memory_recall`, and `composio_authorize`. The important ones are `complete_onboarding` actions:
You have `check_onboarding_status`, `complete_onboarding`, `memory_recall`, and `composio_authorize`.
| Action | What it does |
|--------|----------------|
| `check_status` | **Read-only.** Returns JSON: setup, `exchange_count`, `ready_to_complete`, `ready_to_complete_reason`, `onboarding_status`. **Does not** finish onboarding. |
| `complete` | Finalizes onboarding (flips flags, seeds jobs). **Only** when the latest snapshot has `ready_to_complete: true`. If you call it too early, you get an error — keep chatting. |
| Tool | What it does |
|------|--------------|
| `check_onboarding_status` | **Read-only.** Takes no arguments. Returns JSON: setup, `composio_connected_toolkits`, `webview_logins`, `exchange_count`, `ready_to_complete`, `ready_to_complete_reason`, `onboarding_status`. **Does not** finish onboarding. |
| `complete_onboarding` | Finalizes onboarding (flips flags, seeds jobs). Takes no arguments. **Only** call when the latest snapshot has `ready_to_complete: true`. If you call it too early, you get an error — keep chatting. |
`ready_to_complete` is `true` when **either**:
@@ -31,6 +31,15 @@ When `ready_to_complete` is `false`, read `ready_to_complete_reason` and adapt:
- `already_complete` -> treat as returning user.
- `fewer_than_min_exchanges_and_no_skills_connected` -> keep engaging and keep trying to help them connect at least one skill.
## Read the snapshot before you pitch
Before offering anything, scan two fields in the `check_onboarding_status` JSON:
- **`composio_connected_toolkits`** — list of OAuth-authorised toolkits. If `"gmail"` is in there, **do not** offer to connect Gmail again — reference it ("since your Gmail's already connected, I can…") and move to the next useful step.
- **`webview_logins`** — bools per provider (`gmail`, `whatsapp`, `telegram`, `slack`, `discord`, `linkedin`, `zoom`, `google_messages`). These are the literal JSON keys — match them exactly. A `true` means the embedded browser already has a live session for that provider; don't ask them to log in again, treat it as available context.
Combine both: if `composio_connected_toolkits` has `"gmail"` **or** `webview_logins.gmail` is `true`, skip the Gmail pitch.
## No silent first turn (reactive chat — user sent a message)
The runtime **can** show your **words and** a tool call in the **same** iteration. Use that.
@@ -38,11 +47,11 @@ The runtime **can** show your **words and** a tool call in the **same** iteratio
**On your first iteration of each reply** (while onboarding is still in progress):
1. Write **at least one short sentence** of visible greeting or reply — never a tool-only message.
2. In that **same** iteration, call `complete_onboarding({"action":"check_status"})` so you get the JSON snapshot with fresh `exchange_count` and `ready_to_complete`.
2. In that **same** iteration, call `check_onboarding_status` (no arguments) so you get the JSON snapshot with fresh `exchange_count`, `ready_to_complete`, `composio_connected_toolkits`, and `webview_logins`.
Use the snapshot plus the `[CONNECTION_STATE]` block (when present) on the user message so you know what is connected **before** you authorise links.
If `onboarding_status` is `"unauthenticated"`, do **not** call `complete`. Briefly tell them to log in via the desktop app and stop pitching integrations.
If `onboarding_status` is `"unauthenticated"`, do **not** call `complete_onboarding`. Briefly tell them to log in via the desktop app and stop pitching integrations.
If `onboarding_status` is `"already_complete"`, treat them as a returning user: short friendly welcome, no need to run the full Gmail pitch unless they ask.
@@ -52,34 +61,36 @@ If `onboarding_status` is `"pending"`, continue the conversational flow below.
Aim for this shape over **several** user/assistant turns — not one wall of text:
1. **First substantive reply** — Concise greeting + whats connected / not (from snapshot + `[CONNECTION_STATE]`) + one sentence on what OpenHuman is for (reasoning, memory, channels, integrations).
2. **Gmail first** If the user's message already expresses intent to connect Gmail (e.g. "connect my Gmail", "give me the link", "I'd like to connect"), call `composio_authorize` with `{"toolkit": "gmail"}` **immediately** in that same response — no separate offer needed. Otherwise, offer first and wait for agreement. Either way, put the returned URL in a markdown link: `[Connect Gmail](url)` and explicitly tell them it opens in their default browser.
1. **First substantive reply** — Concise greeting + whats connected / not (from snapshot `composio_connected_toolkits` + `webview_logins` + `[CONNECTION_STATE]`) + one sentence on what OpenHuman is for (reasoning, memory, channels, integrations).
2. **Gmail first** (only if not already connected — see snapshot). If the user's message already expresses intent to connect Gmail (e.g. "connect my Gmail", "give me the link", "I'd like to connect"), call `composio_authorize` with `{"toolkit": "gmail"}` **immediately** in that same response — no separate offer needed. Otherwise, offer first and wait for agreement. Either way, put the returned URL in a markdown link: `[Connect Gmail](url)` and explicitly tell them it opens in their default browser.
3. **If they hesitate** — Once or twice, lightly explain why inbox access matters (triaging mail, drafts, etc.). **Do not** paste three auth links in a row or nag every line.
4. **Try 23 times across the conversation** (not three demands in one message) to connect something. If they refuse everything, **wrap up kindly**: how to connect later in Settings, and that youre here when theyre ready.
5. **Show capability** — Weave examples into chat (e.g. “you could ask it to summarise yesterdays mail”) instead of a bullet list brochure.
6. **Subscription / referral** — One short honest paragraph when it fits (credits, referral), not a pitch deck.
7. **Only call `complete_onboarding({"action":"complete"})`** when the **most recent** `check_status` JSON shows `ready_to_complete: true`. If you get an error, read it and keep the conversation going until criteria are met.
8. **Decline path:** if the user explicitly says "skip", "later", "not now", or equivalent after you've genuinely offered skill connection options across the conversation, acknowledge it, explain where to connect later (Settings), then complete when `ready_to_complete` is true.
7. **Only call `complete_onboarding`** when the **most recent** `check_onboarding_status` JSON shows `ready_to_complete: true`. If you get an error, read it and keep the conversation going until criteria are met.
8. **Decline path:** if the user explicitly says "skip", "later", "not now", or equivalent after you've genuinely offered skill connection options across the conversation, acknowledge it, explain where to connect later (Settings), then call `complete_onboarding` when `ready_to_complete` is true.
## `composio_authorize` rules
- Call when the user agrees to connect, or when their message already expresses clear intent to connect (e.g. "connect my Gmail", "give me the link"). No separate confirmation step needed in that case.
- **Never** call it for a toolkit that's already in `composio_connected_toolkits`. Reference the existing connection instead.
- One toolkit at a time; Gmail is the default first offer.
- Never invent URLs — only use `connectUrl` from the tool response, as a markdown link.
- When sharing the link, clearly state it opens in the user's browser and they should return to chat after finishing auth.
- After OAuth, use `[CONNECTION_STATE]` on the next user message to confirm `connected: true` before celebrating.
- After OAuth, use `[CONNECTION_STATE]` on the next user message (or a fresh `check_onboarding_status`) to confirm `connected: true` before celebrating.
## Proactive invocation (wizard just closed — templates already in chat)
When the system marks this as **proactive** (templates like a time-of-day line and “Getting everything ready…” may already appear):
- **Do not** open with another “Good morning” / “Hey” — the template already greeted.
- Follow the **injected system instructions** for that run (they may tell you to skip `check_status` because a snapshot is embedded). Do **not** call `complete` until the user has actually conversed and `ready_to_complete` is true on a real `check_status` when youre back in reactive mode.
- Follow the **injected system instructions** for that run (they may tell you to skip `check_onboarding_status` because a snapshot is embedded). Do **not** call `complete_onboarding` until the user has actually conversed and `ready_to_complete` is true on a real `check_onboarding_status` when youre back in reactive mode.
## What NOT to do
- **No tool-only first response** in reactive chat — always pair `check_status` with visible prose.
- **No** calling `complete` until `ready_to_complete` is true.
- **No tool-only first response** in reactive chat — always pair `check_onboarding_status` with visible prose.
- **No** calling `complete_onboarding` until `ready_to_complete` is true.
- **No** re-pitching integrations that are already in `composio_connected_toolkits` or logged in per `webview_logins`.
- **No** corporate speak, stacked buzzwords, or fake excitement.
- **No** claiming you can read email or use tools they havent connected.
- **No** exposing routing (“handoff”, “orchestrator”, “different agent”). One assistant.
+17 -15
View File
@@ -42,7 +42,7 @@
use crate::core::event_bus::{publish_global, DomainEvent};
use crate::openhuman::agent::Agent;
use crate::openhuman::config::Config;
use crate::openhuman::tools::implementations::agent::complete_onboarding::build_status_snapshot;
use crate::openhuman::tools::implementations::agent::onboarding_status::build_status_snapshot;
/// Event-bus `source` label attached to the proactive welcome message.
/// Kept as a constant so tests and channel-side filters have a stable
@@ -161,19 +161,21 @@ async fn run_proactive_welcome(config: Config) -> anyhow::Result<()> {
);
// `chat_onboarding_completed` is still `false` at this point —
// `set_onboarding_completed` no longer pre-flips it (see
// `config/ops.rs`). The flag is only flipped when the welcome
// agent calls `complete_onboarding(action="complete")` after
// meeting the engagement criteria. We pass `"pending"` as the
// status, `0` as the initial exchange count, and `false` for
// `ready_to_complete` because this proactive invocation is the
// opening greeting — no exchanges have happened yet.
// `set_onboarding_completed` no longer pre-flips it. The flag
// only flips when the welcome agent calls `complete_onboarding`
// after meeting the engagement criteria. Proactive welcome is
// the opening greeting — no exchanges yet, no snapshot-driven
// toolkit/webview personalisation. Pass empty lists so the
// snapshot shape matches `check_onboarding_status` without
// paying for the Composio/cookie lookup here.
let snapshot = build_status_snapshot(
&config,
"pending",
0,
false,
"fewer_than_min_exchanges_and_no_skills_connected",
&[],
serde_json::Value::Object(Default::default()),
);
let snapshot_json = serde_json::to_string_pretty(&snapshot)
.map_err(|e| anyhow::anyhow!("serialize status snapshot: {e}"))?;
@@ -201,10 +203,10 @@ async fn run_proactive_welcome(config: Config) -> anyhow::Result<()> {
);
// The reactive welcome prompt asks for visible prose plus
// `complete_onboarding(check_status)` on the first iteration. Here we
// `check_onboarding_status` on the first iteration. Here we
// pre-inject the snapshot so the model can write the long welcome
// without a tool round-trip. If it calls `check_status` anyway, the
// result is consistent: pending, not ready to complete.
// without a tool round-trip. If it calls `check_onboarding_status`
// anyway, the result is consistent: pending, not ready to complete.
//
// We also tell the agent that two greeting template messages have
// already been shown so it does not open with a redundant "Good
@@ -219,13 +221,13 @@ async fn run_proactive_welcome(config: Config) -> anyhow::Result<()> {
2. \"Getting everything ready for you...\" (shown ~4 seconds later).\n\
Do NOT open with any greeting (\"Good morning\", \"Hey there\", \"Hi!\", etc.). \
Jump straight into the personalised welcome content about their specific setup.]\n\n\
Do NOT call `complete_onboarding` or any other tool in this run. The \
status snapshot that `complete_onboarding({{\"action\":\"check_status\"}})` \
Do NOT call `check_onboarding_status`, `complete_onboarding`, or any \
other tool in this run. The snapshot that `check_onboarding_status` \
would have returned is already provided below — `onboarding_status` is \
`\"pending\"` and `ready_to_complete` is `false` because no user messages \
have been exchanged yet. Write the personalised welcome message per your \
system prompt. Do NOT call `complete_onboarding` with `complete` — the user \
has not yet had a real conversation.\n\n\
system prompt. Do NOT call `complete_onboarding` — the user has not yet \
had a real conversation.\n\n\
Output format is STRICT JSON only:\n\
{{\"messages\":[\"<message 1>\",\"<message 2>\",\"<message 3>\"]}}\n\
- Return 2-4 assistant messages.\n\
+1 -1
View File
@@ -845,7 +845,7 @@ fn build_session_agent(
// type in the chat pane. If we routed on that flag the welcome
// agent could never run from the Tauri desktop app. The chat
// flag is set only by the welcome agent itself via
// `complete_onboarding(action="complete")`, so it stays `false`
// `complete_onboarding`, so it stays `false`
// for the user's actual first chat message regardless of what
// the React layer did, then flips on the welcome turn so the
// very next message routes to orchestrator.
+5 -5
View File
@@ -310,7 +310,7 @@ impl AgentScoping {
/// so the LLM cannot accidentally send messages or write files
/// while guiding the user through setup. The welcome agent decides
/// when the user is ready and calls
/// `complete_onboarding(action="complete")`, which flips the flag.
/// `complete_onboarding`, which flips the flag.
///
/// * **`true`** → route to the `orchestrator` agent. Orchestrator
/// delegates real work to specialist subagents via a `subagents`
@@ -356,10 +356,10 @@ async fn resolve_target_agent(channel: &str) -> AgentScoping {
"orchestrator"
} else {
// Increment the process-global exchange counter every time a user
// message is routed to the welcome agent. The `complete_onboarding`
// tool reads this counter to decide whether `ready_to_complete` is
// `true` and to enforce the minimum-engagement guard in `complete`.
crate::openhuman::tools::implementations::agent::complete_onboarding::increment_welcome_exchange_count();
// message is routed to the welcome agent. The `check_onboarding_status`
// tool reads this counter to surface `ready_to_complete`, and
// `complete_onboarding` enforces it as the minimum-engagement guard.
crate::openhuman::tools::implementations::agent::onboarding_status::increment_welcome_exchange_count();
"welcome"
};
+1 -1
View File
@@ -574,7 +574,7 @@ pub async fn get_onboarding_completed() -> Result<RpcOutcome<bool>, String> {
/// **`chat_onboarding_completed` is NOT flipped here.** That flag is
/// the exclusive responsibility of the welcome agent: it is set to
/// `true` only after the user has had a meaningful onboarding
/// conversation (via `complete_onboarding(action="complete")`). See
/// conversation (via `complete_onboarding`). See
/// [`crate::openhuman::tools::impl::agent::complete_onboarding`] for
/// the guard criteria.
///
+1 -1
View File
@@ -171,7 +171,7 @@ pub struct Config {
/// `channels::runtime::dispatch::resolve_target_agent`). The
/// welcome agent inspects the user's setup, delivers a
/// personalized greeting, and (when the essentials are in
/// place) calls `complete_onboarding(action="complete")` which
/// place) calls `complete_onboarding` which
/// flips this flag to `true`.
/// * **`true`** — the welcome agent has already run; future chat
/// turns route to the orchestrator.
+1
View File
@@ -64,6 +64,7 @@ pub mod update;
pub mod util;
pub mod voice;
pub mod webhooks;
pub mod webview_accounts;
pub mod webview_apis;
pub mod webview_notifications;
pub mod workspace;
@@ -0,0 +1,193 @@
//! Tool: `check_onboarding_status` — read-only snapshot of the user's
//! workspace setup state for the welcome agent.
//!
//! Pairs with [`super::complete_onboarding`] — that tool finalizes the
//! flow, this one reports what's already in place so the agent can
//! craft a personalized welcome and decide when to finalize.
//!
//! No side effects. No flag flips. Takes no arguments.
use crate::openhuman::config::Config;
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult, ToolScope};
use async_trait::async_trait;
use serde_json::{json, Value};
use super::onboarding_status::{build_status_snapshot, compute_state};
pub struct CheckOnboardingStatusTool;
impl Default for CheckOnboardingStatusTool {
fn default() -> Self {
Self::new()
}
}
impl CheckOnboardingStatusTool {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl Tool for CheckOnboardingStatusTool {
fn name(&self) -> &str {
"check_onboarding_status"
}
fn description(&self) -> &str {
"Read-only JSON snapshot of the user's workspace setup state. \
No side effects, no flag flips. Takes no arguments. Call this \
ONCE on your first iteration to craft a personalised welcome \
and decide when to call `complete_onboarding`.\n\
\n\
The returned JSON has this shape:\n\
```\n\
{\n\
\"authenticated\": true, // bool — JWT present\n\
\"auth_source\": \"session_token\", // \"session_token\" | null\n\
\"default_model\": \"reasoning-v1\", // string\n\
\"channels_connected\": [\"telegram\"], // string[] — connected messaging platforms\n\
\"active_channel\": \"web\", // preferred channel for proactive messages\n\
\"integrations\": { // bool flags for each capability\n\
\"composio\": true,\n\
\"browser\": false,\n\
\"web_search\": true,\n\
\"http_request\": true,\n\
\"local_ai\": true\n\
},\n\
\"composio_connected_toolkits\": [\"gmail\"], // Composio toolkits the user has authorised\n\
\"webview_logins\": { // per-provider CEF cookie presence\n\
\"gmail\": true, \"whatsapp\": false, \"telegram\": false,\n\
\"slack\": false, \"discord\": false, \"linkedin\": false,\n\
\"zoom\": false, \"google_messages\": false\n\
},\n\
\"memory\": { \"backend\": \"sqlite\", \"auto_save\": true },\n\
\"delegate_agents\": [\"researcher\", \"coder\"],\n\
\"ui_onboarding_completed\": true, // React wizard flag\n\
\"chat_onboarding_completed\": false, // still false until complete_onboarding succeeds\n\
\"exchange_count\": 1, // how many user messages handled so far\n\
\"ready_to_complete\": false, // true when criteria for complete_onboarding are met\n\
\"ready_to_complete_reason\": \"fewer_than_min_exchanges_and_no_skills_connected\",\n\
\"onboarding_status\": \"pending\" // \"pending\" | \"already_complete\" | \"unauthenticated\"\n\
}\n\
```\n\
\n\
**Two fields matter for what to offer next:**\n\
* `composio_connected_toolkits` lists OAuth-authorised skills \
(e.g. gmail). Don't re-pitch a toolkit that's already here.\n\
* `webview_logins` reports whether the embedded browser \
already has a live session cookie for each provider. A \
`true` means the user is signed in to that webview — don't \
ask them to log in again, just reference it.\n\
\n\
`ready_to_complete` is `true` when at least one of:\n\
* The user has had at least 3 back-and-forth exchanges, or\n\
* The user has connected at least one Composio integration.\n\
\n\
`onboarding_status`:\n\
* `\"pending\"` — authenticated, conversation in progress. \
Check `ready_to_complete` to know if you may call \
`complete_onboarding`.\n\
* `\"already_complete\"` — `chat_onboarding_completed` is \
already `true`. Welcome the user as a returning visitor.\n\
* `\"unauthenticated\"` — the user has no valid session. \
Explain the auth problem, point them at the desktop login \
flow, and stop."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {},
"additionalProperties": false
})
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::ReadOnly
}
fn scope(&self) -> ToolScope {
ToolScope::AgentOnly
}
async fn execute(&self, _args: Value) -> anyhow::Result<ToolResult> {
tracing::debug!("[check_onboarding_status] execute");
check_status().await
}
}
/// Reads the user's config and returns a structured JSON snapshot.
///
/// Read-only. Combines config flags, the process-global welcome
/// exchange counter, the Composio connected-toolkits list, and the
/// per-provider webview login heuristic (shared CEF cookie probe) into
/// one payload the welcome agent consumes in a single tool call.
async fn check_status() -> anyhow::Result<ToolResult> {
let config = Config::load_or_init()
.await
.map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
let state = compute_state(&config).await;
let webview_logins = crate::openhuman::webview_accounts::detect_webview_logins();
tracing::debug!(
authenticated = state.is_authenticated,
onboarding_status = state.onboarding_status,
exchange_count = state.exchange_count,
composio_connections = state.composio_connected_toolkits.len(),
ready_to_complete = state.ready_to_complete,
"[check_onboarding_status] snapshot built"
);
let snapshot = build_status_snapshot(
&config,
state.onboarding_status,
state.exchange_count,
state.ready_to_complete,
&state.ready_to_complete_reason,
&state.composio_connected_toolkits,
webview_logins,
);
let payload = serde_json::to_string_pretty(&snapshot)
.map_err(|e| anyhow::anyhow!("Failed to serialize status snapshot: {e}"))?;
Ok(ToolResult::success(payload))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_metadata() {
let tool = CheckOnboardingStatusTool::new();
assert_eq!(tool.name(), "check_onboarding_status");
assert_eq!(tool.permission_level(), PermissionLevel::ReadOnly);
assert_eq!(tool.scope(), ToolScope::AgentOnly);
let schema = tool.parameters_schema();
assert_eq!(schema["type"], "object");
assert!(schema.get("required").is_none());
}
#[test]
fn description_documents_new_fields() {
let desc = CheckOnboardingStatusTool::new().description().to_string();
assert!(
desc.contains("composio_connected_toolkits"),
"description should document the composio toolkits field"
);
assert!(
desc.contains("webview_logins"),
"description should document the webview logins field"
);
assert!(desc.contains("ready_to_complete"));
}
#[test]
fn spec_roundtrip() {
let tool = CheckOnboardingStatusTool::new();
let spec = tool.spec();
assert_eq!(spec.name, "check_onboarding_status");
}
}
@@ -1,111 +1,22 @@
//! Tool: complete_onboarding — inspects workspace setup status and, when
//! engagement criteria are met, finalizes the chat welcome flow.
//! Tool: `complete_onboarding`finalize the chat welcome flow.
//!
//! Used exclusively by the **welcome** agent.
//! Used exclusively by the **welcome** agent. This is the finalizer
//! half of the pair; the read-only inspection lives in
//! [`crate::openhuman::tools::implementations::agent::check_onboarding_status`].
//!
//! ## Normal flow
//!
//! 1. Welcome agent calls `check_status` on its first iteration. The
//! tool returns a read-only JSON snapshot**no side effects, no
//! flag flips**. The snapshot includes a `ready_to_complete` bool,
//! a `ready_to_complete_reason` string, and an `exchange_count`
//! uint so the agent knows whether it may proceed.
//! 2. The agent converses with the user until `ready_to_complete` is
//! `true` (either ≥ 3 back-and-forth exchanges, or at least one
//! Composio integration connected).
//! 3. The agent calls `complete` to finalize. The `complete` action
//! enforces the same criteria server-side and **rejects premature
//! calls** with a descriptive error so the agent knows to keep
//! conversing.
//!
//! ## Exchange count tracking
//!
//! A process-global [`AtomicU32`] (`WELCOME_EXCHANGE_COUNT`) counts
//! how many user messages have been dispatched to the welcome agent
//! this session. The dispatch layer calls
//! [`increment_welcome_exchange_count`] once per inbound message when
//! `chat_onboarding_completed` is still `false`. The counter is
//! intentionally process-local (not persisted) because the welcome
//! flow runs exactly once per fresh install; after `complete` flips
//! the flag the counter is never consulted again.
//! Flips `chat_onboarding_completed` to `true` and seeds recurring
//! proactive cron jobs. Rejects (returns a
//! [`ToolResult::error`]) if the user has not yet met the minimum
//! engagement thresholdeither at least
//! [`onboarding_status::MIN_EXCHANGES_TO_COMPLETE`] welcome-agent exchanges,
//! or at least one connected Composio integration.
use crate::openhuman::config::Config;
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult, ToolScope};
use async_trait::async_trait;
use serde_json::{json, Value};
use std::sync::atomic::{AtomicU32, Ordering};
/// Process-global exchange counter for the welcome agent.
///
/// Incremented by [`increment_welcome_exchange_count`] (called from
/// the channel dispatch layer) once per inbound user message that
/// routes to the welcome agent. Used by [`check_status`] to surface
/// `exchange_count` and `ready_to_complete` to the agent, and by
/// [`complete`] to enforce the minimum-engagement guard.
static WELCOME_EXCHANGE_COUNT: AtomicU32 = AtomicU32::new(0);
/// Minimum number of welcome-agent exchanges required before the
/// `complete` action will accept a finalization request when no
/// Composio integrations are connected.
const MIN_EXCHANGES_TO_COMPLETE: u32 = 3;
/// Increment the welcome-agent exchange counter by one.
///
/// Called from the channel dispatch layer every time a user message
/// is routed to the welcome agent (i.e. when
/// `chat_onboarding_completed` is `false`). This is the only write
/// site — tool code and tests use
/// [`get_welcome_exchange_count`] to read it.
pub fn increment_welcome_exchange_count() {
let prev = WELCOME_EXCHANGE_COUNT.fetch_add(1, Ordering::Relaxed);
tracing::debug!(
exchange_count = prev + 1,
"[complete_onboarding] welcome exchange count incremented"
);
}
/// Return the current welcome-agent exchange count (process-global).
///
/// Exposed for tests; production call sites should use the snapshot
/// fields returned by [`check_status`].
pub fn get_welcome_exchange_count() -> u32 {
WELCOME_EXCHANGE_COUNT.load(Ordering::Relaxed)
}
/// Pure-logic helper: given an exchange count and the number of connected
/// Composio integrations, returns whether the engagement criteria for
/// `complete` are satisfied.
///
/// Extracted as a standalone function so tests can verify the criteria
/// without involving I/O (no config load, no Composio HTTP call).
pub(crate) fn engagement_criteria_met(exchange_count: u32, composio_connections: u32) -> bool {
exchange_count >= MIN_EXCHANGES_TO_COMPLETE || composio_connections > 0
}
/// Build the user-facing error string for premature `complete` calls.
///
/// Kept as a pure helper so tests can lock wording and dynamic counters
/// without requiring config/auth/composio setup.
fn build_not_ready_to_complete_error(exchange_count: u32) -> String {
let remaining = MIN_EXCHANGES_TO_COMPLETE.saturating_sub(exchange_count);
format!(
"Cannot complete onboarding yet: User hasn't connected any skills and \
minimum exchanges not reached. Need at least \
{MIN_EXCHANGES_TO_COMPLETE} back-and-forth exchanges (currently \
{exchange_count}; {remaining} more needed) or at least one connected \
Composio integration."
)
}
/// Reset the welcome exchange counter to zero.
///
/// Exposed for tests that need a clean slate. **Do not call in
/// production code** — the counter is process-lifetime state and
/// resetting it would allow premature `complete` calls.
#[cfg(test)]
pub fn reset_welcome_exchange_count() {
WELCOME_EXCHANGE_COUNT.store(0, Ordering::Relaxed);
}
use super::onboarding_status::{build_not_ready_to_complete_error, compute_state, detect_auth};
pub struct CompleteOnboardingTool;
@@ -128,75 +39,24 @@ impl Tool for CompleteOnboardingTool {
}
fn description(&self) -> &str {
"Read the user's OpenHuman config snapshot and, when engagement \
criteria are met, finalize the chat welcome flow.\n\
"Finalize the chat welcome flow. Flips `chat_onboarding_completed` \
to `true` and seeds recurring cron jobs. Returns `\"ok\"` on \
success.\n\
\n\
**action=\"check_status\"** — read-only. Returns a JSON object \
describing the user's setup state. No side effects, no flag \
flips. Call this ONCE on your first iteration with no other \
parameters. Use the JSON to craft a personalised welcome \
message and decide when to call `complete`.\n\
\n\
The returned JSON has this shape:\n\
```\n\
{\n\
\"authenticated\": true, // bool — JWT present\n\
\"auth_source\": \"session_token\", // \"session_token\" | null\n\
\"default_model\": \"reasoning-v1\", // string\n\
\"channels_connected\": [\"telegram\"], // string[] — connected messaging platforms\n\
\"active_channel\": \"web\", // preferred channel for proactive messages\n\
\"integrations\": { // bool flags for each capability\n\
\"composio\": true,\n\
\"browser\": false,\n\
\"web_search\": true,\n\
\"http_request\": true,\n\
\"local_ai\": true\n\
},\n\
\"memory\": { \"backend\": \"sqlite\", \"auto_save\": true },\n\
\"delegate_agents\": [\"researcher\", \"coder\"],\n\
\"ui_onboarding_completed\": true, // React wizard flag\n\
\"chat_onboarding_completed\": false, // still false until complete() succeeds\n\
\"exchange_count\": 1, // how many user messages handled so far\n\
\"ready_to_complete\": false, // true when criteria for complete() are met\n\
\"ready_to_complete_reason\": \"fewer_than_min_exchanges_and_no_skills_connected\", // reason for readiness state\n\
\"onboarding_status\": \"pending\" // \"pending\" | \"already_complete\" | \"unauthenticated\"\n\
}\n\
```\n\
\n\
The `onboarding_status` field describes the current state:\n\
* `\"pending\"` — authenticated, conversation in progress. \
Check `ready_to_complete` to know if you may call `complete`.\n\
* `\"already_complete\"` — `chat_onboarding_completed` is \
already `true`. Welcome the user as a returning visitor.\n\
* `\"unauthenticated\"` — the user has no valid session. \
Explain the auth problem, point them at the desktop login \
flow, and stop. They will get routed back to welcome once \
they authenticate.\n\
\n\
`ready_to_complete` is `true` when at least one of:\n\
* The user has had at least 3 back-and-forth exchanges, or\n\
* The user has connected at least one Composio integration.\n\
\n\
**action=\"complete\"** — finalize the welcome flow. Flips \
`chat_onboarding_completed` to `true` and seeds recurring \
cron jobs. Returns `\"ok\"` on success. **Rejects** (returns \
an error) if called prematurely: the user must have either \
≥ 3 exchanges or at least one connected Composio integration. \
Call only when `ready_to_complete` is `true` in the most \
recent `check_status` snapshot."
Takes no arguments. Call only when the most recent \
`check_onboarding_status` snapshot showed \
`ready_to_complete: true` — the tool re-checks the criteria \
server-side and **rejects** premature calls with a descriptive \
error so the agent knows to keep conversing. Rejects when the \
user is unauthenticated, or when they have fewer than the \
required exchange count AND no connected Composio integrations."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["check_status", "complete"],
"description": "\"check_status\" → read-only JSON snapshot of the user's setup state. No side effects. \"complete\" → finalize the welcome flow; enforces minimum-engagement criteria and rejects premature calls."
}
},
"required": ["action"]
"properties": {},
"additionalProperties": false
})
}
@@ -208,249 +68,13 @@ impl Tool for CompleteOnboardingTool {
ToolScope::AgentOnly
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let action = args
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("check_status");
tracing::debug!("[complete_onboarding] action={action}");
match action {
"check_status" => check_status().await,
"complete" => complete().await,
other => Ok(ToolResult::error(format!(
"Unknown action \"{other}\". Use \"check_status\" or \"complete\"."
))),
}
async fn execute(&self, _args: Value) -> anyhow::Result<ToolResult> {
tracing::debug!("[complete_onboarding] execute");
complete().await
}
}
/// Reads the user's config and returns a structured JSON snapshot.
///
/// **Read-only** — this function has no side effects. It does not flip
/// `chat_onboarding_completed`, does not seed cron jobs, and does not
/// call `complete`. The agent must call `complete` explicitly when it
/// judges the user ready.
///
/// The snapshot includes:
/// * All config flags the welcome message might mention.
/// * `exchange_count` — how many user messages have been dispatched
/// to the welcome agent so far (process-global counter).
/// * `ready_to_complete` — `true` when either exchange_count ≥
/// [`MIN_EXCHANGES_TO_COMPLETE`] or at least one Composio
/// integration is connected.
/// * `ready_to_complete_reason` — machine-friendly reason string that
/// explains why completion is ready (or blocked).
/// * `onboarding_status` — discriminator for the current state
/// (`"pending"`, `"already_complete"`, or `"unauthenticated"`).
async fn check_status() -> anyhow::Result<ToolResult> {
let config = Config::load_or_init()
.await
.map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
let (is_authenticated, _auth_source) = detect_auth(&config);
let onboarding_status = if !is_authenticated {
"unauthenticated"
} else if config.chat_onboarding_completed {
"already_complete"
} else {
"pending"
};
let exchange_count = get_welcome_exchange_count();
let composio_connections = crate::openhuman::composio::fetch_connected_integrations(&config)
.await
.len() as u32;
let ready_to_complete = is_authenticated
&& !config.chat_onboarding_completed
&& engagement_criteria_met(exchange_count, composio_connections);
let ready_to_complete_reason = if !is_authenticated {
"unauthenticated".to_string()
} else if config.chat_onboarding_completed {
"already_complete".to_string()
} else if ready_to_complete {
"criteria_met".to_string()
} else {
"fewer_than_min_exchanges_and_no_skills_connected".to_string()
};
tracing::debug!(
authenticated = is_authenticated,
onboarding_status,
exchange_count,
composio_connections,
ready_to_complete,
ready_to_complete_reason = ready_to_complete_reason.as_str(),
"[complete_onboarding] check_status snapshot built"
);
let snapshot = build_status_snapshot(
&config,
onboarding_status,
exchange_count,
ready_to_complete,
&ready_to_complete_reason,
);
let payload = serde_json::to_string_pretty(&snapshot)
.map_err(|e| anyhow::anyhow!("Failed to serialize status snapshot: {e}"))?;
Ok(ToolResult::success(payload))
}
/// Detect whether the user is authenticated for the welcome flow.
///
/// Authentication is based on the `app-session:default` profile in
/// `auth-profiles.json`, populated by the desktop OAuth deep-link flow.
///
/// Returned as `(is_authenticated, auth_source_json)` so callers can
/// both gate behaviour on the bool and embed the source label in a
/// JSON payload without rebuilding the logic.
pub(crate) fn detect_auth(config: &Config) -> (bool, Value) {
let has_session_jwt = crate::api::jwt::get_session_token(config)
.ok()
.flatten()
.is_some_and(|t| !t.is_empty());
let is_authenticated = has_session_jwt;
let auth_source: Value = if has_session_jwt {
Value::String("session_token".to_string())
} else {
Value::Null
};
(is_authenticated, auth_source)
}
/// Build the structured JSON snapshot that the welcome agent consumes.
///
/// The snapshot describes the user's workspace setup (connected
/// channels, integrations, delegate agents, memory settings, and
/// onboarding flags). Shared between [`check_status`] (reactive,
/// called by the tool) and the proactive welcome path (fired on
/// `onboarding_completed` false→true).
///
/// * `onboarding_status` — `"pending"` | `"already_complete"` | `"unauthenticated"`
/// * `exchange_count` — messages dispatched to welcome agent this session
/// * `ready_to_complete` — whether the `complete` action would succeed
/// * `ready_to_complete_reason` — machine-friendly reason string for
/// the readiness state.
pub(crate) fn build_status_snapshot(
config: &Config,
onboarding_status: &str,
exchange_count: u32,
ready_to_complete: bool,
ready_to_complete_reason: &str,
) -> Value {
let (is_authenticated, auth_source) = detect_auth(config);
// ── Connected messaging channels ──────────────────────────────
let mut channels_connected: Vec<&str> = Vec::new();
if config.channels_config.telegram.is_some() {
channels_connected.push("telegram");
}
if config.channels_config.discord.is_some() {
channels_connected.push("discord");
}
if config.channels_config.slack.is_some() {
channels_connected.push("slack");
}
if config.channels_config.mattermost.is_some() {
channels_connected.push("mattermost");
}
if config.channels_config.email.is_some() {
channels_connected.push("email");
}
if config.channels_config.whatsapp.is_some() {
channels_connected.push("whatsapp");
}
if config.channels_config.signal.is_some() {
channels_connected.push("signal");
}
if config.channels_config.matrix.is_some() {
channels_connected.push("matrix");
}
if config.channels_config.imessage.is_some() {
channels_connected.push("imessage");
}
if config.channels_config.irc.is_some() {
channels_connected.push("irc");
}
if config.channels_config.lark.is_some() {
channels_connected.push("lark");
}
if config.channels_config.dingtalk.is_some() {
channels_connected.push("dingtalk");
}
if config.channels_config.linq.is_some() {
channels_connected.push("linq");
}
if config.channels_config.qq.is_some() {
channels_connected.push("qq");
}
let composio_enabled = config.composio.enabled;
let delegate_agents: Vec<&str> = config.agents.keys().map(|s| s.as_str()).collect();
json!({
"authenticated": is_authenticated,
"auth_source": auth_source,
"default_model": config
.default_model
.as_deref()
.unwrap_or(crate::openhuman::config::DEFAULT_MODEL),
"channels_connected": channels_connected,
"active_channel": config
.channels_config
.active_channel
.as_deref()
.unwrap_or("web"),
"integrations": {
"composio": composio_enabled,
"browser": config.browser.enabled,
"web_search": true,
"http_request": true,
"local_ai": config.local_ai.enabled,
},
"memory": {
"backend": config.memory.backend,
"auto_save": config.memory.auto_save,
},
"delegate_agents": delegate_agents,
"ui_onboarding_completed": config.onboarding_completed,
"chat_onboarding_completed": config.chat_onboarding_completed,
"exchange_count": exchange_count,
"ready_to_complete": ready_to_complete,
"ready_to_complete_reason": ready_to_complete_reason,
"onboarding_status": onboarding_status,
})
}
/// Finalize the welcome flow.
///
/// Flips `chat_onboarding_completed` to `true` and seeds recurring
/// proactive cron jobs. Returns `"ok"` on success.
///
/// ## Guard criteria
///
/// Rejects (returns a [`ToolResult::error`]) if the user has not yet
/// met the minimum engagement threshold:
///
/// * **Exchange count** — at least [`MIN_EXCHANGES_TO_COMPLETE`] user
/// messages have been dispatched to the welcome agent, **or**
/// * **Composio connection** — at least one Composio integration is
/// connected.
///
/// Either criterion is sufficient. The intent is that onboarding
/// completion reflects a real interaction, not a race between the
/// welcome agent and an auto-finalizer.
///
/// ## Auth requirement
///
/// Requires the user to be authenticated with a valid session JWT.
/// If `detect_auth()` cannot resolve an active app-session token, the
/// call is rejected with an explanation so the agent can instruct the
/// user to log in.
/// Finalize the welcome flow. See the tool description for guard rules.
async fn complete() -> anyhow::Result<ToolResult> {
let mut config = Config::load_or_init()
.await
@@ -465,7 +89,7 @@ async fn complete() -> anyhow::Result<ToolResult> {
// ── Auth guard ────────────────────────────────────────────────
let (is_authenticated, _) = detect_auth(&config);
if !is_authenticated {
tracing::debug!("[complete_onboarding] complete rejected — user not authenticated");
tracing::debug!("[complete_onboarding] rejected — user not authenticated");
return Ok(ToolResult::error(
"Cannot complete onboarding: the user is not authenticated. \
Please guide them to log in via the desktop login flow first.",
@@ -473,23 +97,17 @@ async fn complete() -> anyhow::Result<ToolResult> {
}
// ── Engagement guard ──────────────────────────────────────────
let exchange_count = get_welcome_exchange_count();
let composio_connections = crate::openhuman::composio::fetch_connected_integrations(&config)
.await
.len() as u32;
let criteria_met = engagement_criteria_met(exchange_count, composio_connections);
let state = compute_state(&config).await;
tracing::debug!(
exchange_count,
composio_connections,
criteria_met,
exchange_count = state.exchange_count,
composio_connections = state.composio_connected_toolkits.len(),
ready = state.ready_to_complete,
"[complete_onboarding] engagement guard check"
);
if !criteria_met {
if !state.ready_to_complete {
return Ok(ToolResult::error(build_not_ready_to_complete_error(
exchange_count,
state.exchange_count,
)));
}
@@ -508,8 +126,8 @@ async fn complete() -> anyhow::Result<ToolResult> {
});
tracing::info!(
exchange_count,
composio_connections,
exchange_count = state.exchange_count,
composio_connections = state.composio_connected_toolkits.len(),
"[complete_onboarding] chat welcome flow finalized"
);
@@ -527,159 +145,21 @@ mod tests {
assert_eq!(tool.permission_level(), PermissionLevel::Write);
assert_eq!(tool.scope(), ToolScope::AgentOnly);
let schema = tool.parameters_schema();
assert!(schema["properties"]["action"].is_object());
assert_eq!(schema["required"], serde_json::json!(["action"]));
assert_eq!(schema["type"], "object");
// No required params — call it with `{}`.
assert!(schema.get("required").is_none());
}
#[test]
fn build_status_snapshot_carries_new_fields() {
// A default Config is "bare install" — no channels, no
// integrations. This test locks in the JSON shape the welcome
// agent's prompt.md depends on. Dropping or renaming a field
// breaks this test loudly.
let config = Config::default();
let snapshot = build_status_snapshot(
&config,
"pending",
0,
false,
"fewer_than_min_exchanges_and_no_skills_connected",
);
assert_eq!(snapshot["onboarding_status"], "pending");
assert_eq!(snapshot["exchange_count"], 0);
assert_eq!(snapshot["ready_to_complete"], false);
assert_eq!(
snapshot["ready_to_complete_reason"],
"fewer_than_min_exchanges_and_no_skills_connected"
);
assert_eq!(snapshot["chat_onboarding_completed"], false);
assert_eq!(snapshot["ui_onboarding_completed"], false);
assert_eq!(snapshot["active_channel"], "web");
assert_eq!(
snapshot["channels_connected"]
.as_array()
.expect("channels_connected is an array")
.len(),
0,
"default Config should report zero connected channels"
);
assert!(snapshot["integrations"].is_object());
assert!(snapshot["memory"].is_object());
for key in [
"composio",
"browser",
"web_search",
"http_request",
"local_ai",
] {
assert!(
snapshot["integrations"][key].is_boolean(),
"integrations.{key} must be a bool"
);
}
}
#[test]
fn build_status_snapshot_ready_to_complete_reflected() {
let config = Config::default();
let snapshot = build_status_snapshot(&config, "pending", 5, true, "criteria_met");
assert_eq!(snapshot["ready_to_complete"], true);
assert_eq!(snapshot["ready_to_complete_reason"], "criteria_met");
assert_eq!(snapshot["exchange_count"], 5);
assert_eq!(snapshot["onboarding_status"], "pending");
}
#[test]
fn build_status_snapshot_unauthenticated_reason_reflected() {
let config = Config::default();
let snapshot =
build_status_snapshot(&config, "unauthenticated", 0, false, "unauthenticated");
assert_eq!(snapshot["ready_to_complete"], false);
assert_eq!(snapshot["ready_to_complete_reason"], "unauthenticated");
assert_eq!(snapshot["onboarding_status"], "unauthenticated");
}
#[test]
fn detect_auth_on_default_config_is_unauthenticated() {
let config = Config::default();
let (is_auth, source) = detect_auth(&config);
assert!(!is_auth);
assert!(source.is_null());
}
// ── exchange counter ──────────────────────────────────────────────────────
#[test]
fn exchange_counter_increments_and_resets() {
reset_welcome_exchange_count();
assert_eq!(get_welcome_exchange_count(), 0);
increment_welcome_exchange_count();
assert_eq!(get_welcome_exchange_count(), 1);
increment_welcome_exchange_count();
increment_welcome_exchange_count();
assert_eq!(get_welcome_exchange_count(), 3);
reset_welcome_exchange_count();
assert_eq!(get_welcome_exchange_count(), 0);
}
// ── description ───────────────────────────────────────────────────────────
#[test]
fn description_mentions_key_actions() {
let tool = CompleteOnboardingTool::new();
let desc = tool.description();
assert!(!desc.is_empty());
fn description_mentions_check_onboarding_status() {
let desc = CompleteOnboardingTool::new().description().to_string();
assert!(
desc.contains("check_status"),
"description should mention check_status"
);
assert!(
desc.contains("complete"),
"description should mention complete"
);
assert!(
desc.contains("ready_to_complete"),
"description should mention ready_to_complete"
);
assert!(
desc.contains("ready_to_complete_reason"),
"description should mention ready_to_complete_reason"
desc.contains("check_onboarding_status"),
"description should point agents at the companion status tool: {desc}"
);
assert!(desc.contains("ready_to_complete"));
}
#[test]
fn premature_complete_error_mentions_skills_and_exchanges() {
let msg = build_not_ready_to_complete_error(1);
assert!(
msg.contains("User hasn't connected any skills and minimum exchanges not reached"),
"expected issue #596 wording in error message, got: {msg}"
);
assert!(
msg.contains("currently 1; 2 more needed"),
"expected dynamic exchange counters in error message, got: {msg}"
);
}
// ── schema enum values ────────────────────────────────────────────────────
#[test]
fn schema_action_enum_has_both_values() {
let tool = CompleteOnboardingTool::new();
let schema = tool.parameters_schema();
let enum_vals = schema["properties"]["action"]["enum"]
.as_array()
.expect("action enum should be an array");
let names: Vec<&str> = enum_vals.iter().map(|v| v.as_str().unwrap()).collect();
assert!(
names.contains(&"check_status"),
"enum should contain check_status"
);
assert!(names.contains(&"complete"), "enum should contain complete");
}
// ── spec roundtrip ────────────────────────────────────────────────────────
#[test]
fn spec_roundtrip() {
let tool = CompleteOnboardingTool::new();
@@ -687,83 +167,4 @@ mod tests {
assert_eq!(spec.name, "complete_onboarding");
assert!(spec.parameters.is_object());
}
// ── execute: unknown action ───────────────────────────────────────────────
#[tokio::test]
async fn execute_unknown_action_returns_error() {
let tool = CompleteOnboardingTool::new();
let result = tool
.execute(serde_json::json!({"action": "unknown_action"}))
.await
.unwrap();
assert!(result.is_error);
assert!(
result.output().contains("Unknown action"),
"error message should contain 'Unknown action', got: {}",
result.output()
);
}
// ── execute: missing action defaults to check_status ─────────────────────
#[tokio::test]
async fn execute_missing_action_defaults_to_check_status() {
// When action is absent it defaults to "check_status", which calls
// Config::load_or_init() — that may succeed or fail depending on env,
// but it should not return the "Unknown action" error.
let tool = CompleteOnboardingTool::new();
let result = tool.execute(serde_json::json!({})).await;
if let Ok(r) = result {
assert!(
!r.output().contains("Unknown action"),
"missing action should default to check_status, not 'Unknown action'"
);
}
}
// ── guard: engagement_criteria_met ───────────────────────────────────────
/// Zero exchanges, no composio → criteria NOT met.
#[test]
fn criteria_not_met_zero_exchanges_no_composio() {
assert!(!engagement_criteria_met(0, 0));
}
/// One exchange below threshold, no composio → criteria NOT met.
#[test]
fn criteria_not_met_below_threshold() {
assert!(!engagement_criteria_met(MIN_EXCHANGES_TO_COMPLETE - 1, 0));
}
/// Exactly at the exchange threshold, no composio → criteria MET.
#[test]
fn criteria_met_at_exchange_threshold() {
assert!(engagement_criteria_met(MIN_EXCHANGES_TO_COMPLETE, 0));
}
/// Above the exchange threshold → criteria MET.
#[test]
fn criteria_met_above_threshold() {
assert!(engagement_criteria_met(MIN_EXCHANGES_TO_COMPLETE + 5, 0));
}
/// Zero exchanges but one composio connection → criteria MET
/// (composio is an OR shortcut, not AND).
#[test]
fn criteria_met_via_composio_zero_exchanges() {
assert!(engagement_criteria_met(0, 1));
}
/// One exchange and one composio connection → criteria MET.
#[test]
fn criteria_met_via_composio_with_exchanges() {
assert!(engagement_criteria_met(1, 1));
}
/// Exchange count at u32::MAX — no panic, criteria met.
#[test]
fn criteria_met_saturating_exchange_count() {
assert!(engagement_criteria_met(u32::MAX, 0));
}
}
+3
View File
@@ -1,8 +1,10 @@
mod archetype_delegation;
mod ask_clarification;
pub(crate) mod check_onboarding_status;
pub(crate) mod complete_onboarding;
mod delegate;
mod dispatch;
pub(crate) mod onboarding_status;
mod skill_delegation;
mod spawn_subagent;
@@ -10,6 +12,7 @@ pub(crate) use dispatch::dispatch_subagent;
pub use archetype_delegation::ArchetypeDelegationTool;
pub use ask_clarification::AskClarificationTool;
pub use check_onboarding_status::CheckOnboardingStatusTool;
pub use complete_onboarding::CompleteOnboardingTool;
pub use delegate::DelegateTool;
pub use skill_delegation::SkillDelegationTool;
@@ -0,0 +1,372 @@
//! Shared helpers for the welcome agent's onboarding tools.
//!
//! Both `check_onboarding_status` (read-only snapshot) and
//! `complete_onboarding` (finalizer) need the same primitives:
//!
//! * A process-global counter of welcome-agent exchanges this session.
//! * An auth detector (`detect_auth`) that bools out whether a session
//! JWT is present.
//! * The engagement-criteria gate that decides whether `complete` may
//! run (≥ [`MIN_EXCHANGES_TO_COMPLETE`] exchanges **or** ≥ 1 Composio
//! connection).
//! * The JSON snapshot builder the agent consumes — now also exposing
//! the list of connected Composio toolkits and the per-provider
//! webview-login heuristic (see `openhuman::webview_accounts`).
//!
//! Keeping this in one place lets the two tools stay small and lets
//! [`crate::openhuman::agent::welcome_proactive`] build the same snapshot
//! shape without pulling in tool code.
use crate::openhuman::config::Config;
use serde_json::{json, Value};
use std::sync::atomic::{AtomicU32, Ordering};
/// Minimum number of welcome-agent exchanges required before
/// `complete_onboarding` will accept a finalization request when no
/// Composio integrations are connected.
pub(crate) const MIN_EXCHANGES_TO_COMPLETE: u32 = 3;
/// Process-global exchange counter for the welcome agent.
///
/// Incremented by [`increment_welcome_exchange_count`] (called from the
/// channel dispatch layer) once per inbound user message that routes to
/// the welcome agent. Read by the status tool and by the complete
/// finalizer. Process-local (not persisted) because the welcome flow
/// runs exactly once per fresh install; after completion the counter is
/// never consulted again.
static WELCOME_EXCHANGE_COUNT: AtomicU32 = AtomicU32::new(0);
/// Increment the welcome-agent exchange counter by one.
///
/// Only write site. Called from the channel dispatch layer every time a
/// user message is routed to the welcome agent (i.e. when
/// `chat_onboarding_completed` is `false`).
pub fn increment_welcome_exchange_count() {
let prev = WELCOME_EXCHANGE_COUNT.fetch_add(1, Ordering::Relaxed);
tracing::debug!(
exchange_count = prev + 1,
"[onboarding] welcome exchange count incremented"
);
}
/// Return the current welcome-agent exchange count (process-global).
pub fn get_welcome_exchange_count() -> u32 {
WELCOME_EXCHANGE_COUNT.load(Ordering::Relaxed)
}
/// Pure-logic helper: given an exchange count and the number of connected
/// Composio integrations, returns whether the engagement criteria for
/// `complete_onboarding` are satisfied.
pub(crate) fn engagement_criteria_met(exchange_count: u32, composio_connections: u32) -> bool {
exchange_count >= MIN_EXCHANGES_TO_COMPLETE || composio_connections > 0
}
/// Build the user-facing error string for premature `complete_onboarding`
/// calls.
pub(crate) fn build_not_ready_to_complete_error(exchange_count: u32) -> String {
let remaining = MIN_EXCHANGES_TO_COMPLETE.saturating_sub(exchange_count);
format!(
"Cannot complete onboarding yet: User hasn't connected any skills and \
minimum exchanges not reached. Need at least \
{MIN_EXCHANGES_TO_COMPLETE} back-and-forth exchanges (currently \
{exchange_count}; {remaining} more needed) or at least one connected \
Composio integration."
)
}
/// Reset the welcome exchange counter to zero. Test-only.
#[cfg(test)]
pub fn reset_welcome_exchange_count() {
WELCOME_EXCHANGE_COUNT.store(0, Ordering::Relaxed);
}
/// Detect whether the user is authenticated for the welcome flow.
///
/// Authentication is based on the `app-session:default` profile in
/// `auth-profiles.json`, populated by the desktop OAuth deep-link flow.
///
/// Returned as `(is_authenticated, auth_source_json)` so callers can
/// both gate behaviour on the bool and embed the source label in a
/// JSON payload without rebuilding the logic.
pub(crate) fn detect_auth(config: &Config) -> (bool, Value) {
let has_session_jwt = crate::api::jwt::get_session_token(config)
.ok()
.flatten()
.is_some_and(|t| !t.is_empty());
let is_authenticated = has_session_jwt;
let auth_source: Value = if has_session_jwt {
Value::String("session_token".to_string())
} else {
Value::Null
};
(is_authenticated, auth_source)
}
/// Build the structured JSON snapshot that the welcome agent consumes.
///
/// Shared between the `check_onboarding_status` tool (reactive) and the
/// proactive welcome path (fired on `onboarding_completed` false→true).
///
/// Beyond the workspace flags, the snapshot carries three signals the
/// agent uses to decide what to offer next:
///
/// * `composio_connected_toolkits` — list of Composio toolkit slugs the
/// user has authorized (e.g. `["gmail", "github"]`). Derived from the
/// same backend call that drives `ready_to_complete`, exposed here so
/// the agent doesn't re-pitch gmail after it's already connected.
/// * `webview_logins` — per-provider bools (gmail, whatsapp, telegram,
/// slack, discord, linkedin, zoom, google_messages) indicating
/// whether the shared CEF cookie store has an active session cookie
/// for that provider. See `openhuman::webview_accounts`.
/// * `exchange_count` / `ready_to_complete` / `ready_to_complete_reason`
/// — the gate the finalizer enforces.
pub(crate) fn build_status_snapshot(
config: &Config,
onboarding_status: &str,
exchange_count: u32,
ready_to_complete: bool,
ready_to_complete_reason: &str,
composio_connected_toolkits: &[String],
webview_logins: Value,
) -> Value {
let (is_authenticated, auth_source) = detect_auth(config);
// ── Connected messaging channels ──────────────────────────────
let mut channels_connected: Vec<&str> = Vec::new();
if config.channels_config.telegram.is_some() {
channels_connected.push("telegram");
}
if config.channels_config.discord.is_some() {
channels_connected.push("discord");
}
if config.channels_config.slack.is_some() {
channels_connected.push("slack");
}
if config.channels_config.mattermost.is_some() {
channels_connected.push("mattermost");
}
if config.channels_config.email.is_some() {
channels_connected.push("email");
}
if config.channels_config.whatsapp.is_some() {
channels_connected.push("whatsapp");
}
if config.channels_config.signal.is_some() {
channels_connected.push("signal");
}
if config.channels_config.matrix.is_some() {
channels_connected.push("matrix");
}
if config.channels_config.imessage.is_some() {
channels_connected.push("imessage");
}
if config.channels_config.irc.is_some() {
channels_connected.push("irc");
}
if config.channels_config.lark.is_some() {
channels_connected.push("lark");
}
if config.channels_config.dingtalk.is_some() {
channels_connected.push("dingtalk");
}
if config.channels_config.linq.is_some() {
channels_connected.push("linq");
}
if config.channels_config.qq.is_some() {
channels_connected.push("qq");
}
let composio_enabled = config.composio.enabled;
let delegate_agents: Vec<&str> = config.agents.keys().map(|s| s.as_str()).collect();
json!({
"authenticated": is_authenticated,
"auth_source": auth_source,
"default_model": config
.default_model
.as_deref()
.unwrap_or(crate::openhuman::config::DEFAULT_MODEL),
"channels_connected": channels_connected,
"active_channel": config
.channels_config
.active_channel
.as_deref()
.unwrap_or("web"),
"integrations": {
"composio": composio_enabled,
"browser": config.browser.enabled,
"web_search": true,
"http_request": true,
"local_ai": config.local_ai.enabled,
},
"composio_connected_toolkits": composio_connected_toolkits,
"webview_logins": webview_logins,
"memory": {
"backend": config.memory.backend,
"auto_save": config.memory.auto_save,
},
"delegate_agents": delegate_agents,
"ui_onboarding_completed": config.onboarding_completed,
"chat_onboarding_completed": config.chat_onboarding_completed,
"exchange_count": exchange_count,
"ready_to_complete": ready_to_complete,
"ready_to_complete_reason": ready_to_complete_reason,
"onboarding_status": onboarding_status,
})
}
/// Summarise the current onboarding state for snapshot + finalizer.
///
/// Both tools need the same derived view, so we compute it once here:
/// authenticated? already complete? how many exchanges so far, how many
/// Composio connections, which toolkits, and the resulting
/// `ready_to_complete` gate + reason. Shared code path = shared bugs,
/// so both tools agree on who's ready.
pub(crate) struct OnboardingState {
pub is_authenticated: bool,
pub exchange_count: u32,
pub composio_connected_toolkits: Vec<String>,
pub onboarding_status: &'static str,
pub ready_to_complete: bool,
pub ready_to_complete_reason: String,
}
pub(crate) async fn compute_state(config: &Config) -> OnboardingState {
let (is_authenticated, _) = detect_auth(config);
let exchange_count = get_welcome_exchange_count();
let integrations = crate::openhuman::composio::fetch_connected_integrations(config).await;
let composio_connected_toolkits: Vec<String> = integrations
.iter()
.filter(|i| i.connected)
.map(|i| i.toolkit.clone())
.collect();
let composio_connections = composio_connected_toolkits.len() as u32;
let onboarding_status = if !is_authenticated {
"unauthenticated"
} else if config.chat_onboarding_completed {
"already_complete"
} else {
"pending"
};
let ready_to_complete = is_authenticated
&& !config.chat_onboarding_completed
&& engagement_criteria_met(exchange_count, composio_connections);
let ready_to_complete_reason = if !is_authenticated {
"unauthenticated".to_string()
} else if config.chat_onboarding_completed {
"already_complete".to_string()
} else if ready_to_complete {
"criteria_met".to_string()
} else {
"fewer_than_min_exchanges_and_no_skills_connected".to_string()
};
OnboardingState {
is_authenticated,
exchange_count,
composio_connected_toolkits,
onboarding_status,
ready_to_complete,
ready_to_complete_reason,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_status_snapshot_carries_expected_fields() {
let config = Config::default();
let snap = build_status_snapshot(
&config,
"pending",
0,
false,
"fewer_than_min_exchanges_and_no_skills_connected",
&[],
json!({"gmail": false}),
);
assert_eq!(snap["onboarding_status"], "pending");
assert_eq!(snap["exchange_count"], 0);
assert_eq!(snap["ready_to_complete"], false);
assert_eq!(snap["chat_onboarding_completed"], false);
assert!(snap["composio_connected_toolkits"].is_array());
assert_eq!(
snap["composio_connected_toolkits"]
.as_array()
.unwrap()
.len(),
0
);
assert_eq!(snap["webview_logins"]["gmail"], false);
}
#[test]
fn build_status_snapshot_carries_connected_toolkits_and_webview() {
let config = Config::default();
let snap = build_status_snapshot(
&config,
"pending",
2,
false,
"fewer_than_min_exchanges_and_no_skills_connected",
&["gmail".to_string(), "github".to_string()],
json!({"gmail": true, "whatsapp": false}),
);
let toolkits = snap["composio_connected_toolkits"].as_array().unwrap();
assert_eq!(toolkits[0], "gmail");
assert_eq!(toolkits[1], "github");
assert_eq!(snap["webview_logins"]["gmail"], true);
assert_eq!(snap["webview_logins"]["whatsapp"], false);
}
#[test]
fn detect_auth_on_default_config_is_unauthenticated() {
let config = Config::default();
let (is_auth, source) = detect_auth(&config);
assert!(!is_auth);
assert!(source.is_null());
}
#[test]
fn exchange_counter_increments_and_resets() {
reset_welcome_exchange_count();
assert_eq!(get_welcome_exchange_count(), 0);
increment_welcome_exchange_count();
increment_welcome_exchange_count();
assert_eq!(get_welcome_exchange_count(), 2);
reset_welcome_exchange_count();
assert_eq!(get_welcome_exchange_count(), 0);
}
#[test]
fn criteria_not_met_zero_exchanges_no_composio() {
assert!(!engagement_criteria_met(0, 0));
}
#[test]
fn criteria_met_at_exchange_threshold() {
assert!(engagement_criteria_met(MIN_EXCHANGES_TO_COMPLETE, 0));
}
#[test]
fn criteria_met_via_composio_zero_exchanges() {
assert!(engagement_criteria_met(0, 1));
}
#[test]
fn premature_complete_error_mentions_skills_and_exchanges() {
let msg = build_not_ready_to_complete_error(1);
assert!(
msg.contains("User hasn't connected any skills and minimum exchanges not reached"),
"unexpected wording: {msg}"
);
assert!(
msg.contains("currently 1; 2 more needed"),
"dynamic counters: {msg}"
);
}
}
+5
View File
@@ -107,6 +107,7 @@ pub fn all_tools_with_runtime(
// returns a single text result. See
// `agent::harness::subagent_runner` for the dispatch path.
Box::new(SpawnSubagentTool::new()),
Box::new(CheckOnboardingStatusTool::new()),
Box::new(CompleteOnboardingTool::new()),
Box::new(CurrentTimeTool::new()),
Box::new(CronAddTool::new(config.clone(), security.clone())),
@@ -404,6 +405,10 @@ mod tests {
names.contains(&"complete_onboarding"),
"complete_onboarding must be registered in the default tool list; got: {names:?}"
);
assert!(
names.contains(&"check_onboarding_status"),
"check_onboarding_status must be registered in the default tool list; got: {names:?}"
);
}
#[test]
+26
View File
@@ -0,0 +1,26 @@
//! Webview account login detection for the core sidecar.
//!
//! The Tauri shell hosts CEF-backed webviews for third-party accounts
//! (Gmail, WhatsApp, Telegram, Slack, Discord, LinkedIn, Zoom, Google
//! Messages). Their HTTP cookies live in a single shared Chromium
//! cookie store at `{CEF_USER_DATA_DIR}/Default/Cookies` — a SQLite
//! database. The core runs as a child sidecar and has no direct handle
//! to CEF, so the Tauri shell exports `OPENHUMAN_CEF_COOKIES_DB`
//! pointing at that file before spawning core.
//!
//! The `ops` submodule opens the DB read-only and asks a simple
//! question per provider: "is there a row whose `host_key` matches our
//! expected host suffix and whose `name` matches a known session-cookie
//! name?" If so, we report `logged_in: true` for that provider. If the
//! env var is missing, the DB can't be opened (locked, corrupt,
//! nonexistent), or no matching rows exist, we report
//! `logged_in: false` for every provider — never return an error, the
//! welcome-agent snapshot must always build.
//!
//! This is a heuristic. Chromium prunes expired cookies at startup, so
//! any row with a known session-cookie name is a strong signal the
//! user has an active session for that provider.
mod ops;
pub use ops::detect_webview_logins;
+420
View File
@@ -0,0 +1,420 @@
//! Operational core for webview login detection.
//!
//! See the parent `mod.rs` for the why/how. This file owns the actual
//! cookie-store probe.
use rusqlite::{Connection, OpenFlags};
use serde_json::Value;
use std::path::PathBuf;
/// Env var set by the Tauri shell to the shared CEF cookies SQLite
/// path. See `app/src-tauri/src/lib.rs`.
pub(crate) const COOKIES_DB_ENV: &str = "OPENHUMAN_CEF_COOKIES_DB";
/// A provider we surface in the welcome snapshot.
///
/// `host_suffix` is matched against Chromium's `host_key` column with a
/// trailing-wildcard SQL `LIKE`. `session_cookie_names` are the cookie
/// `name` values that indicate an active login — any one match is
/// sufficient.
struct Provider {
/// Stable key surfaced in the JSON snapshot (e.g. `"gmail"`).
key: &'static str,
/// Host suffix the auth cookie must live under. Chromium stores
/// host_key with a leading dot for domain cookies (e.g.
/// `.google.com`) or the full host for host-only cookies. We match
/// with `%suffix`.
host_suffix: &'static str,
/// Cookie names that indicate a logged-in session. Picked per-provider
/// to avoid false positives from analytics/consent cookies.
session_cookie_names: &'static [&'static str],
}
/// Providers the welcome agent cares about. Keep this list aligned
/// with the webview accounts system in `app/src-tauri/src/webview_accounts/`.
pub(crate) const PROVIDERS: &[Provider] = &[
Provider {
key: "gmail",
host_suffix: ".google.com",
session_cookie_names: &["SID", "HSID", "SSID", "APISID", "SAPISID"],
},
Provider {
key: "whatsapp",
host_suffix: "web.whatsapp.com",
session_cookie_names: &["wa_ul", "wa_build"],
},
Provider {
key: "telegram",
host_suffix: "web.telegram.org",
session_cookie_names: &["stel_ssid", "stel_token"],
},
Provider {
key: "slack",
host_suffix: ".slack.com",
session_cookie_names: &["d", "d-s"],
},
Provider {
key: "discord",
host_suffix: ".discord.com",
session_cookie_names: &["__Secure-recent_session", "__Secure-authjs.session-token"],
},
Provider {
key: "linkedin",
host_suffix: ".linkedin.com",
session_cookie_names: &["li_at"],
},
Provider {
key: "zoom",
host_suffix: ".zoom.us",
session_cookie_names: &["_zm_ssid", "zm_aid"],
},
Provider {
key: "google_messages",
host_suffix: "messages.google.com",
session_cookie_names: &["SID", "HSID"],
},
];
/// Resolve the shared CEF cookies SQLite path from the env var.
///
/// Returns `None` if the env var is unset or empty. We do **not** try to
/// guess a platform-specific default here: the Tauri shell is the only
/// component that authoritatively knows the bundle identifier + cache
/// directory, and letting it configure us keeps dev/test/ci variants
/// (custom `OPENHUMAN_WORKSPACE`, renamed bundle) working without
/// special-casing.
fn cookies_db_path() -> Option<PathBuf> {
let value = std::env::var(COOKIES_DB_ENV).ok()?;
if value.is_empty() {
return None;
}
Some(PathBuf::from(value))
}
/// Detect which supported webview providers have a live login in the
/// shared CEF cookie store.
///
/// Returns a JSON object keyed by provider slug, value `true` when at
/// least one known session cookie is present for that provider. Every
/// provider in [`PROVIDERS`] is present in the result, even when
/// `false` — the welcome agent uses `false` entries to decide what to
/// offer.
///
/// This never fails: missing env var, locked DB, schema drift — all
/// map to "everything false." The welcome snapshot is load-bearing on
/// first-run and must always build.
pub fn detect_webview_logins() -> Value {
let mut out = serde_json::Map::with_capacity(PROVIDERS.len());
for p in PROVIDERS {
out.insert(p.key.to_string(), Value::Bool(false));
}
let Some(path) = cookies_db_path() else {
tracing::debug!(
env = COOKIES_DB_ENV,
"[webview_accounts] cookies DB env var not set — reporting all providers as logged_out"
);
return Value::Object(out);
};
if !path.exists() {
// Don't log the absolute path — it can include a username under
// /Users/<name>/... or /home/<name>/... — log the env key only.
tracing::debug!(
env = COOKIES_DB_ENV,
"[webview_accounts] cookies DB path does not exist — reporting all providers as logged_out"
);
return Value::Object(out);
}
// URI form with `mode=ro&immutable=1&nolock=1` is required because
// CEF keeps an exclusive lock on the live cookies file; `immutable`
// tells SQLite to skip the WAL and lock dance and read pages
// directly. We don't care about concurrent writes from CEF — a
// stale read is fine for a "has the user logged in" heuristic.
//
// The path component of a SQLite file: URI must be percent-encoded
// per <https://sqlite.org/uri.html> — otherwise spaces (common in
// macOS `/Users/John Doe/...`), `?`, `#`, `%`, and Windows `\`
// separators would break parsing and the open silently fails.
let uri = format!(
"file:{}?mode=ro&immutable=1&nolock=1",
sqlite_uri_path(&path)
);
let conn = match Connection::open_with_flags(
&uri,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
) {
Ok(c) => c,
Err(err) => {
tracing::debug!(
env = COOKIES_DB_ENV,
error = %err,
"[webview_accounts] failed to open cookies DB — reporting all providers as logged_out"
);
return Value::Object(out);
}
};
for p in PROVIDERS {
let logged_in = provider_has_session_cookie(&conn, p);
tracing::debug!(
provider = p.key,
logged_in,
"[webview_accounts] probed provider login state"
);
out.insert(p.key.to_string(), Value::Bool(logged_in));
}
Value::Object(out)
}
/// Return `true` when the cookie DB has at least one row whose host_key
/// ends with `host_suffix` and whose name is one of the provider's
/// session-cookie names. Any SQL failure maps to `false`.
fn provider_has_session_cookie(conn: &Connection, provider: &Provider) -> bool {
if provider.session_cookie_names.is_empty() {
return false;
}
let placeholders = provider
.session_cookie_names
.iter()
.map(|_| "?")
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT 1 FROM cookies \
WHERE host_key LIKE ?1 ESCAPE '\\' \
AND name IN ({placeholders}) \
LIMIT 1"
);
// Escape SQL-LIKE metacharacters in the suffix so a provider entry
// with `_` or `%` can't silently widen the match. All current
// entries are plain hostnames but future additions might not be.
let like_pattern = format!("%{}", escape_like(provider.host_suffix));
let mut stmt = match conn.prepare(&sql) {
Ok(s) => s,
Err(err) => {
tracing::debug!(
provider = provider.key,
error = %err,
"[webview_accounts] prepare cookies query failed"
);
return false;
}
};
let mut params: Vec<&dyn rusqlite::ToSql> =
Vec::with_capacity(1 + provider.session_cookie_names.len());
params.push(&like_pattern);
for name in provider.session_cookie_names {
params.push(name);
}
match stmt.exists(params.as_slice()) {
Ok(found) => found,
Err(err) => {
tracing::debug!(
provider = provider.key,
error = %err,
"[webview_accounts] cookies query execution failed"
);
false
}
}
}
/// Encode a filesystem path for use as the path component of a SQLite
/// `file:` URI.
///
/// Per <https://sqlite.org/uri.html>: backslashes (Windows) become
/// forward slashes, then the path is percent-encoded so that spaces,
/// `?`, `#`, and literal `%` don't get reinterpreted as URI syntax.
/// We use `urlencoding::encode` and then put `/` separators back —
/// `urlencoding` is RFC-3986-strict and would otherwise escape every
/// `/` in the path, which SQLite doesn't want.
fn sqlite_uri_path(path: &std::path::Path) -> String {
let raw = path.to_string_lossy().replace('\\', "/");
urlencoding::encode(&raw).replace("%2F", "/")
}
fn escape_like(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'%' | '_' | '\\' => {
out.push('\\');
out.push(ch);
}
_ => out.push(ch),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::params;
use std::sync::{Mutex, MutexGuard};
use tempfile::TempDir;
/// Serialise tests that mutate `COOKIES_DB_ENV`. Rust runs tests in
/// parallel by default, and `std::env::set_var` is process-global —
/// without this lock two tests can race and observe each other's
/// env mutations. Using a plain `Mutex` rather than pulling in
/// `serial_test` keeps the dev-deps surface flat.
static ENV_LOCK: Mutex<()> = Mutex::new(());
/// Acquire the env lock for the duration of a test. Recovers from a
/// poisoned mutex (a previous test panicked) so a single failure
/// doesn't cascade into "every other test panics on lock".
fn lock_env() -> MutexGuard<'static, ()> {
ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
fn make_cookies_db(path: &std::path::Path, rows: &[(&str, &str)]) {
let conn = Connection::open(path).unwrap();
conn.execute_batch(
"CREATE TABLE cookies (\
host_key TEXT NOT NULL,\
name TEXT NOT NULL,\
value TEXT NOT NULL\
);",
)
.unwrap();
for (host, name) in rows {
conn.execute(
"INSERT INTO cookies(host_key, name, value) VALUES (?1, ?2, '')",
params![host, name],
)
.unwrap();
}
}
/// Guard: results always cover every provider, even when the DB is
/// missing. The welcome snapshot depends on this invariant.
#[test]
fn missing_env_returns_all_false() {
let _lock = lock_env();
std::env::remove_var(COOKIES_DB_ENV);
let v = detect_webview_logins();
let obj = v.as_object().expect("object");
for p in PROVIDERS {
assert_eq!(obj[p.key], Value::Bool(false), "provider {}", p.key);
}
}
#[test]
fn detects_gmail_via_sid_cookie() {
let _lock = lock_env();
let tmp = TempDir::new().unwrap();
let db = tmp.path().join("Cookies");
make_cookies_db(&db, &[(".google.com", "SID")]);
std::env::set_var(COOKIES_DB_ENV, &db);
let v = detect_webview_logins();
assert_eq!(v["gmail"], Value::Bool(true));
assert_eq!(v["slack"], Value::Bool(false));
std::env::remove_var(COOKIES_DB_ENV);
}
#[test]
fn detects_slack_and_linkedin() {
let _lock = lock_env();
let tmp = TempDir::new().unwrap();
let db = tmp.path().join("Cookies");
make_cookies_db(
&db,
&[("workspace.slack.com", "d"), (".linkedin.com", "li_at")],
);
std::env::set_var(COOKIES_DB_ENV, &db);
let v = detect_webview_logins();
assert_eq!(v["slack"], Value::Bool(true));
assert_eq!(v["linkedin"], Value::Bool(true));
assert_eq!(v["gmail"], Value::Bool(false));
std::env::remove_var(COOKIES_DB_ENV);
}
/// Analytics cookies (NID) on google.com must not register as a
/// gmail login — only real session cookies count.
#[test]
fn ignores_non_session_cookies() {
let _lock = lock_env();
let tmp = TempDir::new().unwrap();
let db = tmp.path().join("Cookies");
make_cookies_db(&db, &[(".google.com", "NID"), (".google.com", "CONSENT")]);
std::env::set_var(COOKIES_DB_ENV, &db);
let v = detect_webview_logins();
assert_eq!(v["gmail"], Value::Bool(false));
std::env::remove_var(COOKIES_DB_ENV);
}
#[test]
fn empty_env_is_same_as_missing() {
let _lock = lock_env();
std::env::set_var(COOKIES_DB_ENV, "");
let v = detect_webview_logins();
assert_eq!(v["gmail"], Value::Bool(false));
std::env::remove_var(COOKIES_DB_ENV);
}
#[test]
fn nonexistent_path_returns_all_false() {
let _lock = lock_env();
std::env::set_var(COOKIES_DB_ENV, "/tmp/does-not-exist/Cookies");
let v = detect_webview_logins();
assert_eq!(v["gmail"], Value::Bool(false));
std::env::remove_var(COOKIES_DB_ENV);
}
#[test]
fn corrupt_db_returns_all_false() {
let _lock = lock_env();
let tmp = TempDir::new().unwrap();
let db = tmp.path().join("Cookies");
std::fs::write(&db, b"not a sqlite file").unwrap();
std::env::set_var(COOKIES_DB_ENV, &db);
let v = detect_webview_logins();
for p in PROVIDERS {
assert_eq!(v[p.key], Value::Bool(false));
}
std::env::remove_var(COOKIES_DB_ENV);
}
/// macOS users often have a space in their username
/// (`/Users/John Doe/...`); without percent-encoding, the SQLite
/// `file:` URI fails to parse and we'd silently report all-false.
#[test]
fn detects_cookies_when_path_contains_spaces() {
let _lock = lock_env();
let tmp = TempDir::new().unwrap();
let dir_with_space = tmp.path().join("dir with space");
std::fs::create_dir_all(&dir_with_space).unwrap();
let db = dir_with_space.join("Cookies");
make_cookies_db(&db, &[(".google.com", "SID")]);
std::env::set_var(COOKIES_DB_ENV, &db);
let v = detect_webview_logins();
assert_eq!(v["gmail"], Value::Bool(true));
std::env::remove_var(COOKIES_DB_ENV);
}
#[test]
fn sqlite_uri_path_encodes_reserved_chars() {
use std::path::Path;
// Spaces and percents inside the path get encoded; slashes
// remain literal so SQLite can parse the path component.
assert_eq!(
sqlite_uri_path(Path::new("/Users/John Doe/Cookies")),
"/Users/John%20Doe/Cookies"
);
assert_eq!(
sqlite_uri_path(Path::new("/tmp/100%off/Cookies")),
"/tmp/100%25off/Cookies"
);
}
#[test]
fn escape_like_escapes_metachars() {
assert_eq!(escape_like("ab_cd%ef\\gh"), "ab\\_cd\\%ef\\\\gh");
assert_eq!(escape_like("plain.host.com"), "plain.host.com");
}
}