Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions crates/lns-service/examples/approval_preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ fn seed() -> Snapshot {
host_value_available: true,
oauth_display_name: None,
token_fallback: None,
env_var: Some("OPENAI_API_KEY".into()),
injection_domains: vec!["api.openai.com".into()],
is_project_defined: false,
},
CredentialCardPrompt {
id: "cred-novalue".into(),
Expand All @@ -140,6 +143,9 @@ fn seed() -> Snapshot {
host_value_available: false,
oauth_display_name: None,
token_fallback: None,
env_var: Some("ANTHROPIC_API_KEY".into()),
injection_domains: vec!["api.anthropic.com".into()],
is_project_defined: true,
},
CredentialCardPrompt {
id: "cred-oauth".into(),
Expand All @@ -150,6 +156,9 @@ fn seed() -> Snapshot {
token_fallback: Some(TokenFallback {
help: Some("https://github.com/settings/personal-access-tokens/new".into()),
}),
env_var: None,
injection_domains: vec!["api.github.com".into(), "github.com".into()],
is_project_defined: false,
},
],
sign_ins: vec![SignInCard {
Expand All @@ -160,6 +169,9 @@ fn seed() -> Snapshot {
token_fallback: Some(TokenFallback {
help: Some("https://github.com/settings/personal-access-tokens/new".into()),
}),
env_var: None,
injection_domains: vec!["api.github.com".into(), "github.com".into()],
is_project_defined: false,
}],
informs: vec!["sign-in to GitHub failed: device code expired".into()],
connecting: vec!["OpenRouter".into()],
Expand Down
25 changes: 25 additions & 0 deletions crates/lns-service/src/approval_flow/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@ pub enum CredentialInjection {
},
}

impl CredentialInjection {
pub(crate) fn domain(&self) -> &str {
match self {
Self::Header { domain, .. } => domain,
Self::UriPlaceholder { domain, .. } => domain,
}
}
}
Comment thread
Copilot marked this conversation as resolved.

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CredentialPending {
Expand Down Expand Up @@ -349,4 +358,20 @@ mod tests {
let parsed: HostFrame = serde_json::from_str(&s).unwrap();
assert_eq!(frame, parsed);
}

#[test]
fn domain_accessor_returns_domain_for_both_injection_variants() {
let header = CredentialInjection::Header {
domain: "api.some-provider.example".into(),
header: "Authorization".into(),
value: "Bearer some-secret".into(),
};
assert_eq!(header.domain(), "api.some-provider.example");

let uri = CredentialInjection::UriPlaceholder {
domain: "api.some-provider.example".into(),
value: "some-secret".into(),
};
assert_eq!(uri.domain(), "api.some-provider.example");
}
}
19 changes: 19 additions & 0 deletions crates/lns-service/src/approval_flow/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ pub struct CredentialCardPrompt {
pub host_value_available: bool,
pub oauth_display_name: Option<String>,
pub token_fallback: Option<TokenFallback>,
pub env_var: Option<String>,
pub injection_domains: Vec<String>,
pub is_project_defined: bool,
}

/// An interactive sign-in card: which service, where to sign in, and — for a device flow — the code to type (`None` for a pkce browser redirect). `token_fallback` is `Some` when the integration lets a blocked user pivot to a pasted token.
Expand All @@ -50,6 +53,9 @@ pub struct SignInCard {
pub user_code: Option<String>,
pub verification_uri: String,
pub token_fallback: Option<TokenFallback>,
pub env_var: Option<String>,
pub injection_domains: Vec<String>,
pub is_project_defined: bool,
}

pub struct WindowState {
Expand Down Expand Up @@ -195,6 +201,9 @@ impl WindowState {
host_value_available,
oauth_display_name: prompt.oauth_display_name,
token_fallback: prompt.token_fallback,
env_var: prompt.env_var,
injection_domains: prompt.injection_domains,
is_project_defined: prompt.is_project_defined,
},
decision_tx,
seq,
Expand Down Expand Up @@ -484,6 +493,7 @@ pub const ACCENT_GREEN_HOVER: Color32 = Color32::from_rgb(0x6e, 0xe7, 0x9a);
pub const ACCENT_GREEN_PRESSED: Color32 = Color32::from_rgb(0x22, 0xc5, 0x5e);
pub const STATUS_CRITICAL: Color32 = Color32::from_rgb(0xf4, 0x71, 0x74);
pub const STATUS_WARNING: Color32 = Color32::from_rgb(0xff, 0xb1, 0x4a);
pub const TEXT_WARN: Color32 = STATUS_WARNING;
pub const CATEGORY: Color32 = Color32::from_rgb(0x3d, 0x90, 0xce);

pub fn lds_visuals() -> egui::Visuals {
Expand Down Expand Up @@ -614,6 +624,9 @@ mod tests {
action: format!("use of {credential_id} placeholder"),
oauth_display_name: None,
token_fallback: None,
env_var: None,
injection_domains: vec![],
is_project_defined: false,
}
}

Expand Down Expand Up @@ -1053,6 +1066,9 @@ mod tests {
user_code: Some("WXYZ-1234".into()),
verification_uri: "https://some-oauth.example/login/device".into(),
token_fallback: None,
env_var: None,
injection_domains: vec![],
is_project_defined: false,
}
}

Expand Down Expand Up @@ -1277,6 +1293,9 @@ mod tests {
action: "use of some-oauth placeholder".into(),
oauth_display_name: Some(name.into()),
token_fallback: None,
env_var: None,
injection_domains: vec![],
is_project_defined: false,
}
}

