Skip to content

Commit 84f9584

Browse files
In-app auth secret creation flow. (#10420)
## Description <!-- Please remember to add your design buddy onto the PR for review, if it contains any UI changes! --> Completes REMOTE-1613. ## Screenshots / Videos <!-- Attach screenshots or a short video demonstrating the change, where appropriate. Remove this section if it is not relevant to your PR. --> See looms: - https://www.loom.com/share/034b88e4b4a940a4895793ad188accdc - https://www.loom.com/share/5e73fb892deb488abaffaededc4d5d90 ## Testing <!-- How did you test this change? What automated tests did you add? If you didn't add any new tests, what's your justification for not adding any? --> Tested locally that things WAI! ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode duhhhh.
1 parent 9d2296d commit 84f9584

20 files changed

Lines changed: 2875 additions & 11 deletions

File tree

app/src/ai/auth_secret_types.rs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
use anyhow::{anyhow, Result};
2+
use warp_cli::agent::Harness;
3+
use warp_graphql::managed_secrets::ManagedSecretType;
4+
use warp_managed_secrets::ManagedSecretValue;
5+
6+
pub struct AuthSecretTypeField {
7+
pub label: &'static str,
8+
pub optional: bool,
9+
}
10+
11+
pub struct AuthSecretTypeInfo {
12+
pub display_name: &'static str,
13+
pub secret_type: ManagedSecretType,
14+
pub fields: &'static [AuthSecretTypeField],
15+
}
16+
17+
pub fn auth_secret_types_for_harness(harness: Harness) -> &'static [AuthSecretTypeInfo] {
18+
match harness {
19+
Harness::Claude => &CLAUDE_AUTH_SECRET_TYPES,
20+
_ => &[],
21+
}
22+
}
23+
24+
pub fn build_managed_secret_value(
25+
info: &AuthSecretTypeInfo,
26+
field_values: &[String],
27+
) -> Result<ManagedSecretValue> {
28+
if field_values.len() != info.fields.len() {
29+
return Err(anyhow!(
30+
"Expected {} field values, got {}",
31+
info.fields.len(),
32+
field_values.len()
33+
));
34+
}
35+
for (field, value) in info.fields.iter().zip(field_values.iter()) {
36+
if !field.optional && value.trim().is_empty() {
37+
return Err(anyhow!("Field '{}' is required", field.label));
38+
}
39+
}
40+
match info.secret_type {
41+
ManagedSecretType::AnthropicApiKey => Ok(ManagedSecretValue::anthropic_api_key(
42+
field_values[0].clone(),
43+
)),
44+
ManagedSecretType::AnthropicBedrockApiKey => {
45+
Ok(ManagedSecretValue::anthropic_bedrock_api_key(
46+
field_values[0].clone(),
47+
field_values[1].clone(),
48+
))
49+
}
50+
ManagedSecretType::AnthropicBedrockAccessKey => {
51+
let session_token = if field_values[2].trim().is_empty() {
52+
None
53+
} else {
54+
Some(field_values[2].clone())
55+
};
56+
Ok(ManagedSecretValue::anthropic_bedrock_access_key(
57+
field_values[0].clone(),
58+
field_values[1].clone(),
59+
session_token,
60+
field_values[3].clone(),
61+
))
62+
}
63+
ManagedSecretType::RawValue | ManagedSecretType::Dotenvx => Err(anyhow!(
64+
"Auth secret type {:?} is not supported via the harness FTUX flow",
65+
info.secret_type
66+
)),
67+
}
68+
}
69+
70+
static CLAUDE_AUTH_SECRET_TYPES: [AuthSecretTypeInfo; 3] = [
71+
AuthSecretTypeInfo {
72+
display_name: "Anthropic API Key",
73+
secret_type: ManagedSecretType::AnthropicApiKey,
74+
fields: &[AuthSecretTypeField {
75+
label: "ANTHROPIC_API_KEY",
76+
optional: false,
77+
}],
78+
},
79+
AuthSecretTypeInfo {
80+
display_name: "Bedrock API Key",
81+
secret_type: ManagedSecretType::AnthropicBedrockApiKey,
82+
fields: &[
83+
AuthSecretTypeField {
84+
label: "AWS_BEARER_TOKEN_BEDROCK",
85+
optional: false,
86+
},
87+
AuthSecretTypeField {
88+
label: "AWS_REGION",
89+
optional: false,
90+
},
91+
],
92+
},
93+
AuthSecretTypeInfo {
94+
display_name: "Bedrock Access Key",
95+
secret_type: ManagedSecretType::AnthropicBedrockAccessKey,
96+
fields: &[
97+
AuthSecretTypeField {
98+
label: "AWS_ACCESS_KEY_ID",
99+
optional: false,
100+
},
101+
AuthSecretTypeField {
102+
label: "AWS_SECRET_ACCESS_KEY",
103+
optional: false,
104+
},
105+
AuthSecretTypeField {
106+
label: "AWS_SESSION_TOKEN",
107+
optional: true,
108+
},
109+
AuthSecretTypeField {
110+
label: "AWS_REGION",
111+
optional: false,
112+
},
113+
],
114+
},
115+
];

