Skip to content

Commit ee89f3c

Browse files
factorydroidechobt
authored andcommitted
fix(engine): validate auth before creating git snapshot
Fixes bounty issue #1652 Previously, the session would create a git snapshot before calling run_agent_loop(), but authentication was only validated when making the actual API request inside run_agent_loop(). This caused git snapshots to be created even when authentication would fail. This fix adds a validate_auth() method to the ModelClient trait that performs a lightweight auth check before creating any side effects. The session now calls validate_auth() before creating the git snapshot, ensuring early failure on invalid credentials.
1 parent cbc6f76 commit ee89f3c

File tree

6 files changed

+106
-7
lines changed

6 files changed

+106
-7
lines changed

cortex-engine/src/client/backend.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,17 @@ impl ModelClient for BackendClient {
241241
&self.capabilities
242242
}
243243

244+
async fn validate_auth(&self) -> Result<()> {
245+
// Use existing health_check to validate connectivity and auth
246+
match self.health_check().await {
247+
Ok(true) => Ok(()),
248+
Ok(false) => Err(CortexError::BackendUnavailable(
249+
"Backend health check failed".to_string(),
250+
)),
251+
Err(e) => Err(e),
252+
}
253+
}
254+
244255
async fn complete(&self, request: CompletionRequest) -> Result<ResponseStream> {
245256
let url = format!("{}/api/v1/completions/stream", self.base_url);
246257

cortex-engine/src/client/cortex.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,17 @@ impl ModelClient for CortexClient {
301301
&self.capabilities
302302
}
303303

304+
async fn validate_auth(&self) -> Result<()> {
305+
// Use existing health_check to validate connectivity and auth
306+
match self.health_check().await {
307+
Ok(true) => Ok(()),
308+
Ok(false) => Err(CortexError::BackendUnavailable(
309+
"Cortex backend health check failed".to_string(),
310+
)),
311+
Err(e) => Err(e),
312+
}
313+
}
314+
304315
async fn complete(&self, request: CompletionRequest) -> Result<ResponseStream> {
305316
let url = format!("{}/v1/responses", self.base_url);
306317
let body = self.build_request(&request);

cortex-engine/src/client/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ pub trait ModelClient: Send + Sync {
4848
/// Get model capabilities.
4949
fn capabilities(&self) -> &ModelCapabilities;
5050

51+
/// Validate authentication credentials before making requests.
52+
/// This allows checking auth early to avoid side effects on auth failure.
53+
/// Returns Ok(()) if auth is valid, or an error if authentication fails.
54+
async fn validate_auth(&self) -> Result<()>;
55+
5156
/// Send a completion request and get a stream of responses.
5257
async fn complete(&self, request: CompletionRequest) -> Result<ResponseStream>;
5358

cortex-engine/src/client/openai.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,31 @@ impl ModelClient for OpenAiClient {
108108
&self.capabilities
109109
}
110110

111+
async fn validate_auth(&self) -> Result<()> {
112+
// Make a lightweight models list request to validate the API key
113+
let response = self
114+
.client
115+
.get(format!("{}/models", self.base_url))
116+
.header("Authorization", format!("Bearer {}", self.api_key))
117+
.timeout(std::time::Duration::from_secs(10))
118+
.send()
119+
.await?;
120+
121+
if response.status().is_success() {
122+
Ok(())
123+
} else if response.status() == reqwest::StatusCode::UNAUTHORIZED {
124+
Err(CortexError::AuthenticationError {
125+
message: "Invalid API key".to_string(),
126+
})
127+
} else {
128+
let status = response.status();
129+
let body = response.text().await.unwrap_or_default();
130+
Err(CortexError::model(format!(
131+
"Auth validation failed {status}: {body}"
132+
)))
133+
}
134+
}
135+
111136
async fn complete(&self, request: CompletionRequest) -> Result<ResponseStream> {
112137
let mut req = self.build_request(&request);
113138
req.stream = Some(true);

cortex-engine/src/client/openai_compatible.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,37 @@ impl<T: OpenAICompatibleClient> ModelClient for T {
386386
OpenAICompatibleClient::capabilities(self)
387387
}
388388

389+
async fn validate_auth(&self) -> Result<()> {
390+
// Make a lightweight models list request to validate the API key
391+
let mut http_request = self
392+
.http_client()
393+
.get(format!("{}/models", self.base_url()))
394+
.header("Authorization", format!("Bearer {}", self.api_key()))
395+
.timeout(std::time::Duration::from_secs(10));
396+
397+
// Add any provider-specific headers
398+
for (key, value) in self.additional_headers() {
399+
http_request = http_request.header(&key, &value);
400+
}
401+
402+
let response = http_request.send().await?;
403+
404+
if response.status().is_success() {
405+
Ok(())
406+
} else if response.status() == reqwest::StatusCode::UNAUTHORIZED {
407+
Err(CortexError::AuthenticationError {
408+
message: format!("{} API key is invalid", self.provider_name()),
409+
})
410+
} else {
411+
let status = response.status();
412+
let body = response.text().await.unwrap_or_default();
413+
Err(CortexError::model(format!(
414+
"{} auth validation failed {status}: {body}",
415+
self.provider_name()
416+
)))
417+
}
418+
}
419+
389420
async fn complete(&self, request: CompletionRequest) -> Result<ResponseStream> {
390421
let mut req = build_openai_request(&request);
391422
req.stream = Some(true);

cortex-engine/src/session.rs

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ use crate::error::{CortexError, Result};
3030
use crate::rollout::reader::{RolloutItem, get_events, get_session_meta};
3131
use crate::rollout::recorder::SessionMeta;
3232
use crate::rollout::{RolloutRecorder, SESSIONS_SUBDIR, get_rollout_path, read_rollout};
33-
use crate::skills::{SkillsManager, render_skills_section, try_get_skills_manager};
33+
use crate::skills::{render_skills_section, try_get_skills_manager};
3434
use crate::summarization::SummarizationStrategy;
3535
use crate::tools::context::ToolOutputChunk;
3636
use crate::tools::{ToolContext, ToolRouter};
@@ -503,6 +503,22 @@ impl Session {
503503
}))
504504
.await;
505505

506+
// Validate authentication before creating any side effects (like git snapshots)
507+
// This ensures we fail early if credentials are invalid
508+
if let Err(e) = self.client.validate_auth().await {
509+
tracing::error!("Authentication validation failed: {}", e);
510+
self.emit(EventMsg::Error(ErrorEvent {
511+
message: format!("Authentication failed: {}", e),
512+
cortex_error_info: None,
513+
}))
514+
.await;
515+
self.emit(EventMsg::TaskComplete(TaskCompleteEvent {
516+
last_agent_message: None,
517+
}))
518+
.await;
519+
return Err(e);
520+
}
521+
506522
// Fast git-based snapshot (uses git write-tree, instant)
507523
// Only track if the cwd is a git repository (has .git directory)
508524
let is_git_repo = self.config.cwd.join(".git").exists()
@@ -1797,12 +1813,12 @@ fn build_system_prompt_with_skills(config: &Config, skills_section: Option<&str>
17971813
}
17981814

17991815
// Add skills section if provided
1800-
if let Some(skills) = skills_section {
1801-
if !skills.is_empty() {
1802-
additional.push_str("\n## Available Skills\n");
1803-
additional.push_str(skills);
1804-
additional.push('\n');
1805-
}
1816+
if let Some(skills) = skills_section
1817+
&& !skills.is_empty()
1818+
{
1819+
additional.push_str("\n## Available Skills\n");
1820+
additional.push_str(skills);
1821+
additional.push('\n');
18061822
}
18071823

18081824
prompt = prompt.replace("{{ADDITIONAL_CONTEXT}}", &additional);

0 commit comments

Comments
 (0)