Expand Down
9 changes: 9 additions & 0 deletions crates/lns-service/src/credential_flow/notification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ impl CredentialNotifier for WindowCredentialNotifier {
user_code: prompt.user_code.clone(),
verification_uri: prompt.verification_uri.clone(),
token_fallback: prompt.token_fallback.clone(),
env_var: prompt.env_var.clone(),
injection_domains: prompt.injection_domains.clone(),
is_project_defined: prompt.is_project_defined,
},
cancel,
);
Expand Down Expand Up @@ -132,6 +135,9 @@ mod tests {
action: format!("use of {credential_id} placeholder"),
oauth_display_name: None,
token_fallback: None,
env_var: None,
injection_domains: vec![],
is_project_defined: false,
}
}

Expand Down Expand Up @@ -249,6 +255,9 @@ mod tests {
token_fallback: Some(lns_policy::integrations::TokenFallback {
help: Some("https://example.com/pat".into()),
}),
env_var: None,
injection_domains: vec![],
is_project_defined: false,
}
}

Expand Down
48 changes: 47 additions & 1 deletion crates/lns-service/src/credential_flow/session.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! The credential-rule source of truth lives in `~/.lns-credentials.json`, not `lns-policy.yaml`.

use std::collections::{HashMap, HashSet};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

Expand Down Expand Up @@ -39,6 +39,9 @@ pub struct CredentialPendingPrompt {
pub oauth_display_name: Option<String>,
/// Some when the integration declares a token fallback, so the consent card can also reveal "use a token instead".
pub token_fallback: Option<TokenFallback>,
pub env_var: Option<String>,
pub injection_domains: Vec<String>,
pub is_project_defined: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
Expand All @@ -50,6 +53,9 @@ pub struct SignInPrompt {
pub verification_uri: String,
/// Some when the integration declares a token fallback, so the sign-in card can offer "use a token instead" to a user blocked from the browser dance.
pub token_fallback: Option<TokenFallback>,
pub env_var: Option<String>,
pub injection_domains: Vec<String>,
pub is_project_defined: bool,
}

/// Abstracts the desktop notification surface so tests can drive prompts without the real system.
Expand Down Expand Up @@ -112,6 +118,7 @@ pub struct CredentialSession {
policy_emitter: PolicyEmitter,
timeout: Duration,
custom_providers: Arc<Vec<DefProvider>>,
bundled_ids: HashSet<String>,
connectable: HashSet<String>,
connect: ConnectEmitter,
oauth_configs: HashMap<String, crate::oauth::OauthConfig>,
Expand Down Expand Up @@ -155,6 +162,7 @@ impl CredentialSession {
policy_emitter,
timeout,
custom_providers: Arc::new(Vec::new()),
bundled_ids: HashSet::new(),
connectable: HashSet::new(),
connect: Box::new(|_| {}),
oauth_configs: HashMap::new(),
Expand Down Expand Up @@ -234,6 +242,11 @@ impl CredentialSession {
self
}

pub fn with_bundled_ids(mut self, bundled_ids: HashSet<String>) -> Self {
self.bundled_ids = bundled_ids;
self
}

/// Marks the catalog integrations that aren't connected yet: detecting one offers to connect, and accepting runs `connect` to allow its routes live.
pub fn with_connect_emitter(
mut self,
Expand Down Expand Up @@ -290,12 +303,17 @@ impl CredentialSession {
} else {
req.action
};
let (env_var, injection_domains, is_project_defined) =
self.provider_disclosure(&req.credential_id);
self.notifier.present(&CredentialPendingPrompt {
id: req.id,
credential_id: req.credential_id,
action,
token_fallback,
oauth_display_name,
env_var,
injection_domains,
is_project_defined,
});
}

Expand All @@ -309,6 +327,24 @@ impl CredentialSession {
)
}

fn provider_disclosure(&self, credential_id: &str) -> (Option<String>, Vec<String>, bool) {
self.custom_providers
.iter()
.find(|p| p.id() == credential_id)
.map(|p| {
let domains: Vec<String> = p
.unarmed_injections()
.iter()
.map(|inj| inj.domain().to_string())
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let is_project = !self.bundled_ids.contains(p.id());
(Some(p.env_var().to_string()), domains, is_project)
})
.unwrap_or((None, Vec::new(), false))
}

/// True when `credential_id` already holds a usable value and injects into the host named in `action`, so a gate for it is a propagation race the host can safely allow rather than re-prompt. A request to a host the credential does not inject into (a real leak attempt) returns false and still prompts.
fn is_armed_for_request(&self, credential_id: &str, action: &str) -> bool {
if !self.has_armed_value(credential_id) {
Expand Down Expand Up @@ -455,6 +491,8 @@ impl CredentialSession {
) -> bool {
let display_name = self.display_name_for(credential_id);
let token_fallback = self.token_fallbacks.get(credential_id).cloned();
let (env_var, injection_domains, is_project_defined) =
self.provider_disclosure(credential_id);
let challenge = (self.pkce_challenge_gen)();
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<crate::oauth::SignInPivot>();
let card_id = credential_id.to_string();
Expand All @@ -466,6 +504,9 @@ impl CredentialSession {
user_code: None,
verification_uri: auth_url.to_string(),
token_fallback,
env_var,
injection_domains,
is_project_defined,
},
cancel_tx,
);
Expand Down Expand Up @@ -513,6 +554,8 @@ impl CredentialSession {
};
let display_name = self.display_name_for(credential_id);
let token_fallback = self.token_fallbacks.get(credential_id).cloned();
let (env_var, injection_domains, is_project_defined) =
self.provider_disclosure(credential_id);
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<crate::oauth::SignInPivot>();
let card_id = credential_id.to_string();
let present = move |code: &crate::oauth::DeviceCode| {
Expand All @@ -523,6 +566,9 @@ impl CredentialSession {
user_code: Some(code.user_code.clone()),
verification_uri: code.verification_uri.clone(),
token_fallback,
env_var,
injection_domains,
is_project_defined,
},
cancel_tx,
);
Expand Down
6 changes: 6 additions & 0 deletions crates/lns-service/src/credential_flow/watcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ mod tests {
action: "use".into(),
oauth_display_name: None,
token_fallback: None,
env_var: None,
injection_domains: vec![],
is_project_defined: false,
});
let (cancel_tx, _cancel_rx) = tokio::sync::oneshot::channel();
n.present_sign_in(
Expand All @@ -157,6 +160,9 @@ mod tests {
user_code: Some("WXYZ-1234".into()),
verification_uri: "https://example.com/device".into(),
token_fallback: None,
env_var: None,
injection_domains: vec![],
is_project_defined: false,
},
cancel_tx,
);
Expand Down
6 changes: 6 additions & 0 deletions crates/lns-service/src/supervisor/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,12 @@ async fn start_credential_subsystem(
policy_emitter,
)
.with_custom_providers(custom_providers)
.with_bundled_ids(
lns_policy::integrations::bundled_integrations()
.iter()
.map(|i| i.id.clone())
.collect(),
)
.with_connect_emitter(connectable_ids, connect_emitter)
.with_oauth(
oauth.configs,
Expand Down
29 changes: 29 additions & 0 deletions crates/lns-service/src/tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1365,6 +1365,32 @@ fn render_credential_card(
.monospace()
.color(window::TEXT_MUTED),
);
if let Some(env) = &prompt.env_var {
ui.add_space(4.0);
ui.label(
RichText::new(format!("Reads host env: ${env}"))
.size(theme::FONT_CAPTION)
.color(window::TEXT_MUTED),
);
}
if !prompt.injection_domains.is_empty() {
ui.add_space(2.0);
let domains = prompt.injection_domains.join(", ");
ui.label(
RichText::new(format!("Sends to: {domains}"))
.size(theme::FONT_CAPTION)
.color(window::TEXT_MUTED),
);
}
if prompt.is_project_defined {
ui.add_space(4.0);
ui.label(
RichText::new("Project-defined provider (not built-in)")
.size(theme::FONT_CAPTION)
.strong()
.color(window::TEXT_WARN),
);
}
if !prompt.host_value_available {
ui.add_space(8.0);
ui.colored_label(
Expand Down Expand Up @@ -1841,6 +1867,9 @@ mod tests {
host_value_available: false,
oauth_display_name: None,
token_fallback: None,
env_var: None,
injection_domains: vec![],
is_project_defined: false,
}
}

Expand Down
Loading