app/src/ai/cloud_agent_settings.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
//! This module contains user-specific settings for cloud agent features,
44
//! such as remembering the last selected environment.
55
6-
use settings::{macros::define_settings_group, SupportedPlatforms, SyncToCloud};
6+
use std::collections::HashMap;
7+
8+
use settings::{macros::define_settings_group, Setting as _, SupportedPlatforms, SyncToCloud};
9+
use warp_cli::agent::Harness;
710

811
use crate::server::ids::SyncId;
912

@@ -14,5 +17,32 @@ define_settings_group!(CloudAgentSettings, settings: [
1417
supported_platforms: SupportedPlatforms::ALL,
1518
sync_to_cloud: SyncToCloud::Never,
1619
private: true,
20+
},
21+
harness_auth_ftux_completed: HarnessAuthFtuxCompleted {
22+
type: HashMap<String, bool>,
23+
default: HashMap::new(),
24+
supported_platforms: SupportedPlatforms::ALL,
25+
sync_to_cloud: SyncToCloud::Never,
26+
private: true,
1727
}
1828
]);
29+
30+
impl CloudAgentSettings {
31+
pub fn is_harness_auth_ftux_completed(&self, harness: Harness) -> bool {
32+
self.harness_auth_ftux_completed
33+
.value()
34+
.get(harness.config_name())
35+
.copied()
36+
.unwrap_or(false)
37+
}
38+
39+
pub fn mark_harness_auth_ftux_completed(
40+
&mut self,
41+
harness: Harness,
42+
ctx: &mut warpui::ModelContext<Self>,
43+
) {
44+
let mut map = self.harness_auth_ftux_completed.value().clone();
45+
map.insert(harness.config_name().to_string(), true);
46+
let _ = self.harness_auth_ftux_completed.set_value(map, ctx);
47+
}
48+
}

app/src/ai/harness_availability.rs

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
use std::collections::HashMap;
2+
13
use serde::{Deserialize, Serialize};
24
use warp_cli::agent::Harness;
35
use warp_core::features::FeatureFlag;
46
use warp_core::user_preferences::GetUserPreferences;
7+
use warp_managed_secrets::{client::SecretOwner, ManagedSecretManager, ManagedSecretValue};
58
use warpui::{Entity, ModelContext, SingletonEntity};
69

710
use crate::ai::harness_display;
@@ -41,12 +44,29 @@ fn default_harnesses() -> Vec<HarnessAvailability> {
4144
}]
4245
}
4346

47+
#[derive(Debug, Clone)]
48+
pub enum AuthSecretFetchState {
49+
NotFetched,
50+
Loading,
51+
Loaded(Vec<AuthSecretEntry>),
52+
Failed(#[allow(dead_code)] String),
53+
}
54+
55+
#[derive(Debug, Clone)]
56+
pub struct AuthSecretEntry {
57+
pub name: String,
58+
}
59+
4460
pub enum HarnessAvailabilityEvent {
4561
Changed,
62+
AuthSecretsLoaded,
63+
AuthSecretCreated { harness: Harness, name: String },
64+
AuthSecretCreationFailed { error: String },
4665
}
4766

4867
pub struct HarnessAvailabilityModel {
4968
harnesses: Vec<HarnessAvailability>,
69+
auth_secrets: HashMap<Harness, AuthSecretFetchState>,
5070
}
5171

5272
impl HarnessAvailabilityModel {
@@ -64,6 +84,10 @@ impl HarnessAvailabilityModel {
6484

6585
ctx.subscribe_to_model(&AuthManager::handle(ctx), |me, event, ctx| {
6686
if let AuthManagerEvent::AuthComplete = event {
87+
let cached_harnesses: Vec<Harness> = me.auth_secrets.keys().copied().collect();
88+
for harness in cached_harnesses {
89+
me.invalidate_auth_secrets(harness);
90+
}
6791
me.refresh(ctx);
6892
}
6993
});
@@ -74,7 +98,10 @@ impl HarnessAvailabilityModel {
7498
}
7599
});
76100

77-
let me = Self { harnesses };
101+
let me = Self {
102+
harnesses,
103+
auth_secrets: HashMap::new(),
104+
};
78105
me.refresh(ctx);
79106
me
80107
}
@@ -116,6 +143,101 @@ impl HarnessAvailabilityModel {
116143
.filter(|m| !m.is_empty())
117144
}
118145

