Skip to main content

systemprompt_cli/session/resolution/
mod.rs

1//! Session resolution: pick a profile and produce an authenticated session.
2//!
3//! [`get_or_create_session`] is the entry point. It resolves the active
4//! profile (CLI override, `SYSTEMPROMPT_PROFILE`, the stored active key, or
5//! bootstrap), reuses a valid cached session when present, and otherwise mints
6//! a new local or tenant session. The [`helpers`] submodule holds the
7//! per-strategy resolution steps.
8//!
9//! A freshly minted session is stored under its key, but only a profile
10//! chosen from the stored session may become the active one: an explicit
11//! `--profile` or `SYSTEMPROMPT_PROFILE` override, or a discovered profile,
12//! is a one-shot target and must not leave the next bare invocation pointed
13//! at it. `admin session switch|login` remain the ways to change the active
14//! profile.
15//!
16//! Copyright (c) systemprompt.io — Business Source License 1.1.
17//! See <https://systemprompt.io> for licensing details.
18
19pub mod helpers;
20
21use std::path::{Path, PathBuf};
22
23use anyhow::{Context, Result};
24use systemprompt_cloud::{SessionKey, SessionStore};
25use systemprompt_config::{ProfileBootstrap, SecretsBootstrap};
26use systemprompt_loader::ProfileLoader;
27use systemprompt_logging::CliService;
28use systemprompt_models::Profile;
29
30use super::context::CliSessionContext;
31use crate::cli_settings::{OutputFormat, VerbosityLevel};
32use crate::context::CommandContext;
33use crate::paths::ResolvedPaths;
34use crate::shared::ProfileSource;
35use helpers::{
36    create_new_session, extract_profile_name, initialize_profile_bootstraps,
37    resolve_profile_path_from_session, resolve_profile_path_without_session, try_session_from_env,
38    try_validate_context,
39};
40
41#[derive(Debug)]
42pub struct ProfileContext<'a> {
43    pub name: &'a str,
44    pub path: PathBuf,
45}
46
47async fn get_session_for_profile(
48    profile_input: &str,
49    ctx: &CommandContext,
50) -> Result<CliSessionContext> {
51    let (profile_path, profile) = crate::shared::resolve_profile_with_data(profile_input)
52        .map_err(|e| anyhow::anyhow!("{}", e))?;
53
54    if !ProfileBootstrap::is_initialized() {
55        ProfileBootstrap::init_from_path(&profile_path)
56            .with_context(|| format!("Failed to initialize profile '{}'", profile_input))?;
57    }
58
59    if !SecretsBootstrap::is_initialized() {
60        SecretsBootstrap::try_init().await.with_context(
61            || "Failed to initialize secrets. Check your profile's secrets configuration.",
62        )?;
63    }
64
65    get_session_for_loaded_profile(&profile, &profile_path, ProfileSource::Cli, ctx).await
66}
67
68async fn get_session_for_loaded_profile(
69    profile: &Profile,
70    profile_path: &Path,
71    source: ProfileSource,
72    ctx: &CommandContext,
73) -> Result<CliSessionContext> {
74    if let Some(session_ctx) = try_session_from_env(profile, &ctx.env) {
75        return Ok(session_ctx);
76    }
77
78    let profile_name = extract_profile_name(profile_path)?;
79    let tenant_id = profile.cloud.as_ref().and_then(|c| c.tenant_id.as_ref());
80    let session_key = SessionKey::from_tenant_id(tenant_id);
81    let sessions_dir = ResolvedPaths::discover().sessions_dir();
82    let mut store = SessionStore::load_or_create(&sessions_dir)?;
83
84    if let Some(mut session) = store
85        .get_valid_session(&session_key, &profile.security.issuer)
86        .cloned()
87    {
88        session.touch();
89
90        if let Some(refreshed) = try_validate_context(&mut session, &profile_name).await {
91            session = refreshed;
92        }
93
94        store.upsert_session(&session_key, session.clone());
95        store.save(&sessions_dir)?;
96        return Ok(CliSessionContext {
97            session,
98            profile: profile.clone(),
99        });
100    }
101
102    let session_email_hint = store
103        .get_session(&session_key)
104        .map(|s| s.user_email.to_string());
105
106    let profile_ctx = ProfileContext {
107        name: &profile_name,
108        path: profile_path.to_path_buf(),
109    };
110
111    let session = create_new_session(
112        profile,
113        &profile_ctx,
114        &session_key,
115        &ctx.cli,
116        session_email_hint.as_deref(),
117    )
118    .await?;
119
120    record_new_session(&mut store, &session_key, &session, &profile_name, source);
121    store.save(&sessions_dir)?;
122
123    if session.session_token.as_str().is_empty() {
124        anyhow::bail!("Session token is empty. Session creation failed.");
125    }
126
127    Ok(CliSessionContext {
128        session,
129        profile: profile.clone(),
130    })
131}
132
133pub fn record_new_session(
134    store: &mut SessionStore,
135    session_key: &SessionKey,
136    session: &systemprompt_cloud::CliSession,
137    profile_name: &str,
138    source: ProfileSource,
139) {
140    store.upsert_session(session_key, session.clone());
141    if source == ProfileSource::Session {
142        store.set_active_with_profile(session_key, profile_name);
143    }
144}
145
146async fn try_session_from_active_key(ctx: &CommandContext) -> Result<Option<CliSessionContext>> {
147    let paths = ResolvedPaths::discover();
148    let sessions_dir = paths.sessions_dir();
149    let store = SessionStore::load_or_create(&sessions_dir)?;
150
151    let Some(ref active_key_str) = store.active_key else {
152        return Ok(None);
153    };
154
155    let active_key = store
156        .active_session_key()
157        .ok_or_else(|| anyhow::anyhow!("Invalid active session key: {}", active_key_str))?;
158
159    let active_profile = store.active_profile_name.as_deref();
160
161    let profile_path = if let Some(session) = store.active_session_for_profile_discovery() {
162        match resolve_profile_path_from_session(session, active_profile)? {
163            Some(path) => path,
164            None => return Ok(None),
165        }
166    } else {
167        resolve_profile_path_without_session(&paths, &store, &active_key, active_profile)?
168    };
169
170    let profile = ProfileLoader::load_from_path(&profile_path).with_context(|| {
171        format!(
172            "Failed to load profile from stored path: {}",
173            profile_path.display()
174        )
175    })?;
176
177    initialize_profile_bootstraps(&profile_path).await?;
178
179    let session_ctx =
180        get_session_for_loaded_profile(&profile, &profile_path, ProfileSource::Session, ctx)
181            .await?;
182    Ok(Some(session_ctx))
183}
184
185pub async fn get_or_create_session(ctx: &CommandContext) -> Result<CliSessionContext> {
186    let session_ctx = resolve_session(ctx).await?;
187
188    let config = &ctx.cli;
189    let banner_requested = config.verbosity >= VerbosityLevel::Verbose;
190    let banner_warranted = session_ctx.profile.target.is_cloud();
191    if config.is_interactive()
192        && config.output_format == OutputFormat::Table
193        && config.verbosity != VerbosityLevel::Quiet
194        && (banner_requested || banner_warranted)
195    {
196        let tenant = session_ctx
197            .session
198            .tenant_key
199            .as_ref()
200            .map_or("local", systemprompt_identifiers::TenantId::as_str);
201        CliService::session_context_with_url(
202            session_ctx.session.profile_name.as_str(),
203            &session_ctx.session.session_id,
204            Some(tenant),
205            Some(&session_ctx.profile.server.api_external_url),
206        );
207    }
208
209    Ok(session_ctx)
210}
211
212async fn resolve_session(ctx: &CommandContext) -> Result<CliSessionContext> {
213    if let Some(ref profile_name) = ctx.cli.profile_override {
214        return get_session_for_profile(profile_name, ctx).await;
215    }
216
217    if ctx.env.profile.is_none()
218        && let Some(session_ctx) = try_session_from_active_key(ctx).await?
219    {
220        return Ok(session_ctx);
221    }
222
223    let profile = ProfileBootstrap::get()
224        .map_err(|_e| {
225            anyhow::anyhow!(
226                "Profile required.\n\nSet SYSTEMPROMPT_PROFILE environment variable to your \
227                 profile.yaml path, or use --profile <name>."
228            )
229        })?
230        .clone();
231
232    let profile_path_str = ProfileBootstrap::get_path().map_err(|_e| {
233        anyhow::anyhow!(
234            "Profile path required.\n\nSet SYSTEMPROMPT_PROFILE environment variable or use \
235             --profile <name>."
236        )
237    })?;
238
239    let source = if ctx.env.profile.is_some() {
240        ProfileSource::Env
241    } else {
242        ProfileSource::Discovery
243    };
244    let profile_path = Path::new(profile_path_str);
245    get_session_for_loaded_profile(&profile, profile_path, source, ctx).await
246}