146+
pub fn auth_secrets_for(&self, harness: Harness) -> &AuthSecretFetchState {
147+
self.auth_secrets
148+
.get(&harness)
149+
.unwrap_or(&AuthSecretFetchState::NotFetched)
150+
}
151+
152+
pub fn ensure_auth_secrets_fetched(&mut self, harness: Harness, ctx: &mut ModelContext<Self>) {
153+
if matches!(
154+
self.auth_secrets_for(harness),
155+
AuthSecretFetchState::NotFetched | AuthSecretFetchState::Failed(_)
156+
) {
157+
self.fetch_auth_secrets(harness, ctx);
158+
}
159+
}
160+
161+
fn fetch_auth_secrets(&mut self, harness: Harness, ctx: &mut ModelContext<Self>) {
162+
let Some(agent_harness) = harness_to_graphql_harness(harness) else {
163+
return;
164+
};
165+
166+
if !AuthStateProvider::as_ref(ctx).get().is_logged_in() {
167+
return;
168+
}
169+
170+
self.auth_secrets
171+
.insert(harness, AuthSecretFetchState::Loading);
172+
173+
let api = ServerApiProvider::as_ref(ctx).get_managed_secrets_client();
174+
ctx.spawn(
175+
async move { api.list_harness_auth_secrets(agent_harness).await },
176+
move |me, result: Result<Vec<warp_graphql::managed_secrets::ManagedSecret>, _>, ctx| {
177+
match result {
178+
Ok(secrets) => {
179+
let entries = secrets
180+
.into_iter()
181+
.map(|s| AuthSecretEntry { name: s.name })
182+
.collect();
183+
me.auth_secrets
184+
.insert(harness, AuthSecretFetchState::Loaded(entries));
185+
ctx.emit(HarnessAvailabilityEvent::AuthSecretsLoaded);
186+
}
187+
Err(e) => {
188+
let msg = e.to_string();
189+
report_error!(e.context("Failed to fetch harness auth secrets"));
190+
me.auth_secrets
191+
.insert(harness, AuthSecretFetchState::Failed(msg));
192+
}
193+
}
194+
},
195+
);
196+
}
197+
198+
pub fn invalidate_auth_secrets(&mut self, harness: Harness) {
199+
self.auth_secrets.remove(&harness);
200+
}
201+
202+
pub fn create_auth_secret(
203+
&mut self,
204+
harness: Harness,
205+
name: String,
206+
value: ManagedSecretValue,
207+
ctx: &mut ModelContext<Self>,
208+
) {
209+
let manager = ManagedSecretManager::handle(ctx);
210+
let create_future =
211+
manager
212+
.as_ref(ctx)
213+
.create_secret(SecretOwner::CurrentUser, name, value, None);
214+
ctx.spawn(create_future, move |me, result, ctx| match result {
215+
Ok(secret) => {
216+
let entry = AuthSecretEntry {
217+
name: secret.name.clone(),
218+
};
219+
match me.auth_secrets.get_mut(&harness) {
220+
Some(AuthSecretFetchState::Loaded(entries)) => {
221+
entries.push(entry);
222+
}
223+
_ => {
224+
me.auth_secrets
225+
.insert(harness, AuthSecretFetchState::Loaded(vec![entry]));
226+
}
227+
}
228+
ctx.emit(HarnessAvailabilityEvent::AuthSecretCreated {
229+
harness,
230+
name: secret.name,
231+
});
232+
}
233+
Err(e) => {
234+
let msg = e.to_string();
235+
report_error!(e.context("Failed to create harness auth secret"));
236+
ctx.emit(HarnessAvailabilityEvent::AuthSecretCreationFailed { error: msg });
237+
}
238+
});
239+
}
240+
119241
pub fn refresh(&self, ctx: &mut ModelContext<Self>) {
120242
// The endpoint queries `user`, which requires auth.
121243
if !AuthStateProvider::as_ref(ctx).get().is_logged_in() {
@@ -130,6 +252,11 @@ impl HarnessAvailabilityModel {
130252
if new_harnesses != me.harnesses {
131253
me.harnesses = new_harnesses;
132254
me.cache(ctx);
255+
// Invalidate cached auth secrets so the next menu open refetches.
256+
let stale: Vec<Harness> = me.auth_secrets.keys().copied().collect();
257+
for harness in stale {
258+
me.invalidate_auth_secrets(harness);
259+
}
133260
ctx.emit(HarnessAvailabilityEvent::Changed);
134261
}
135262
}
@@ -160,6 +287,16 @@ fn get_cached(ctx: &ModelContext<HarnessAvailabilityModel>) -> Option<Vec<Harnes
160287
serde_json::from_str::<Vec<HarnessAvailability>>(&raw).ok()
161288
}
162289

290+
fn harness_to_graphql_harness(harness: Harness) -> Option<warp_graphql::ai::AgentHarness> {
291+
match harness {
292+
Harness::Oz => Some(warp_graphql::ai::AgentHarness::Oz),
293+
Harness::Claude => Some(warp_graphql::ai::AgentHarness::ClaudeCode),
294+
Harness::Gemini => Some(warp_graphql::ai::AgentHarness::Gemini),
295+
Harness::Codex => Some(warp_graphql::ai::AgentHarness::Codex),
296+
Harness::OpenCode | Harness::Unknown => None,
297+
}
298+
}
299+
163300
impl Entity for HarnessAvailabilityModel {
164301
type Event = HarnessAvailabilityEvent;
165302
}

app/src/ai/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub mod ambient_agents;
1313
pub(crate) mod artifact_download;
1414
pub mod artifacts;
1515
pub(crate) mod attachment_utils;
16+
pub mod auth_secret_types;
1617
#[cfg(not(target_family = "wasm"))]
1718
pub mod aws_credentials;
1819
pub(crate) mod block_context;

0 commit comments

Comments
 (0)