save progress

This commit is contained in:
taggart_comet 2026-02-13 19:56:43 +02:00
parent 95b32ed25c
commit a86ad6e41c
55 changed files with 2763 additions and 818 deletions

531
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -66,6 +66,12 @@ pulldown-cmark = "0.10"
arboard = "3.6"
base64 = "0.22"
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
oauth2 = "4.4"
tiny_http = "0.12"
open = "5.0"
sha2 = "0.10"
url = "2.5"
rand = "0.8"
[dev-dependencies]

View file

@ -14,7 +14,7 @@ class Cat:
def pet(self) -> str:
if self.mood == "happy":
return f"{self.name} purrs loudly."
return f"{self.name} tolerates the petting."
return f"{self.name} tolerates the petting with a calm blink."
def begs(self) -> str:
self.mood = "needy"

View file

@ -5,20 +5,20 @@ mod project;
pub mod prompting;
pub mod session;
pub mod startup;
pub mod todo;
pub mod tools;
mod user_settings;
pub mod workflow;
pub mod todo;
pub use agent_mode::AgentModeType;
pub use project::Project;
pub use session::service::SessionService;
pub use session::{Session, SessionRequest};
pub use startup::StartupService;
pub use user_settings::UserSettings;
pub use workflow::{CancellationToken, Chain};
#[allow(unused_imports)]
pub use todo::{TodoList, TodoItem};
pub use todo::{TodoItem, TodoList};
pub use user_settings::{AuthMethod, UserSettings};
pub use workflow::{CancellationToken, Chain};
/// Model type enum matching the inference engine types
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -27,6 +27,13 @@ pub enum ModelType {
OpenAI,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelAuthType {
Local,
ApiKey,
OAuth,
}
impl ModelType {
pub fn as_str(&self) -> &'static str {
match self {
@ -42,4 +49,29 @@ impl ModelType {
_ => None,
}
}
}
}
impl ModelAuthType {
pub fn as_str(&self) -> &'static str {
match self {
ModelAuthType::Local => "local",
ModelAuthType::ApiKey => "api_key",
ModelAuthType::OAuth => "oauth",
}
}
pub fn from_str(value: &str) -> Self {
match value {
"local" => ModelAuthType::Local,
"oauth" => ModelAuthType::OAuth,
_ => ModelAuthType::ApiKey,
}
}
pub fn from_auth_method(method: &AuthMethod) -> Self {
match method {
AuthMethod::ApiKey => ModelAuthType::ApiKey,
AuthMethod::OAuth => ModelAuthType::OAuth,
}
}
}

View file

@ -1,8 +1,10 @@
use super::store::{PermissionStore, StoreError};
use super::types::{PermissionConfig, PermissionDecision, PermissionRequest, PermissionScope};
use super::types::{
PermissionConfig, PermissionRequest, PermissionScope, SystemPermissionDecision,
UserPermissionDecision,
};
use crate::domain::session::Request;
use crate::domain::tools::Tool;
use crate::utils::paths::is_within_root;
use crate::utils::AskError;
use std::path::PathBuf;
use std::sync::Arc;
@ -23,7 +25,10 @@ pub struct PermissionChecker {
}
pub trait PermissionPrompter: Send + Sync {
fn ask_permission(&self, request: &PermissionRequest) -> Result<PermissionDecision, AskError>;
fn ask_permission(
&self,
request: &PermissionRequest,
) -> Result<UserPermissionDecision, AskError>;
}
impl PermissionChecker {
@ -46,60 +51,68 @@ impl PermissionChecker {
request: &dyn Request,
project_id: Option<i32>,
) -> Result<bool, CheckerError> {
// Auto-allow read-only operations within project root
if tool.is_read_only() {
let all_within_project = tool.get_affected_paths(request)
.iter()
.all(|p| p.starts_with(&request.project_root().to_path_buf()));
if all_within_project {
return Ok(true);
}
}
let permission_request = PermissionRequest::new(
tool.name().to_string(),
tool.get_command(request),
tool.get_affected_paths(request),
super::types::PermissionScope::Project,
PermissionScope::Project,
project_id,
tool.is_read_only(),
request.project_root().to_path_buf(),
);
let decision = self.resolve_permission(&permission_request)?;
if decision == PermissionDecision::Ask
&& self.is_allowed_by_default(tool, request, &permission_request)
{
return Ok(true);
}
match decision {
PermissionDecision::AllowOnce => Ok(true),
PermissionDecision::Ask => self.prompt_and_store(&permission_request),
PermissionDecision::AllowAllReadsInSession
| PermissionDecision::AllowAllWritesInSession
| PermissionDecision::AllowCommandForProject => {
// These should have been resolved already, but handle them safely
Ok(true)
// Dangerous commands always require a prompt
if let Some(ref cmd) = permission_request.command {
if self.is_dangerous_command(cmd) {
return self.prompt_and_store(&permission_request);
}
}
// Restricted paths always require a prompt
if permission_request
.paths
.iter()
.any(|p| self.is_restricted_path(p))
{
return self.prompt_and_store(&permission_request);
}
let decision = self.resolve_permission(&permission_request)?;
match decision {
SystemPermissionDecision::Allow => Ok(true),
SystemPermissionDecision::Ask => self.prompt_and_store(&permission_request),
}
}
/// Store a permission decision
fn store_permission_decision(
&self,
request: &PermissionRequest,
decision: PermissionDecision,
user_decision: UserPermissionDecision,
) -> Result<(), CheckerError> {
// Handle command-specific permissions (project-scoped, persistent)
if decision == PermissionDecision::AllowCommandForProject {
let permission = super::types::Permission::new(
request.tool_name.clone(),
request.command.clone(), // Store the specific command
None, // No resource pattern for commands
decision,
PermissionScope::Project, // Project scope = persistent
request.project_id,
);
self.store.create_permission(permission)?;
if user_decision == UserPermissionDecision::AllowOnce {
return Ok(());
}
// Handle session-wide permissions
if !matches!(decision, PermissionDecision::AllowAllReadsInSession | PermissionDecision::AllowAllWritesInSession) {
return Ok(());
}
// Get project root pattern for session-wide permissions
let resource_pattern = if let Some(first_path) = request.paths.first() {
// Minimal behavior: for project-scoped patch_files AlwaysAllow, store without
// resource pattern so it applies to any path within the project for this tool.
let resource_pattern = if request.tool_name == "patch_files"
&& user_decision == UserPermissionDecision::AlwaysAllow
&& request.scope == PermissionScope::Project
{
None
} else if let Some(first_path) = request.paths.first() {
// Try to get the project root by going up the directory tree
let mut path = first_path.clone();
while let Some(parent) = path.parent() {
@ -116,10 +129,10 @@ impl PermissionChecker {
let permission = super::types::Permission::new(
request.tool_name.clone(),
None, // No command pattern for session-wide
None, // No command pattern for session-wide
resource_pattern,
decision,
PermissionScope::Session,
user_decision,
PermissionScope::Project,
request.project_id,
);
@ -144,123 +157,38 @@ impl PermissionChecker {
.any(|restricted| path_str.starts_with(restricted) || path_str.contains(restricted))
}
fn check_default_rules(
&self,
request: &PermissionRequest,
) -> Result<PermissionDecision, CheckerError> {
// Check for dangerous commands
if let Some(command) = &request.command {
if self.is_dangerous_command(command) {
return Ok(PermissionDecision::Ask);
}
}
// Check for restricted paths
for path in &request.paths {
if self.is_restricted_path(path) {
return Ok(PermissionDecision::Ask);
}
}
// Use default decision
Ok(self.config.default_decision.clone())
}
fn is_allowed_by_default(
&self,
tool: &dyn Tool,
request: &dyn Request,
permission_request: &PermissionRequest,
) -> bool {
// Allow safe tools that don't require permission
if tool.skip_permission_check() {
return true;
}
// Allow read-only tools if paths are within project root
if !tool.is_read_only() {
return false;
}
let project_root = request.project_root();
if permission_request.paths.is_empty() {
return false;
}
permission_request.paths.iter().all(|path| {
let normalized = if path.is_absolute() {
path.to_path_buf()
} else {
project_root.join(path)
};
is_within_root(&normalized, project_root)
})
}
fn resolve_permission(
&self,
request: &PermissionRequest,
) -> Result<PermissionDecision, CheckerError> {
) -> Result<SystemPermissionDecision, CheckerError> {
let project_id: i32 = request.project_id.unwrap_or(0);
if project_id == 0 {
return Ok(PermissionDecision::Ask);
return Ok(SystemPermissionDecision::Ask);
}
// Check for command-specific permissions first (project-scoped, persistent)
if let Some(command) = &request.command {
if let Ok(Some(perm)) = self.store.find_permission(
&request.tool_name,
project_id,
command,
"",
) {
if perm.decision == PermissionDecision::AllowCommandForProject
&& perm.scope == PermissionScope::Project {
return Ok(PermissionDecision::AllowOnce); // Grant this specific operation
}
}
let command_str = request.command.as_ref().map_or("", |s| s.as_str());
let path_str = request
.paths
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let perm =
self.store
.find_permission(&request.tool_name, project_id, command_str, &path_str);
if let Ok(Some(p)) = perm {
return Ok(p.system_decision());
}
// Check for session-wide permissions
let session_permissions = self.store.find_session_permissions(project_id)?;
for perm in session_permissions {
// CRITICAL SECURITY CHECK: Only grant session-wide permissions if ALL paths are within project root
let all_paths_in_project = request.paths.iter().all(|path| {
let normalized = if path.is_absolute() {
path.to_path_buf()
} else {
request.project_root.join(path)
};
is_within_root(&normalized, &request.project_root)
});
if !all_paths_in_project {
// Paths outside project - don't use session permission, ask explicitly
continue;
}
if request.is_read_only && perm.is_session_wide_all_reads() {
return Ok(PermissionDecision::AllowOnce); // Grant this specific operation
}
if !request.is_read_only && perm.is_session_wide_all_writes() {
return Ok(PermissionDecision::AllowOnce); // Grant this specific operation
}
}
// Apply default security rules
self.check_default_rules(request)
Ok(self.config.default_decision.clone())
}
fn prompt_and_store(&self, request: &PermissionRequest) -> Result<bool, CheckerError> {
match self.prompter.ask_permission(request) {
Ok(decision @ PermissionDecision::AllowAllReadsInSession)
| Ok(decision @ PermissionDecision::AllowAllWritesInSession)
| Ok(decision @ PermissionDecision::AllowCommandForProject) => {
Ok(decision) => {
self.store_permission_decision(request, decision)?;
Ok(true)
}
Ok(PermissionDecision::AllowOnce) => Ok(true),
Ok(PermissionDecision::Ask) => Ok(false),
Err(AskError::IoError) => {
Err(CheckerError::Failed("Permission prompt failed".to_string()))
}
@ -272,7 +200,6 @@ impl PermissionChecker {
mod tests {
use super::*;
use crate::domain::permissions::types::Permission;
use crate::domain::permissions::types::{PermissionConfig, PermissionDecision};
use crate::domain::permissions::{PermissionRequest, PermissionScope};
use crate::domain::session::{Request, SessionRequest};
use crate::domain::tools::{Error, Tool, ToolResult};
@ -307,24 +234,6 @@ mod tests {
Ok(permission)
}
fn find_session_permissions(&self, project_id: i32) -> Result<Vec<Permission>, StoreError> {
let permissions = self.permissions.lock().unwrap();
let session_perms: Vec<Permission> = permissions
.iter()
.filter(|p| {
p.project_id == Some(project_id)
&& p.scope == PermissionScope::Session
&& matches!(
p.decision,
PermissionDecision::AllowAllReadsInSession
| PermissionDecision::AllowAllWritesInSession
)
})
.cloned()
.collect();
Ok(session_perms)
}
fn find_permission(
&self,
tool: &str,
@ -400,14 +309,14 @@ mod tests {
struct TestPrompter {
calls: Arc<AtomicUsize>,
decision: PermissionDecision,
decision: UserPermissionDecision,
}
impl PermissionPrompter for TestPrompter {
fn ask_permission(
&self,
_request: &PermissionRequest,
) -> Result<PermissionDecision, AskError> {
) -> Result<UserPermissionDecision, AskError> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(self.decision.clone())
}
@ -598,12 +507,12 @@ mod tests {
let calls = Arc::new(AtomicUsize::new(0));
let prompter = Arc::new(TestPrompter {
calls: Arc::clone(&calls),
decision: PermissionDecision::AllowOnce,
decision: UserPermissionDecision::AlwaysAllow,
});
let checker = PermissionChecker::new_with_prompter(
store,
PermissionConfig {
default_decision: PermissionDecision::Ask,
default_decision: SystemPermissionDecision::Ask,
..PermissionConfig::default()
},
prompter,
@ -631,12 +540,12 @@ mod tests {
let calls = Arc::new(AtomicUsize::new(0));
let prompter = Arc::new(TestPrompter {
calls: Arc::clone(&calls),
decision: PermissionDecision::AllowOnce,
decision: UserPermissionDecision::AlwaysAllow,
});
let checker = PermissionChecker::new_with_prompter(
store,
PermissionConfig {
default_decision: PermissionDecision::Ask,
default_decision: SystemPermissionDecision::Ask,
..PermissionConfig::default()
},
prompter,
@ -663,12 +572,12 @@ mod tests {
let calls = Arc::new(AtomicUsize::new(0));
let prompter = Arc::new(TestPrompter {
calls: Arc::clone(&calls),
decision: PermissionDecision::AllowOnce,
decision: UserPermissionDecision::AlwaysAllow,
});
let checker = PermissionChecker::new_with_prompter(
store,
PermissionConfig {
default_decision: PermissionDecision::Ask,
default_decision: SystemPermissionDecision::Ask,
..PermissionConfig::default()
},
prompter,
@ -693,12 +602,12 @@ mod tests {
let calls = Arc::new(AtomicUsize::new(0));
let prompter = Arc::new(TestPrompter {
calls: Arc::clone(&calls),
decision: PermissionDecision::AllowOnce,
decision: UserPermissionDecision::AlwaysAllow,
});
let checker = PermissionChecker::new_with_prompter(
store,
PermissionConfig {
default_decision: PermissionDecision::AllowOnce,
default_decision: SystemPermissionDecision::Allow,
..PermissionConfig::default()
},
prompter,
@ -725,12 +634,12 @@ mod tests {
let calls = Arc::new(AtomicUsize::new(0));
let prompter = Arc::new(TestPrompter {
calls: Arc::clone(&calls),
decision: PermissionDecision::AllowOnce,
decision: UserPermissionDecision::AlwaysAllow,
});
let checker = PermissionChecker::new_with_prompter(
store,
PermissionConfig {
default_decision: PermissionDecision::AllowOnce,
default_decision: SystemPermissionDecision::Allow,
..PermissionConfig::default()
},
prompter,
@ -748,7 +657,7 @@ mod tests {
}
#[test]
fn resolve_permission_returns_ask_for_read_only() {
fn resolve_permission_returns_ask_when_no_stored_permission() {
let abs = std::fs::canonicalize(".").expect("failed to canonicalize path");
let store = Arc::new(TestStore::with_permissions(vec![]));
@ -757,7 +666,7 @@ mod tests {
PermissionConfig::default(),
Arc::new(TestPrompter {
calls: Arc::new(AtomicUsize::new(0)),
decision: PermissionDecision::AllowOnce,
decision: UserPermissionDecision::AlwaysAllow,
}),
);
let request = PermissionRequest::new(
@ -766,13 +675,13 @@ mod tests {
vec![abs.clone()],
PermissionScope::Project,
Some(1),
true, // is_read_only
true,
abs.parent().unwrap_or(&abs).to_path_buf(),
);
let decision = checker.resolve_permission(&request).unwrap();
assert_eq!(decision, PermissionDecision::Ask);
assert_eq!(decision, SystemPermissionDecision::Ask);
}
#[test]
@ -781,19 +690,21 @@ mod tests {
let root = temp.path().to_path_buf();
let file_path = root.join("sample.txt");
// Command permission with AllowOnce → system_decision = Ask
let command_permission = Permission::new(
"command_tool".to_string(),
Some("rm -rf /".to_string()),
None,
PermissionDecision::Ask,
UserPermissionDecision::AllowOnce,
PermissionScope::Project,
Some(1),
);
// Path permission with AlwaysAllow → system_decision = Allow
let path_permission = Permission::new(
"command_tool".to_string(),
None,
Some(file_path.to_string_lossy().to_string()),
PermissionDecision::AllowOnce,
UserPermissionDecision::AlwaysAllow,
PermissionScope::Project,
Some(1),
);
@ -806,7 +717,7 @@ mod tests {
PermissionConfig::default(),
Arc::new(TestPrompter {
calls: Arc::new(AtomicUsize::new(0)),
decision: PermissionDecision::AllowOnce,
decision: UserPermissionDecision::AlwaysAllow,
}),
);
let request = PermissionRequest::new(
@ -815,13 +726,14 @@ mod tests {
vec![file_path.clone()],
PermissionScope::Project,
Some(1),
false, // is_read_only
false,
root,
);
let decision = checker.resolve_permission(&request).unwrap();
assert_eq!(decision, PermissionDecision::Ask);
// Command match has higher specificity, and AllowOnce → Ask
assert_eq!(decision, SystemPermissionDecision::Ask);
}
#[test]
@ -834,7 +746,7 @@ mod tests {
"command_tool".to_string(),
None,
Some(file_path.to_string_lossy().to_string()),
PermissionDecision::AllowOnce,
UserPermissionDecision::AlwaysAllow,
PermissionScope::Project,
Some(1),
);
@ -844,7 +756,7 @@ mod tests {
PermissionConfig::default(),
Arc::new(TestPrompter {
calls: Arc::new(AtomicUsize::new(0)),
decision: PermissionDecision::AllowOnce,
decision: UserPermissionDecision::AlwaysAllow,
}),
);
let request = PermissionRequest::new(
@ -853,13 +765,14 @@ mod tests {
vec![file_path.clone()],
PermissionScope::Project,
Some(1),
false, // is_read_only
false,
root,
);
let decision = checker.resolve_permission(&request).unwrap();
assert_eq!(decision, PermissionDecision::Ask);
// Path permission with AlwaysAllow → Allow
assert_eq!(decision, SystemPermissionDecision::Allow);
}
#[test]
@ -868,7 +781,7 @@ mod tests {
"command_tool".to_string(),
None,
None,
PermissionDecision::Ask,
UserPermissionDecision::AllowOnce,
PermissionScope::Project,
Some(1),
);
@ -878,7 +791,7 @@ mod tests {
PermissionConfig::default(),
Arc::new(TestPrompter {
calls: Arc::new(AtomicUsize::new(0)),
decision: PermissionDecision::AllowOnce,
decision: UserPermissionDecision::AlwaysAllow,
}),
);
let request = PermissionRequest::new(
@ -887,13 +800,14 @@ mod tests {
vec![],
PermissionScope::Project,
Some(1),
false, // is_read_only
false,
PathBuf::from("/tmp"),
);
let decision = checker.resolve_permission(&request).unwrap();
assert_eq!(decision, PermissionDecision::Ask);
// AllowOnce → system_decision = Ask
assert_eq!(decision, SystemPermissionDecision::Ask);
}
#[test]
@ -902,12 +816,12 @@ mod tests {
let checker = PermissionChecker::new_with_prompter(
store,
PermissionConfig {
default_decision: PermissionDecision::AllowOnce,
default_decision: SystemPermissionDecision::Allow,
..PermissionConfig::default()
},
Arc::new(TestPrompter {
calls: Arc::new(AtomicUsize::new(0)),
decision: PermissionDecision::AllowOnce,
decision: UserPermissionDecision::AlwaysAllow,
}),
);
let request = PermissionRequest::new(
@ -916,147 +830,181 @@ mod tests {
vec![],
PermissionScope::Project,
Some(1),
false, // is_read_only
false,
PathBuf::from("/tmp"),
);
let decision = checker.resolve_permission(&request).unwrap();
assert_eq!(decision, PermissionDecision::AllowOnce);
assert_eq!(decision, SystemPermissionDecision::Allow);
}
#[test]
fn session_read_permission_only_works_within_project() {
fn stored_permission_does_not_apply_to_different_resource() {
let project_dir = tempfile::tempdir().unwrap();
let external_dir = tempfile::tempdir().unwrap();
let project_root = project_dir.path().to_path_buf();
let external_file = external_dir.path().join("external.txt");
std::fs::write(&external_file, "data").unwrap();
// Create a session-wide read permission
let session_perm = Permission::new(
// Create a permission with a specific resource pattern
let perm = Permission::new(
"read_only".to_string(),
None,
Some(format!("{}/**", project_root.to_string_lossy())),
PermissionDecision::AllowAllReadsInSession,
PermissionScope::Session,
UserPermissionDecision::AlwaysAllow,
PermissionScope::Project,
Some(1),
);
let store = Arc::new(TestStore::with_permissions(vec![session_perm]));
let store = Arc::new(TestStore::with_permissions(vec![perm]));
let checker = PermissionChecker::new_with_prompter(
store,
PermissionConfig::default(),
Arc::new(TestPrompter {
calls: Arc::new(AtomicUsize::new(0)),
decision: PermissionDecision::AllowOnce,
decision: UserPermissionDecision::AlwaysAllow,
}),
);
// Try to read a file OUTSIDE the project
// Request for a file that doesn't match the resource pattern
let request = PermissionRequest::new(
"read_only".to_string(),
None,
vec![external_file],
PermissionScope::Project,
Some(1),
true, // is_read_only
true,
project_root,
);
let decision = checker.resolve_permission(&request).unwrap();
// Should NOT grant access via session permission - should Ask
assert_eq!(decision, PermissionDecision::Ask);
// Resource pattern doesn't match → falls back to default (Ask)
assert_eq!(decision, SystemPermissionDecision::Ask);
}
#[test]
fn session_write_permission_only_works_within_project() {
fn stored_permission_does_not_apply_to_different_project() {
let project_dir = tempfile::tempdir().unwrap();
let external_dir = tempfile::tempdir().unwrap();
let project_root = project_dir.path().to_path_buf();
let external_file = external_dir.path().join("external.txt");
std::fs::write(&external_file, "data").unwrap();
// Create a session-wide write permission
let session_perm = Permission::new(
// Create a permission for project 1
let perm = Permission::new(
"write_tool".to_string(),
None,
Some(format!("{}/**", project_root.to_string_lossy())),
PermissionDecision::AllowAllWritesInSession,
PermissionScope::Session,
None,
UserPermissionDecision::AlwaysAllow,
PermissionScope::Project,
Some(1),
);
let store = Arc::new(TestStore::with_permissions(vec![session_perm]));
let store = Arc::new(TestStore::with_permissions(vec![perm]));
let checker = PermissionChecker::new_with_prompter(
store,
PermissionConfig::default(),
Arc::new(TestPrompter {
calls: Arc::new(AtomicUsize::new(0)),
decision: PermissionDecision::AllowOnce,
decision: UserPermissionDecision::AlwaysAllow,
}),
);
// Try to write a file OUTSIDE the project
// Request for project 2 - should not match
let request = PermissionRequest::new(
"write_tool".to_string(),
None,
vec![external_file],
vec![project_root.join("file.txt")],
PermissionScope::Project,
Some(1),
false, // is_read_only
Some(2),
false,
project_root,
);
let decision = checker.resolve_permission(&request).unwrap();
// Should NOT grant access via session permission - should Ask
assert_eq!(decision, PermissionDecision::Ask);
assert_eq!(decision, SystemPermissionDecision::Ask);
}
#[test]
fn session_read_permission_works_within_project() {
fn stored_write_permission_grants_access() {
let project_dir = tempfile::tempdir().unwrap();
let project_root = project_dir.path().to_path_buf();
// Create an AlwaysAllow permission for write_tool (no resource pattern = matches any)
let perm = Permission::new(
"write_tool".to_string(),
None,
None,
UserPermissionDecision::AlwaysAllow,
PermissionScope::Project,
Some(1),
);
let store = Arc::new(TestStore::with_permissions(vec![perm]));
let checker = PermissionChecker::new_with_prompter(
store,
PermissionConfig::default(),
Arc::new(TestPrompter {
calls: Arc::new(AtomicUsize::new(0)),
decision: UserPermissionDecision::AlwaysAllow,
}),
);
let request = PermissionRequest::new(
"write_tool".to_string(),
None,
vec![project_root.join("internal.txt")],
PermissionScope::Project,
Some(1),
false,
project_root,
);
let decision = checker.resolve_permission(&request).unwrap();
assert_eq!(decision, SystemPermissionDecision::Allow);
}
#[test]
fn stored_read_permission_grants_access() {
let project_dir = tempfile::tempdir().unwrap();
let project_root = project_dir.path().to_path_buf();
let internal_file = project_root.join("internal.txt");
std::fs::write(&internal_file, "data").unwrap();
// Create a session-wide read permission
let session_perm = Permission::new(
// Create an AlwaysAllow permission for read_only (no resource pattern = matches any)
let perm = Permission::new(
"read_only".to_string(),
None,
Some(format!("{}/**", project_root.to_string_lossy())),
PermissionDecision::AllowAllReadsInSession,
PermissionScope::Session,
None,
UserPermissionDecision::AlwaysAllow,
PermissionScope::Project,
Some(1),
);
let store = Arc::new(TestStore::with_permissions(vec![session_perm]));
let store = Arc::new(TestStore::with_permissions(vec![perm]));
let checker = PermissionChecker::new_with_prompter(
store,
PermissionConfig::default(),
Arc::new(TestPrompter {
calls: Arc::new(AtomicUsize::new(0)),
decision: PermissionDecision::AllowOnce,
decision: UserPermissionDecision::AlwaysAllow,
}),
);
// Try to read a file INSIDE the project
let request = PermissionRequest::new(
"read_only".to_string(),
None,
vec![internal_file],
PermissionScope::Project,
Some(1),
true, // is_read_only
true,
project_root,
);
let decision = checker.resolve_permission(&request).unwrap();
// SHOULD grant access via session permission
assert_eq!(decision, PermissionDecision::AllowOnce);
assert_eq!(decision, SystemPermissionDecision::Allow);
}
#[test]
@ -1064,12 +1012,12 @@ mod tests {
let project_dir = tempfile::tempdir().unwrap();
let project_root = project_dir.path().to_path_buf();
// Create a project-scoped command permission
// Create a project-scoped command permission with AlwaysAllow
let command_perm = Permission::new(
"shell_exec".to_string(),
Some("npm test".to_string()),
None,
PermissionDecision::AllowCommandForProject,
UserPermissionDecision::AlwaysAllow,
PermissionScope::Project,
Some(1),
);
@ -1080,7 +1028,7 @@ mod tests {
PermissionConfig::default(),
Arc::new(TestPrompter {
calls: Arc::new(AtomicUsize::new(0)),
decision: PermissionDecision::AllowOnce,
decision: UserPermissionDecision::AlwaysAllow,
}),
);
@ -1091,12 +1039,12 @@ mod tests {
vec![],
PermissionScope::Project,
Some(1),
false, // is_read_only
false,
project_root.clone(),
);
let decision = checker.resolve_permission(&request).unwrap();
assert_eq!(decision, PermissionDecision::AllowOnce); // Allowed via stored permission
assert_eq!(decision, SystemPermissionDecision::Allow);
// Request for a DIFFERENT command should ask
let different_request = PermissionRequest::new(
@ -1110,6 +1058,48 @@ mod tests {
);
let decision = checker.resolve_permission(&different_request).unwrap();
assert_eq!(decision, PermissionDecision::Ask); // Different command, must ask
assert_eq!(decision, SystemPermissionDecision::Ask);
}
#[test]
fn patch_files_permission_within_project_root_applies_to_other_project_files() {
let project_dir = tempfile::tempdir().unwrap();
let project_root = project_dir.path().to_path_buf();
// User previously created an AlwaysAllow permission for patch_files
// targeting one file inside this project.
let existing_perm = Permission::new(
"patch_files".to_string(),
None,
None,
UserPermissionDecision::AlwaysAllow,
PermissionScope::Project,
Some(1),
);
let store = Arc::new(TestStore::with_permissions(vec![existing_perm]));
let checker = PermissionChecker::new_with_prompter(
store,
PermissionConfig::default(),
Arc::new(TestPrompter {
calls: Arc::new(AtomicUsize::new(0)),
decision: UserPermissionDecision::AlwaysAllow,
}),
);
// New request patches a DIFFERENT file, but still within the same project root.
// Expected behavior (desired): this should be allowed by the existing permission.
let request = PermissionRequest::new(
"patch_files".to_string(),
None,
vec![project_root.join("src/b.rs")],
PermissionScope::Project,
Some(1),
false,
project_root,
);
let decision = checker.resolve_permission(&request).unwrap();
assert_eq!(decision, SystemPermissionDecision::Allow);
}
}

View file

@ -1,4 +1,4 @@
use super::types::{Permission, PermissionDecision, PermissionScope};
use super::types::{Permission, PermissionScope, UserPermissionDecision};
use crate::infrastructure::db::DbPool;
use rusqlite::params;
use thiserror::Error;
@ -18,7 +18,6 @@ pub trait PermissionStore {
command_pattern: &str,
resource_pattern: &str,
) -> Result<Option<Permission>, StoreError>;
fn find_session_permissions(&self, project_id: i32) -> Result<Vec<Permission>, StoreError>;
}
pub struct SqlitePermissionStore {
@ -31,18 +30,6 @@ impl SqlitePermissionStore {
}
fn row_to_permission(&self, row: &rusqlite::Row) -> Result<Permission, rusqlite::Error> {
let decision_str: String = row.get("decision")?;
let decision = match decision_str.as_str() {
"allow_all_reads_session" => PermissionDecision::AllowAllReadsInSession,
"allow_all_writes_session" => PermissionDecision::AllowAllWritesInSession,
"allow_command_for_project" => PermissionDecision::AllowCommandForProject,
"ask" => PermissionDecision::Ask,
"once" => PermissionDecision::AllowOnce,
// Handle legacy values gracefully
"allow" | "deny" => PermissionDecision::Ask,
_ => PermissionDecision::Ask, // Default to ask on error
};
let scope_str: String = row.get("scope")?;
let scope = match scope_str.as_str() {
"session" => PermissionScope::Session,
@ -61,7 +48,7 @@ impl SqlitePermissionStore {
tool_name: row.get("tool_name")?,
command_pattern: row.get("command_pattern")?,
resource_pattern: row.get("resource_pattern")?,
decision,
user_decision: UserPermissionDecision::AlwaysAllow,
scope,
project_id: row.get("project_id")?,
created_at,
@ -71,12 +58,10 @@ impl SqlitePermissionStore {
impl PermissionStore for SqlitePermissionStore {
fn create_permission(&self, permission: Permission) -> Result<Permission, StoreError> {
let decision_str = match permission.decision {
PermissionDecision::AllowAllReadsInSession => "allow_all_reads_session",
PermissionDecision::AllowAllWritesInSession => "allow_all_writes_session",
PermissionDecision::AllowCommandForProject => "allow_command_for_project",
PermissionDecision::Ask => "ask",
PermissionDecision::AllowOnce => "once",
let decision_str = match permission.user_decision {
UserPermissionDecision::AlwaysAllow => "always_allow",
UserPermissionDecision::AllowOnce => "allow_once",
UserPermissionDecision::Deny => "deny",
};
let scope_str = match permission.scope {
@ -159,10 +144,8 @@ impl PermissionStore for SqlitePermissionStore {
})?;
// Build params list dynamically to match the query
let mut param_values: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(project_id),
Box::new(tool.to_string()),
];
let mut param_values: Vec<Box<dyn rusqlite::ToSql>> =
vec![Box::new(project_id), Box::new(tool.to_string())];
if !command_pattern.is_empty() {
param_values.push(Box::new(command_pattern.to_string()));
@ -172,10 +155,8 @@ impl PermissionStore for SqlitePermissionStore {
param_values.push(Box::new(resource_pattern.to_string()));
}
let params_refs: Vec<&dyn rusqlite::ToSql> = param_values
.iter()
.map(|p| p.as_ref())
.collect();
let params_refs: Vec<&dyn rusqlite::ToSql> =
param_values.iter().map(|p| p.as_ref()).collect();
let result = conn.query_row(&query, params_refs.as_slice(), |row| {
self.row_to_permission(row)
@ -187,32 +168,6 @@ impl PermissionStore for SqlitePermissionStore {
Err(e) => Err(StoreError::Database(e)),
}
}
fn find_session_permissions(&self, project_id: i32) -> Result<Vec<Permission>, StoreError> {
let conn = self.conn.get().map_err(|e| {
StoreError::Database(rusqlite::Error::ToSqlConversionFailure(Box::new(
std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to get connection: {}", e),
),
)))
})?;
let mut stmt = conn.prepare(
"SELECT id, tool_name, command_pattern, resource_pattern, decision, scope,
project_id, created_at
FROM permissions
WHERE project_id = ?1
AND scope = 'session'
AND decision IN ('allow_all_reads_session', 'allow_all_writes_session')",
)?;
let permissions = stmt
.query_map(params![project_id], |row| self.row_to_permission(row))?
.collect::<Result<Vec<_>, _>>()?;
Ok(permissions)
}
}
#[cfg(test)]
@ -238,7 +193,8 @@ mod tests {
created_at TEXT NOT NULL
)",
[],
).unwrap();
)
.unwrap();
pool
}
@ -253,7 +209,7 @@ mod tests {
"test_tool".to_string(),
None,
None,
PermissionDecision::AllowOnce,
UserPermissionDecision::AllowOnce,
PermissionScope::Project,
Some(1),
);
@ -262,7 +218,10 @@ mod tests {
// Should find the permission when searching with empty strings
let result = store.find_permission("test_tool", 1, "", "").unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().decision, PermissionDecision::AllowOnce);
assert_eq!(
result.unwrap().user_decision,
UserPermissionDecision::AlwaysAllow
);
}
#[test]
@ -275,16 +234,21 @@ mod tests {
"test_tool".to_string(),
Some("echo test".to_string()),
None,
PermissionDecision::Ask,
UserPermissionDecision::AlwaysAllow,
PermissionScope::Project,
Some(1),
);
store.create_permission(permission).unwrap();
// Should find the permission when searching with command and empty resource
let result = store.find_permission("test_tool", 1, "echo test", "").unwrap();
let result = store
.find_permission("test_tool", 1, "echo test", "")
.unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().decision, PermissionDecision::Ask);
assert_eq!(
result.unwrap().user_decision,
UserPermissionDecision::AlwaysAllow
);
}
#[test]
@ -297,16 +261,21 @@ mod tests {
"test_tool".to_string(),
None,
Some("/path/to/file".to_string()),
PermissionDecision::AllowOnce,
UserPermissionDecision::AllowOnce,
PermissionScope::Project,
Some(1),
);
store.create_permission(permission).unwrap();
// Should find the permission when searching with empty command and resource
let result = store.find_permission("test_tool", 1, "", "/path/to/file").unwrap();
let result = store
.find_permission("test_tool", 1, "", "/path/to/file")
.unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().decision, PermissionDecision::AllowOnce);
assert_eq!(
result.unwrap().user_decision,
UserPermissionDecision::AlwaysAllow
);
}
#[test]
@ -319,16 +288,21 @@ mod tests {
"test_tool".to_string(),
Some("rm -rf".to_string()),
Some("/etc/passwd".to_string()),
PermissionDecision::Ask,
UserPermissionDecision::AlwaysAllow,
PermissionScope::Project,
Some(1),
);
store.create_permission(permission).unwrap();
// Should find the permission when searching with both patterns
let result = store.find_permission("test_tool", 1, "rm -rf", "/etc/passwd").unwrap();
let result = store
.find_permission("test_tool", 1, "rm -rf", "/etc/passwd")
.unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().decision, PermissionDecision::Ask);
assert_eq!(
result.unwrap().user_decision,
UserPermissionDecision::AlwaysAllow
);
}
#[test]
@ -341,7 +315,7 @@ mod tests {
"test_tool".to_string(),
Some("echo test".to_string()),
None,
PermissionDecision::AllowOnce,
UserPermissionDecision::AllowOnce,
PermissionScope::Project,
Some(1),
);
@ -353,47 +327,23 @@ mod tests {
}
#[test]
fn test_find_session_permissions() {
fn test_find_permission_different_project_no_match() {
let pool = setup_test_db();
let store = SqlitePermissionStore::new(pool.clone());
// Create session-wide read permission
let read_perm = Permission::new(
// Create permission for project 1
let permission = Permission::new(
"read_tool".to_string(),
None,
Some("/project/**".to_string()),
PermissionDecision::AllowAllReadsInSession,
PermissionScope::Session,
Some(1),
);
store.create_permission(read_perm).unwrap();
// Create session-wide write permission
let write_perm = Permission::new(
"write_tool".to_string(),
None,
Some("/project/**".to_string()),
PermissionDecision::AllowAllWritesInSession,
PermissionScope::Session,
Some(1),
);
store.create_permission(write_perm).unwrap();
// Create non-session permission (should not be returned)
let other_perm = Permission::new(
"other_tool".to_string(),
None,
None,
PermissionDecision::AllowOnce,
UserPermissionDecision::AlwaysAllow,
PermissionScope::Project,
Some(1),
);
store.create_permission(other_perm).unwrap();
store.create_permission(permission).unwrap();
// Find session permissions
let result = store.find_session_permissions(1).unwrap();
assert_eq!(result.len(), 2);
assert!(result.iter().any(|p| matches!(p.decision, PermissionDecision::AllowAllReadsInSession)));
assert!(result.iter().any(|p| matches!(p.decision, PermissionDecision::AllowAllWritesInSession)));
// Should NOT find when searching for project 2
let result = store.find_permission("read_tool", 2, "", "").unwrap();
assert!(result.is_none());
}
}
}

View file

@ -3,12 +3,16 @@ use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum PermissionDecision {
Ask, // Prompt user again
AllowOnce, // One-time approval
AllowAllReadsInSession, // Session-wide read access
AllowAllWritesInSession, // Session-wide write access
AllowCommandForProject, // Allow specific command for this project (persistent)
pub enum SystemPermissionDecision {
Ask,
Allow,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum UserPermissionDecision {
AllowOnce,
AlwaysAllow,
Deny,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@ -24,9 +28,9 @@ pub struct Permission {
pub tool_name: String, // "shell", "file_edit", etc.
pub command_pattern: Option<String>, // "rm -rf", "git push", etc.
pub resource_pattern: Option<String>, // "api.search.brave.com", "/src/**", etc.
pub decision: PermissionDecision, // Allow, Deny, Ask
pub scope: PermissionScope, // Session, Project, Global
pub project_id: Option<i32>, // If project-scoped
pub user_decision: UserPermissionDecision,
pub scope: PermissionScope, // Session, Project, Global
pub project_id: Option<i32>, // If project-scoped
pub created_at: DateTime<Utc>,
}
@ -36,7 +40,7 @@ impl Permission {
tool_name: String,
command_pattern: Option<String>,
resource_pattern: Option<String>,
decision: PermissionDecision,
decision: UserPermissionDecision,
scope: PermissionScope,
project_id: Option<i32>,
) -> Self {
@ -45,88 +49,18 @@ impl Permission {
tool_name,
command_pattern,
resource_pattern,
decision,
user_decision: decision,
scope,
project_id,
created_at: Utc::now(),
}
}
/// Check if this permission matches the given tool, command, and path
pub fn matches(&self, tool: &str, command: Option<&str>, path: Option<&PathBuf>) -> bool {
if self.tool_name != tool {
return false;
pub fn system_decision(self) -> SystemPermissionDecision {
if self.user_decision == UserPermissionDecision::AlwaysAllow {
return SystemPermissionDecision::Allow;
}
// Check command pattern if specified
if let (Some(pattern), Some(cmd)) = (&self.command_pattern, command) {
if !self.matches_command(pattern, cmd) {
return false;
}
}
// Check resource pattern if specified
if let (Some(pattern), Some(p)) = (&self.resource_pattern, path) {
if !self.matches_path(pattern, p) {
return false;
}
}
true
}
fn matches_command(&self, pattern: &str, command: &str) -> bool {
// Simple pattern matching - can be enhanced with regex
if pattern.contains('*') {
// Basic wildcard matching
let pattern_parts: Vec<&str> = pattern.split_whitespace().collect();
let command_parts: Vec<&str> = command.split_whitespace().collect();
if pattern_parts.len() != command_parts.len() {
return false;
}
pattern_parts
.iter()
.zip(command_parts.iter())
.all(|(p, c)| *p == "*" || *p == *c)
} else {
pattern == command
}
}
fn matches_path(&self, pattern: &str, path: &PathBuf) -> bool {
let path_str = path.to_string_lossy();
if pattern.contains('*') {
// Basic glob matching
if pattern.ends_with("/**") {
let prefix = pattern.trim_end_matches("/**");
path_str.starts_with(prefix)
} else if pattern.starts_with("**/") {
let suffix = pattern.trim_start_matches("**/");
path_str.ends_with(suffix)
} else {
// Simple wildcard - can be enhanced with proper glob matching
path_str.contains(pattern.trim_matches('*'))
}
} else {
path_str == pattern
}
}
/// Check if this is a session-wide "allow all reads" permission
pub fn is_session_wide_all_reads(&self) -> bool {
matches!(self.decision, PermissionDecision::AllowAllReadsInSession)
&& self.scope == PermissionScope::Session
&& self.resource_pattern.as_ref().map(|p| p.ends_with("/**")).unwrap_or(false)
}
/// Check if this is a session-wide "allow all writes" permission
pub fn is_session_wide_all_writes(&self) -> bool {
matches!(self.decision, PermissionDecision::AllowAllWritesInSession)
&& self.scope == PermissionScope::Session
&& self.resource_pattern.as_ref().map(|p| p.ends_with("/**")).unwrap_or(false)
SystemPermissionDecision::Ask
}
}
@ -165,7 +99,7 @@ impl PermissionRequest {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionConfig {
pub default_decision: PermissionDecision,
pub default_decision: SystemPermissionDecision,
pub dangerous_commands: Vec<String>,
pub restricted_paths: Vec<String>,
pub require_confirmation: bool,
@ -174,7 +108,7 @@ pub struct PermissionConfig {
impl Default for PermissionConfig {
fn default() -> Self {
Self {
default_decision: PermissionDecision::Ask,
default_decision: SystemPermissionDecision::Ask,
dangerous_commands: vec![
"rm -rf".to_string(),
"sudo".to_string(),
@ -188,6 +122,7 @@ impl Default for PermissionConfig {
"/usr/bin".to_string(),
"/bin".to_string(),
"/sbin".to_string(),
".env".to_string(),
"~/.ssh".to_string(),
"~/.aws".to_string(),
"~/.gnupg".to_string(),
@ -195,4 +130,4 @@ impl Default for PermissionConfig {
require_confirmation: true,
}
}
}
}

View file

@ -1,8 +1,8 @@
use crate::domain::AgentModeType;
use crate::domain::bt::BTStepNodeInterface;
use crate::domain::session::Request;
use crate::domain::ModelType;
use crate::domain::prompting::user::get_user_prompt;
use crate::domain::session::Request;
use crate::domain::AgentModeType;
use crate::domain::ModelType;
/// LLM prompt templates for the coding assistant
///
@ -29,26 +29,36 @@ pub fn get_bt_tree_step_prompt(
}
}
pub fn get_system_prompt(model_type: ModelType, agent_mode: AgentModeType, remaining_calls: usize) -> String {
pub fn get_system_prompt(
model_type: ModelType,
agent_mode: AgentModeType,
remaining_calls: usize,
) -> String {
let (os_name, shell_name) = get_runtime_environment();
if agent_mode == AgentModeType::Plan {
let mut system_prompt = _system_prompt_for_plan(model_type);
if remaining_calls < 3 {
system_prompt.push_str(&format!("\n\nYou have {} tool calls left to process this request.", remaining_calls));
system_prompt.push_str(&format!(
"\n\nYou have {} tool calls left to process this request.",
remaining_calls
));
}
return system_prompt;
}
if agent_mode == AgentModeType::BuildFromPlan {
let mut system_prompt = _system_prompt_for_build_from_plan(model_type);
if remaining_calls < 3 {
system_prompt.push_str(&format!("\n\nYou have {} tool calls left to process this request.", remaining_calls));
system_prompt.push_str(&format!(
"\n\nYou have {} tool calls left to process this request.",
remaining_calls
));
}
return system_prompt;
}
let mut system_prompt = if model_type == ModelType::OpenAI {
format!(
"You are Drastis, a coding agent. \n\
"You're Drastis, you're running as a coding agent in the CLI on a user's computer. \n\
Use the available tools to gather context and make changes. \
When using tools, pass JSON arguments that match their parameters. \n\
Runtime: os={}, shell={}.",
@ -56,12 +66,15 @@ pub fn get_system_prompt(model_type: ModelType, agent_mode: AgentModeType, remai
)
} else {
format!(
"You are Drastis, a coding agent. Use available tools to gather context and make changes. Be concise and accurate. Runtime: os={}, shell={}.",
"You're Drastis, you're running as a coding agent in the CLI on a user's computer. Use available tools to gather context and make changes. Be concise and accurate. Runtime: os={}, shell={}.",
os_name, shell_name
)
};
if remaining_calls < 3 {
system_prompt.push_str(&format!("\n\nYou have {} tool calls left to process this request.", remaining_calls));
system_prompt.push_str(&format!(
"\n\nYou have {} tool calls left to process this request.",
remaining_calls
));
}
system_prompt
}
@ -70,7 +83,7 @@ fn _system_prompt_for_plan(model_type: ModelType) -> String {
let (os_name, shell_name) = get_runtime_environment();
if model_type == ModelType::OpenAI {
format!(
"You are Drastis, a coding agent. \n\
"You're Drastis, you're running as a coding agent in the CLI on a user's computer. \n\
You're in the Plan Mode! Use tools to gather all required information to make a detailed plan of what user wants to achieve. \n\
Ask the user, if there're any ambiguities, and clarify what needs to be done if the instructions are not clear. \n\
When you gathered the information, use the `update_todo_list` tool to create the TODO list and ask the user if he would like to make any changes to the TODO list. \n\

View file

@ -4,6 +4,6 @@ mod tools;
mod user;
pub use general::*;
pub use user::*;
pub use session::*;
pub use tools::*;
pub use user::*;

View file

@ -1,7 +1,7 @@
use crate::domain::AgentModeType;
use crate::domain::ModelType;
use crate::domain::session::Request;
use crate::domain::todo::TodoListStatus;
use crate::domain::AgentModeType;
use crate::domain::ModelType;
/// Format request history and current request as a complete prompt
pub fn get_user_prompt(_model_type: ModelType, request: &dyn Request) -> String {
@ -23,4 +23,4 @@ fn _get_build_from_plan_prompt(request: &dyn Request) -> String {
}
}
"Report very briefly what was done without using any tools, since all the TODO items are completed.".to_string()
}
}

View file

@ -41,4 +41,4 @@ pub trait Request {
/// Get the session's TODO list (plan)
fn get_session_plan(&self) -> Option<crate::domain::todo::TodoList>;
}
}

View file

@ -7,8 +7,8 @@ use crate::infrastructure::db::DbPool;
use crate::infrastructure::AgentToUiEvent;
use crate::infrastructure::InferenceEngine;
use crate::repository::{
ModelsRepository, SessionRequestStepsRepository, SessionRequestsRepository,
TodoListRepository, UserSettingsRepository,
ModelsRepository, SessionRequestStepsRepository, SessionRequestsRepository, TodoListRepository,
UserSettingsRepository,
};
use crossbeam_channel::Sender;
use std::sync::Arc;
@ -97,7 +97,9 @@ impl SessionService {
match repo.get_by_session(session.id()) {
Ok(Some(row)) => {
match serde_json::from_str::<crate::domain::todo::TodoList>(&row.content) {
Ok(todo_list) => !todo_list.is_completed() && !todo_list.items.is_empty(),
Ok(todo_list) => {
!todo_list.is_completed() && !todo_list.items.is_empty()
}
Err(_) => false,
}
}
@ -133,8 +135,7 @@ impl SessionService {
// Run the workflow
let result: Result<Chain, WorkflowError> = if self.use_behavior_trees {
self.workflow
.run_using_bt(&mut request, cancel)
self.workflow.run_using_bt(&mut request, cancel)
} else {
self.workflow
.run(&mut request, cancel, effective_mode)

View file

@ -128,6 +128,7 @@ impl Request for Session {
}
fn get_history_steps(&self) -> Vec<crate::domain::workflow::step::ChainStep> {
use crate::domain::workflow::step::ChainStep;
use crate::repository::SessionRequestStepsRepository;
// BuildFromPlan keeps context minimal — TODO list (with statuses) is
@ -145,16 +146,43 @@ impl Request for Session {
}
};
let steps_repo = SessionRequestStepsRepository::new(conn);
let steps_repo = SessionRequestStepsRepository::new(conn.clone());
// Load all steps from all requests in this session
match steps_repo.load_steps_for_session(self.id) {
let steps = match steps_repo.load_steps_for_session(self.id) {
Ok(steps) => steps,
Err(e) => {
log::warn!("Failed to load history steps for session {}: {}", self.id, e);
log::warn!(
"Failed to load history steps for session {}: {}",
self.id,
e
);
Vec::new()
}
};
if !steps.is_empty() {
return steps;
}
// Fallback: rebuild assistant responses from stored steps_log when steps table is empty
let mut fallback_steps = Vec::new();
for request in &self.requests {
if let Some(log) = request.steps_log() {
for line in log.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
fallback_steps.push(ChainStep::assistant_response(
trimmed.to_string(),
trimmed.to_string(),
));
}
}
}
fallback_steps
}
fn get_session_plan(&self) -> Option<crate::domain::todo::TodoList> {
@ -201,4 +229,4 @@ impl Request for Session {
}
// Note: From<SessionRow> is no longer implemented because Session requires a Project entity.
// Use Session::from_row_with_project() instead.
// Use Session::from_row_with_project() instead.

View file

@ -8,6 +8,7 @@ use crate::repository::SessionRequestRow;
pub struct SessionRequest {
prompt: String,
result_summary: Option<String>,
steps_log: Option<String>,
#[allow(dead_code)]
mode: AgentModeType,
}
@ -23,6 +24,10 @@ impl SessionRequest {
self.result_summary.as_deref()
}
pub fn steps_log(&self) -> Option<&str> {
self.steps_log.as_deref()
}
#[allow(dead_code)]
pub fn mode(&self) -> AgentModeType {
self.mode
@ -32,6 +37,7 @@ impl SessionRequest {
Self {
prompt: row.prompt,
result_summary: row.result_summary,
steps_log: row._steps_log,
mode: row.mode,
}
}

View file

@ -85,9 +85,10 @@ impl StartupService {
let naming_prompt = session_naming_prompt(self.engine.get_type(), &prompt_preview);
let mut chain = Chain::new();
chain
.steps
.push(ChainStep::user_message(naming_prompt.parse().unwrap(), Vec::new()));
chain.steps.push(ChainStep::user_message(
naming_prompt.parse().unwrap(),
Vec::new(),
));
let session_name = match self.engine.generate(&[], &chain, &[], None) {
Ok(raw) => {
log::debug!("Raw session name response: {:?}", raw.summary);
@ -196,7 +197,8 @@ impl StartupService {
.map(SessionRequest::from_row)
.collect();
let session = Session::load_with_requests(session_row, project, requests);
let mut session = Session::load_with_requests(session_row, project, requests);
session.set_conn(self.conn.clone());
Ok(session)
}
}

View file

@ -20,9 +20,15 @@ impl TodoListStatus {
#[allow(dead_code)]
pub fn from_str(value: &str) -> Self {
match value {
"pending" | "PENDING" | "planned" | "PLANNED" | "todo" | "TODO" => TodoListStatus::Pending,
"in_progress" | "IN_PROGRESS" | "doing" | "DOING" | "started" | "STARTED" => TodoListStatus::InProgress,
"completed" | "COMPLETED" | "done" | "DONE" | "finished" | "FINISHED" => TodoListStatus::Completed,
"pending" | "PENDING" | "planned" | "PLANNED" | "todo" | "TODO" => {
TodoListStatus::Pending
}
"in_progress" | "IN_PROGRESS" | "doing" | "DOING" | "started" | "STARTED" => {
TodoListStatus::InProgress
}
"completed" | "COMPLETED" | "done" | "DONE" | "finished" | "FINISHED" => {
TodoListStatus::Completed
}
&_ => TodoListStatus::Pending,
}
}
@ -42,6 +48,8 @@ pub struct TodoList {
impl TodoList {
pub fn is_completed(&self) -> bool {
self.items.iter().all(|item| item.status == TodoListStatus::Completed)
self.items
.iter()
.all(|item| item.status == TodoListStatus::Completed)
}
}
}

View file

@ -106,7 +106,12 @@ impl Tool for DiscoverObjects {
let input = match self.load_input() {
Ok(input) => input,
Err(e) => {
return ToolResult::error(self.name().to_string(), String::new(), e.to_string(), String::new())
return ToolResult::error(
self.name().to_string(),
String::new(),
e.to_string(),
String::new(),
)
}
};
@ -117,7 +122,12 @@ impl Tool for DiscoverObjects {
Self::format_output(lang, &objects),
input.call_id,
),
Err(e) => ToolResult::error(self.name().to_string(), input.raw.clone(), e.to_string(), input.call_id),
Err(e) => ToolResult::error(
self.name().to_string(),
input.raw.clone(),
e.to_string(),
input.call_id,
),
}
}

View file

@ -169,7 +169,12 @@ impl Tool for FindFiles {
let input = match self.load_input() {
Ok(input) => input,
Err(e) => {
return ToolResult::error(self.name().to_string(), String::new(), e.to_string(), String::new())
return ToolResult::error(
self.name().to_string(),
String::new(),
e.to_string(),
String::new(),
)
}
};
@ -177,7 +182,14 @@ impl Tool for FindFiles {
let search_root =
match Self::resolve_search_root(input.root.as_deref(), request.project_root()) {
Ok(root) => root,
Err(e) => return ToolResult::error(self.name().to_string(), input.raw.clone(), e, input.call_id.clone()),
Err(e) => {
return ToolResult::error(
self.name().to_string(),
input.raw.clone(),
e,
input.call_id.clone(),
)
}
};
// Check if search root exists

View file

@ -47,7 +47,7 @@ pub struct ToolResult {
tool_name: String,
call_id: String,
pub(crate) input: String,
is_successful: bool,
is_successful: bool,
output: String,
error_message: String,
file_changes: Option<Vec<FileChange>>,
@ -62,7 +62,7 @@ impl ToolResult {
output,
error_message: String::new(),
file_changes: None,
call_id
call_id,
}
}
@ -74,7 +74,7 @@ impl ToolResult {
output: String::new(),
error_message: message.into(),
file_changes: None,
call_id
call_id,
}
}
@ -161,6 +161,7 @@ pub trait Tool: Send + Sync {
fn is_read_only(&self) -> bool {
false
}
#[allow(dead_code)]
fn skip_permission_check(&self) -> bool {
false
}

View file

@ -78,7 +78,12 @@ impl Tool for PatchFiles {
let input = match self.load_input() {
Ok(input) => input,
Err(e) => {
return ToolResult::error(self.name().to_string(), String::new(), e.to_string(), String::new())
return ToolResult::error(
self.name().to_string(),
String::new(),
e.to_string(),
String::new(),
)
}
};
@ -149,7 +154,7 @@ impl Tool for PatchFiles {
return ToolResult::error(
self.name().to_string(),
input.raw.clone(),
format!("Failed to apply patch: {}", e),
format!("Patch Failed: {}. Next step: read the file region you’re editing (function/block + ~10 lines around it) and generate a new patch using those exact lines as context. Then retry patch_files.", e),
input.call_id.clone(),
)
}
@ -612,4 +617,4 @@ mod tests {
result.output_string()
);
}
}
}

View file

@ -202,7 +202,12 @@ impl Tool for ReadObjects {
let input = match self.load_input() {
Ok(input) => input,
Err(e) => {
return ToolResult::error(self.name().to_string(), String::new(), e.to_string(), String::new())
return ToolResult::error(
self.name().to_string(),
String::new(),
e.to_string(),
String::new(),
)
}
};
@ -213,7 +218,12 @@ impl Tool for ReadObjects {
Self::format_output(lang, results),
input.call_id,
),
Err(e) => ToolResult::error(self.name().to_string(), input.raw.clone(), e.to_string(), input.call_id),
Err(e) => ToolResult::error(
self.name().to_string(),
input.raw.clone(),
e.to_string(),
input.call_id,
),
}
}
@ -485,4 +495,4 @@ mod tests {
};
assert!(query.matches(&obj_partial), "Should match partial name");
}
}
}

View file

@ -28,6 +28,35 @@ struct ShellExecInputParsed {
call_id: String,
}
fn is_allowed_read_only_command(command: &str) -> bool {
let trimmed = command.trim();
if trimmed.is_empty() {
return false;
}
if trimmed.contains(';')
|| trimmed.contains("&&")
|| trimmed.contains("||")
|| trimmed.contains('|')
|| trimmed.contains('>')
|| trimmed.contains('<')
{
return false;
}
let mut parts = trimmed.split_whitespace();
let first = parts.next().unwrap_or_default();
let args: Vec<&str> = parts.collect();
match first {
"rg" | "grep" | "glob" | "cat" | "head" | "tail" | "less" | "more" | "wc" | "cut"
| "sort" | "uniq" | "find" | "ls" | "tree" | "stat" | "file" | "awk" | "pwd"
| "which" | "type" => true,
"sed" => args.iter().any(|arg| *arg == "-n" || *arg == "--quiet" || *arg == "--silent"),
_ => false,
}
}
impl Tool for ShellExec {
fn name(&self) -> &'static str {
"shell_exec"
@ -135,7 +164,12 @@ impl Tool for ShellExec {
if !stderr.is_empty() {
result.push_str(&format!("[stderr]: {}", stderr));
}
return ToolResult::error(self.name().to_string(), input.raw.clone(), result, input.call_id.clone());
return ToolResult::error(
self.name().to_string(),
input.raw.clone(),
result,
input.call_id.clone(),
);
};
ToolResult::ok(self.name().to_string(), input.raw, result, input.call_id)
@ -251,6 +285,15 @@ DO NOT use it to change files, use `patch_files` tool for this.",
paths
}
fn is_read_only(&self) -> bool {
self.input
.lock()
.unwrap()
.as_ref()
.map(|input| is_allowed_read_only_command(&input.command))
.unwrap_or(false)
}
}
impl ShellExec {
@ -346,7 +389,10 @@ mod tests {
let tool = ShellExec::new();
assert!(tool
.parse_input(r#"{"command":"echo hello"}"#.to_string(), "call-id".to_string())
.parse_input(
r#"{"command":"echo hello"}"#.to_string(),
"call-id".to_string()
)
.is_none());
let result = tool.work(&request);
@ -388,10 +434,13 @@ mod tests {
let tool = ShellExec::new();
assert!(tool
.parse_input(format!(
r#"{{"command":"pwd","working_dir":"{}"}}"#,
subdir.display()
), "call-id".to_string())
.parse_input(
format!(
r#"{{"command":"pwd","working_dir":"{}"}}"#,
subdir.display()
),
"call-id".to_string()
)
.is_none());
let result = tool.work(&request);
@ -412,7 +461,10 @@ mod tests {
let tool = ShellExec::new();
assert!(tool
.parse_input(r#"{"command":"pwd","working_dir":"/tmp"}"#.to_string(), "call-id".to_string())
.parse_input(
r#"{"command":"pwd","working_dir":"/tmp"}"#.to_string(),
"call-id".to_string()
)
.is_none());
let result = tool.work(&request);
@ -450,7 +502,10 @@ mod tests {
let tool = ShellExec::new();
assert!(tool
.parse_input(r#"{"command":"echo error >&2 && exit 1"}"#.to_string(), "call-id".to_string())
.parse_input(
r#"{"command":"echo error >&2 && exit 1"}"#.to_string(),
"call-id".to_string()
)
.is_none());
let result = tool.work(&request);
@ -478,10 +533,13 @@ mod tests {
let tool = ShellExec::new();
assert!(tool
.parse_input(format!(
r#"{{"command":"echo 'test content' > {}"}}"#,
file_path.display()
), "call-id".to_string())
.parse_input(
format!(
r#"{{"command":"echo 'test content' > {}"}}"#,
file_path.display()
),
"call-id".to_string()
)
.is_none());
let result = tool.work(&request);
@ -503,7 +561,10 @@ mod tests {
let tool = ShellExec::new();
assert!(tool
.parse_input(r#"{"command":"echo 'hello world' | tr 'a-z' 'A-Z'"}"#.to_string(), "call-id".to_string())
.parse_input(
r#"{"command":"echo 'hello world' | tr 'a-z' 'A-Z'"}"#.to_string(),
"call-id".to_string()
)
.is_none());
let result = tool.work(&request);
@ -513,4 +574,64 @@ mod tests {
result.output_string()
);
}
#[test]
fn test_shell_exec_is_read_only_allows_read_commands() {
let tool = ShellExec::new();
assert!(tool
.parse_input(
r#"{"command":"rg -n foo src"}"#.to_string(),
"call-id".to_string()
)
.is_none());
assert!(tool.is_read_only());
let tool = ShellExec::new();
assert!(tool
.parse_input(
r#"{"command":"grep -n foo file.txt"}"#.to_string(),
"call-id".to_string()
)
.is_none());
assert!(tool.is_read_only());
let tool = ShellExec::new();
assert!(tool
.parse_input(
r#"{"command":"sed -n '1,10p' file.txt"}"#.to_string(),
"call-id".to_string()
)
.is_none());
assert!(tool.is_read_only());
}
#[test]
fn test_shell_exec_is_read_only_rejects_write_or_compound() {
let tool = ShellExec::new();
assert!(tool
.parse_input(
r#"{"command":"sed '1,10p' file.txt"}"#.to_string(),
"call-id".to_string()
)
.is_none());
assert!(!tool.is_read_only());
let tool = ShellExec::new();
assert!(tool
.parse_input(
r#"{"command":"echo hi > out.txt"}"#.to_string(),
"call-id".to_string()
)
.is_none());
assert!(!tool.is_read_only());
let tool = ShellExec::new();
assert!(tool
.parse_input(
r#"{"command":"echo hi && rg -n foo src"}"#.to_string(),
"call-id".to_string()
)
.is_none());
assert!(!tool.is_read_only());
}
}

View file

@ -164,13 +164,25 @@ impl Tool for Structure {
let input = match self.load_input() {
Ok(input) => input,
Err(e) => {
return ToolResult::error(self.name().to_string(), String::new(), e.to_string(), String::new())
return ToolResult::error(
self.name().to_string(),
String::new(),
e.to_string(),
String::new(),
)
}
};
let target_path = match Self::resolve_path(&input.path, request.project_root()) {
Ok(p) => p,
Err(e) => return ToolResult::error(self.name().to_string(), input.raw.clone(), e, input.call_id.clone()),
Err(e) => {
return ToolResult::error(
self.name().to_string(),
input.raw.clone(),
e,
input.call_id.clone(),
)
}
};
if !target_path.exists() {

View file

@ -1,4 +1,5 @@
use crate::domain::session::Request;
use crate::domain::todo::TodoList;
use crate::domain::tools::{Error, Tool, ToolResult};
use crate::infrastructure::db::DbPool;
use crate::infrastructure::event_bus::AgentToUiEvent;
@ -6,7 +7,6 @@ use crate::repository::TodoListRepository;
use crossbeam_channel::Sender;
use serde_json::{json, Value};
use std::sync::Mutex;
use crate::domain::todo::TodoList;
pub struct UpdateTodoList {
input: Mutex<Option<UpdateTodoListInput>>,
@ -42,7 +42,10 @@ impl Tool for UpdateTodoList {
match parsed {
Ok(_) => {
let mut lock = self.input.lock().unwrap();
*lock = Some(UpdateTodoListInput { raw: input, call_id });
*lock = Some(UpdateTodoListInput {
raw: input,
call_id,
});
None
}
Err(e) => Some(Error::Parse(format!("Failed to parse input: {}", e))),
@ -148,7 +151,7 @@ impl Tool for UpdateTodoList {
}
fn desc(&self) -> String {
"Use this in Plan mode to create or update TODO items after exploring the codebase. For every use, provide full TODO list, as it will replace the existing list.\n\
"Use this in Plan mode to create or update TODO items after exploring the codebase. For every use, provide full TODO list, as it will replace the existing list.\n\
If you're not in Plan mode, only update statuses of TODO items as you finish them.
"
.to_string()

View file

@ -163,9 +163,12 @@ impl Tool for WebSearch {
Ok(results) => {
let output = WebSearchOutput { results };
match serde_json::to_string(&output) {
Ok(json_output) => {
ToolResult::ok(self.name().to_string(), input.raw.clone(), json_output, input.call_id)
}
Ok(json_output) => ToolResult::ok(
self.name().to_string(),
input.raw.clone(),
json_output,
input.call_id,
),
Err(e) => ToolResult::error(
self.name().to_string(),
input.raw.clone(),
@ -320,7 +323,10 @@ mod tests {
use crate::domain::permissions::{
checker::{PermissionChecker, PermissionPrompter},
store::{PermissionStore, StoreError},
types::{PermissionConfig, PermissionDecision, PermissionRequest, PermissionScope},
types::{
PermissionConfig, PermissionRequest, PermissionScope, SystemPermissionDecision,
UserPermissionDecision,
},
};
use crate::domain::session::{Request, SessionRequest};
use crate::domain::UserSettings;
@ -359,11 +365,6 @@ mod tests {
Ok(permission)
}
fn find_session_permissions(&self, _project_id: i32) -> Result<Vec<crate::domain::permissions::types::Permission>, StoreError> {
// Return empty for tests - this test doesn't use session permissions
Ok(Vec::new())
}
fn find_permission(
&self,
tool: &str,
@ -407,14 +408,14 @@ mod tests {
struct TestPrompter {
calls: Arc<AtomicUsize>,
decision: PermissionDecision,
decision: UserPermissionDecision,
}
impl PermissionPrompter for TestPrompter {
fn ask_permission(
&self,
_request: &PermissionRequest,
) -> Result<PermissionDecision, crate::utils::AskError> {
) -> Result<UserPermissionDecision, crate::utils::AskError> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(self.decision.clone())
}
@ -482,6 +483,11 @@ mod tests {
web_search_enabled,
brave_api_key: brave_api_key.map(String::from),
max_tool_calls_per_request: 10,
auth_method: "api_key".to_string(),
oauth_access_token: None,
oauth_refresh_token: None,
oauth_token_expiry: None,
oauth_account_id: None,
};
UserSettings::from(row)
}
@ -495,12 +501,12 @@ mod tests {
let calls = Arc::new(AtomicUsize::new(0));
let prompter = Arc::new(TestPrompter {
calls: Arc::clone(&calls),
decision: PermissionDecision::AllowOnce,
decision: UserPermissionDecision::AllowOnce,
});
let checker = PermissionChecker::new_with_prompter(
store,
PermissionConfig {
default_decision: PermissionDecision::Ask,
default_decision: SystemPermissionDecision::Ask,
..PermissionConfig::default()
},
prompter,
@ -525,12 +531,12 @@ mod tests {
let store = Arc::new(TestStore::new());
let calls = Arc::new(AtomicUsize::new(0));
let decision_to_return = Arc::new(Mutex::new(PermissionDecision::AllowOnce));
let decision_to_return = Arc::new(Mutex::new(UserPermissionDecision::AllowOnce));
let captured_request = Arc::new(Mutex::new(None::<PermissionRequest>));
struct CapturingPrompter {
calls: Arc<AtomicUsize>,
decision: Arc<Mutex<PermissionDecision>>,
decision: Arc<Mutex<UserPermissionDecision>>,
captured: Arc<Mutex<Option<PermissionRequest>>>,
}
@ -538,7 +544,7 @@ mod tests {
fn ask_permission(
&self,
request: &PermissionRequest,
) -> Result<PermissionDecision, crate::utils::AskError> {
) -> Result<UserPermissionDecision, crate::utils::AskError> {
self.calls.fetch_add(1, Ordering::SeqCst);
*self.captured.lock().unwrap() = Some(request.clone());
Ok(self.decision.lock().unwrap().clone())
@ -554,7 +560,7 @@ mod tests {
let checker = PermissionChecker::new_with_prompter(
store,
PermissionConfig {
default_decision: PermissionDecision::Ask,
default_decision: SystemPermissionDecision::Ask,
..PermissionConfig::default()
},
prompter,
@ -578,5 +584,4 @@ mod tests {
assert_eq!(captured_req.tool_name, "web_search");
assert_eq!(captured_req.paths, vec![PathBuf::from("docs.rs")]);
}
}
}

View file

@ -1,6 +1,30 @@
use crate::repository::UserSettingsRow;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthMethod {
ApiKey,
OAuth,
}
#[allow(dead_code)]
impl AuthMethod {
pub fn as_str(&self) -> &str {
match self {
AuthMethod::ApiKey => "api_key",
AuthMethod::OAuth => "oauth",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"oauth" => AuthMethod::OAuth,
_ => AuthMethod::ApiKey,
}
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct UserSettings {
_id: i64,
openai_api_key: Option<String>,
@ -11,8 +35,14 @@ pub struct UserSettings {
web_search_enabled: bool,
brave_api_key: Option<String>,
max_tool_calls_per_request: i32,
auth_method: AuthMethod,
oauth_access_token: Option<String>,
oauth_refresh_token: Option<String>,
oauth_token_expiry: Option<i64>,
oauth_account_id: Option<String>,
}
#[allow(dead_code)]
impl UserSettings {
pub fn openai_api_key(&self) -> Option<&str> {
self.openai_api_key.as_deref()
@ -22,7 +52,6 @@ impl UserSettings {
self.openai_tracing_enabled
}
#[allow(dead_code)]
pub fn current_model_name(&self) -> Option<&str> {
self.current_model_name.as_deref()
}
@ -43,6 +72,29 @@ impl UserSettings {
pub fn max_tool_calls_per_request(&self) -> i32 {
self.max_tool_calls_per_request
}
pub fn auth_method(&self) -> &AuthMethod {
&self.auth_method
}
pub fn oauth_access_token(&self) -> Option<&str> {
self.oauth_access_token.as_deref()
}
pub fn oauth_refresh_token(&self) -> Option<&str> {
self.oauth_refresh_token.as_deref()
}
pub fn oauth_account_id(&self) -> Option<&str> {
self.oauth_account_id.as_deref()
}
pub fn is_oauth_token_expired(&self) -> bool {
if let Some(expiry) = self.oauth_token_expiry {
return chrono::Utc::now().timestamp() >= expiry;
}
false
}
}
impl From<UserSettingsRow> for UserSettings {
@ -57,6 +109,11 @@ impl From<UserSettingsRow> for UserSettings {
web_search_enabled: row.web_search_enabled,
brave_api_key: row.brave_api_key,
max_tool_calls_per_request: row.max_tool_calls_per_request,
auth_method: AuthMethod::from_str(&row.auth_method),
oauth_access_token: row.oauth_access_token,
oauth_refresh_token: row.oauth_refresh_token,
oauth_token_expiry: row.oauth_token_expiry,
oauth_account_id: row.oauth_account_id,
}
}
}

View file

@ -1,8 +1,8 @@
use super::step::ChainStep;
use super::step::StepType;
use crate::domain::todo::TodoList;
use crate::domain::tools::ToolResult;
use serde::{Deserialize, Serialize};
use crate::domain::todo::TodoList;
use super::step::StepType;
/// Represents an execution chain containing multiple steps
/// The chain is built incrementally as tools are executed
@ -36,7 +36,8 @@ impl Chain {
/// Add a step to the chain after executing a tool
pub fn add_step(&mut self, result: ToolResult) {
if result.tool_name() == "update_todo_list" && result.is_successful() {
if let Ok(updated_todo_list) = serde_json::from_str::<TodoList>(&result.input_string()) {
if let Ok(updated_todo_list) = serde_json::from_str::<TodoList>(&result.input_string())
{
self.set_todo_list(Some(updated_todo_list));
return;
}
@ -122,7 +123,7 @@ impl Chain {
.collect::<Vec<_>>()
.join("\n")
}
pub fn set_system_prompt(&mut self, system_prompt: String) {
self.system_prompt = system_prompt;
}

View file

@ -16,7 +16,7 @@ impl AllToolset {
session_id: i64,
settings: &UserSettings,
conn: DbPool,
event_sender: Sender<AgentToUiEvent>
event_sender: Sender<AgentToUiEvent>,
) -> Self {
let mut tools: HashMap<String, Box<dyn Tool>> = HashMap::new();

View file

@ -15,7 +15,7 @@ impl AllNoTodoToolset {
_session_id: i64,
settings: &UserSettings,
_conn: DbPool,
_event_sender: Sender<AgentToUiEvent>
_event_sender: Sender<AgentToUiEvent>,
) -> Self {
let mut tools: HashMap<String, Box<dyn Tool>> = HashMap::new();
@ -50,4 +50,4 @@ impl Toolset for AllNoTodoToolset {
fn tools(&self) -> &HashMap<String, Box<dyn Tool>> {
&self.tools
}
}
}

View file

@ -1,9 +1,9 @@
use crate::domain::tools::*;
use crate::domain::workflow::toolset::Toolset;
use crate::infrastructure::db::DbPool;
use std::collections::HashMap;
use crossbeam_channel::Sender;
use crate::infrastructure::AgentToUiEvent;
use crossbeam_channel::Sender;
use std::collections::HashMap;
/// General toolset containing read-only and utility tools
pub struct DiscoverToolset {

View file

@ -11,11 +11,7 @@ pub struct FinishingAllToolset {
}
impl FinishingAllToolset {
pub fn new(
session_id: i64,
conn: DbPool,
event_sender: Sender<AgentToUiEvent>
) -> Self {
pub fn new(session_id: i64, conn: DbPool, event_sender: Sender<AgentToUiEvent>) -> Self {
let mut tools: HashMap<String, Box<dyn Tool>> = HashMap::new();
let patch_files = Box::new(PatchFiles::new());

View file

@ -10,11 +10,7 @@ pub struct FinishingAllNoTodoToolset {
}
impl FinishingAllNoTodoToolset {
pub fn new(
_session_id: i64,
_conn: DbPool,
_event_sender: Sender<AgentToUiEvent>
) -> Self {
pub fn new(_session_id: i64, _conn: DbPool, _event_sender: Sender<AgentToUiEvent>) -> Self {
let mut tools: HashMap<String, Box<dyn Tool>> = HashMap::new();
let patch_files = Box::new(PatchFiles::new());
@ -28,4 +24,4 @@ impl Toolset for FinishingAllNoTodoToolset {
fn tools(&self) -> &HashMap<String, Box<dyn Tool>> {
&self.tools
}
}
}

View file

@ -1,29 +1,29 @@
mod all;
mod all_no_todo;
mod edit;
mod none;
mod discover;
mod edit;
mod finishing_all;
mod finishing_all_no_todo;
mod finishing_discover;
mod none;
use std::collections::HashMap;
use std::sync::Arc;
use crossbeam_channel::Sender;
use crate::domain::tools::Error;
use crate::domain::tools::Tool;
use crate::domain::UserSettings;
use crate::infrastructure::db::DbPool;
use crate::infrastructure::event_bus::AgentToUiEvent;
use crate::infrastructure::inference::ToolCall;
pub use all::AllToolset;
pub use all_no_todo::AllNoTodoToolset;
pub use edit::EditToolset;
pub use none::NoneToolset;
use crossbeam_channel::Sender;
pub use discover::DiscoverToolset;
pub use edit::EditToolset;
pub use finishing_all::FinishingAllToolset;
pub use finishing_all_no_todo::FinishingAllNoTodoToolset;
pub use finishing_discover::FinishingDiscoverToolset;
use crate::infrastructure::db::DbPool;
use crate::domain::tools::Tool;
use crate::domain::UserSettings;
use crate::infrastructure::event_bus::AgentToUiEvent;
use crate::infrastructure::inference::ToolCall;
use crate::domain::tools::Error;
pub use none::NoneToolset;
use std::collections::HashMap;
use std::sync::Arc;
#[derive(Clone, Copy)]
pub enum ToolsetType {
@ -51,12 +51,29 @@ impl ToolsetType {
ToolsetType::Discover => Arc::new(DiscoverToolset::new(session_id, conn, event_sender)),
ToolsetType::Edit => Arc::new(EditToolset::new(session_id, conn, event_sender)),
ToolsetType::All => Arc::new(AllToolset::new(session_id, settings, conn, event_sender)),
ToolsetType::AllNoTodo => Arc::new(AllNoTodoToolset::new(session_id, settings, conn, event_sender)),
ToolsetType::FinishingAllNoTodo => Arc::new(FinishingAllNoTodoToolset::new(session_id, conn, event_sender)),
ToolsetType::AllNoTodo => Arc::new(AllNoTodoToolset::new(
session_id,
settings,
conn,
event_sender,
)),
ToolsetType::FinishingAllNoTodo => Arc::new(FinishingAllNoTodoToolset::new(
session_id,
conn,
event_sender,
)),
ToolsetType::None => Arc::new(NoneToolset::new()),
ToolsetType::FinishingAll => Arc::new(FinishingAllToolset::new(session_id, conn, event_sender)),
ToolsetType::FinishingDiscover => Arc::new(FinishingDiscoverToolset::new(session_id, conn, event_sender)),
ToolsetType::FinishingEdit => Arc::new(EditToolset::new(session_id, conn, event_sender)),
ToolsetType::FinishingAll => {
Arc::new(FinishingAllToolset::new(session_id, conn, event_sender))
}
ToolsetType::FinishingDiscover => Arc::new(FinishingDiscoverToolset::new(
session_id,
conn,
event_sender,
)),
ToolsetType::FinishingEdit => {
Arc::new(EditToolset::new(session_id, conn, event_sender))
}
}
}
@ -102,10 +119,11 @@ pub trait Toolset {
.map(|t| t.as_ref())
.ok_or_else(|| Error::Parse(format!("Tool not found: {}", tool_call.name)))?;
if let Some(err) = tool.parse_input(tool_call.arguments.clone(), tool_call.call_id.clone()) {
if let Some(err) = tool.parse_input(tool_call.arguments.clone(), tool_call.call_id.clone())
{
return Err(err);
}
Ok(tool)
}
}
}

View file

@ -6,17 +6,17 @@ use crate::domain::bt::GeneralTree;
use crate::domain::prompting;
use crate::domain::session::Request;
use crate::domain::todo::TodoListStatus;
use crate::domain::workflow::toolset::NoneToolset;
use crate::domain::workflow::toolset::ToolsetType;
use crate::domain::AgentModeType;
use crate::infrastructure::db::DbPool;
use crate::infrastructure::event_bus::{AgentToUiEvent, StepPhase};
use crate::infrastructure::inference::InferenceEngine;
use tokio::runtime::{Builder, Handle};
use crate::domain::workflow::toolset::NoneToolset;
use crate::domain::workflow::toolset::ToolsetType;
use crossbeam_channel::Sender;
use openai_agents_tracing::{SpanKind, TracingFacade};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use crossbeam_channel::Sender;
use crate::domain::AgentModeType;
use tokio::runtime::{Builder, Handle};
/// Main workflow orchestrator that runs LLM-driven coding tasks
/// Implements an eternal agent loop that:
@ -48,10 +48,7 @@ impl Workflow {
toolset: Arc::new(NoneToolset::new()),
engine,
chain: Chain::new(),
tool_runner: ToolRunner::new(
permission_checker,
event_sender.clone(),
),
tool_runner: ToolRunner::new(permission_checker, event_sender.clone()),
tracer: None,
event_sender,
conn,
@ -170,17 +167,20 @@ impl Workflow {
user_prompt_override: Option<String>,
mode: AgentModeType,
) -> Result<(), Error> {
let base_user_prompt = prompting::get_user_prompt(self.engine.get_type(), request);
let user_prompt = user_prompt_override
.as_deref()
.unwrap_or(&base_user_prompt)
.to_string();
self.chain.steps.push(ChainStep::user_message(user_prompt.clone(), request.images().to_vec()));
self.chain.steps.push(ChainStep::user_message(
user_prompt.clone(),
request.images().to_vec(),
));
// Get max_tool_calls from override (for BT mode) or user settings
let max_tool_calls = max_tool_calls_override.unwrap_or_else(|| {
request.user_settings()
request
.user_settings()
.map(|s| s.max_tool_calls_per_request() as usize)
.unwrap_or(50)
});
@ -189,7 +189,11 @@ impl Workflow {
let mut tool_call_count = 0;
let mut current_active_todo = self.chain.todo_list.clone();
let mut in_finishing_mode = false;
let finishing_threshold = if max_tool_calls > 5 { max_tool_calls - 5 } else { max_tool_calls };
let finishing_threshold = if max_tool_calls > 5 {
max_tool_calls - 5
} else {
max_tool_calls
};
// Eternal agent loop
for _iteration in 1..=max_tool_calls {
@ -204,7 +208,11 @@ impl Workflow {
let remaining_calls = max_tool_calls.saturating_sub(tool_call_count);
// Get base system prompt and inject remaining count
let system_prompt = prompting::get_system_prompt(self.engine.get_type(), request.mode(), remaining_calls);
let system_prompt = prompting::get_system_prompt(
self.engine.get_type(),
request.mode(),
remaining_calls,
);
self.chain.set_system_prompt(system_prompt.clone());
// Switch to finishing toolset if approaching limit
@ -280,7 +288,10 @@ impl Workflow {
// Check if TODO item changed and reset counter if so (BuildFromPlan mode only)
if mode == AgentModeType::BuildFromPlan && is_update_todo {
if self._did_todo_item_change(&current_active_todo) {
log::info!("Active TODO item changed, resetting counter from {} to 0", tool_call_count);
log::info!(
"Active TODO item changed, resetting counter from {} to 0",
tool_call_count
);
tool_call_count = 0;
current_active_todo = self.chain.todo_list.clone();
@ -364,29 +375,28 @@ impl Workflow {
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let index = (nanos % options.len() as u128) as usize;
let _ = self
.event_sender
.send(AgentToUiEvent::ProgressEvent {
step_name: "inference".to_string(),
phase: StepPhase::Before,
summary: options[index].to_string(),
});
let _ = self.event_sender.send(AgentToUiEvent::ProgressEvent {
step_name: "inference".to_string(),
phase: StepPhase::Before,
summary: options[index].to_string(),
});
}
/// Detects if the active TODO item has changed
/// Returns true if the first non-completed item's title is different
fn _did_todo_item_change(&self, previous_todo: &Option<crate::domain::todo::TodoList>) -> bool {
// Get first non-completed item from previous TODO list
let prev_active = previous_todo.as_ref().and_then(|list| {
list.items.iter()
list.items
.iter()
.find(|item| item.status != TodoListStatus::Completed)
.map(|item| &item.title)
});
// Get first non-completed item from current TODO list
let curr_active = self.chain.todo_list.as_ref().and_then(|list| {
list.items.iter()
list.items
.iter()
.find(|item| item.status != TodoListStatus::Completed)
.map(|item| &item.title)
});
@ -396,4 +406,4 @@ impl Workflow {
// If both exist but titles differ, change occurred
prev_active != curr_active
}
}
}

View file

@ -3,6 +3,15 @@ use super::translator::{build_llm_result, build_request_dto};
use crate::infrastructure::inference::LLMInferenceResult;
use std::error::Error;
#[derive(Clone)]
pub enum AuthToken {
ApiKey(String),
OAuth {
token: String,
account_id: Option<String>,
},
}
#[derive(Debug)]
pub enum OpenAIClientError {
Api {
@ -16,6 +25,17 @@ pub enum OpenAIClientError {
NoText {
body: String,
},
BodyRead {
source: reqwest::Error,
status: reqwest::StatusCode,
content_encoding: Option<String>,
content_type: Option<String>,
content_length: Option<u64>,
transfer_encoding: Option<String>,
response_headers: String,
partial_body: Option<String>,
},
AuthExpired,
}
impl std::fmt::Display for OpenAIClientError {
@ -34,6 +54,34 @@ impl std::fmt::Display for OpenAIClientError {
OpenAIClientError::NoText { body } => {
write!(f, "No output_text found in OpenAI response (body={})", body)
}
OpenAIClientError::BodyRead {
source,
status,
content_encoding,
content_type,
content_length,
transfer_encoding,
response_headers,
partial_body,
} => {
write!(
f,
"Failed to read SSE response body (status={}, content-encoding={}, content-type={}, content-length={}, transfer-encoding={}, headers={}, partial-body={}, error={:?})",
status,
content_encoding.as_deref().unwrap_or("<missing>"),
content_type.as_deref().unwrap_or("<missing>"),
content_length
.map(|value| value.to_string())
.unwrap_or_else(|| "<missing>".to_string()),
transfer_encoding.as_deref().unwrap_or("<missing>"),
response_headers,
partial_body.as_deref().unwrap_or("<empty>"),
source
)
}
OpenAIClientError::AuthExpired => {
write!(f, "OAuth token expired - please re-authenticate")
}
}
}
}
@ -42,23 +90,27 @@ impl std::error::Error for OpenAIClientError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
OpenAIClientError::Deserialize { source, .. } => Some(source),
OpenAIClientError::BodyRead { source, .. } => Some(source),
_ => None,
}
}
}
pub struct OpenAIClient {
api_key: String,
auth_token: AuthToken,
model: String,
client: reqwest::blocking::Client,
}
impl OpenAIClient {
pub fn new(api_key: String, model: String) -> Self {
pub fn new(auth_token: AuthToken, model: String) -> Self {
let client = reqwest::blocking::Client::builder()
.build()
.expect("Failed to build OpenAI HTTP client");
Self {
api_key,
auth_token,
model,
client: reqwest::blocking::Client::new(),
client,
}
}
@ -71,12 +123,7 @@ impl OpenAIClient {
) -> Result<LLMInferenceResult, Box<dyn Error + Send + Sync>> {
let max_attempts = 3;
for attempt in 1..=max_attempts {
match self.call_responses_api_inner(
tools,
chain,
images,
tracer.as_deref_mut(),
) {
match self.call_responses_api_inner(tools, chain, images, tracer.as_deref_mut()) {
Ok(result) => return Ok(result),
Err(e) => {
let error_str = e.to_string();
@ -122,15 +169,14 @@ impl OpenAIClient {
images: &[String],
mut tracer: Option<&mut openai_agents_tracing::TracingFacade>,
) -> Result<LLMInferenceResult, Box<dyn Error + Send + Sync>> {
let url = "https://api.openai.com/v1/responses";
// Determine API URL based on auth type
let url = match &self.auth_token {
AuthToken::OAuth { .. } => "https://chatgpt.com/backend-api/codex/responses",
AuthToken::ApiKey(_) => "https://api.openai.com/v1/responses",
};
let request_body = build_request_dto(
&self.model,
images,
tools,
chain,
tracer.as_deref_mut(),
);
let request_body =
build_request_dto(&self.model, images, tools, chain, tracer.as_deref_mut());
// Start span with model name and add request as JSON
if let Some(tracer) = &mut tracer {
@ -142,30 +188,97 @@ impl OpenAIClient {
}
}
let response = self
.client
.post(url)
.header("Authorization", format!("Bearer {}", self.api_key))
.json(&request_body)
// Build request with appropriate headers
let mut request_builder = self.client.post(url).json(&request_body);
// Add authentication header
match &self.auth_token {
AuthToken::ApiKey(key) => {
request_builder =
request_builder.header("Authorization", format!("Bearer {}", key));
}
AuthToken::OAuth { token, account_id } => {
request_builder =
request_builder.header("Authorization", format!("Bearer {}", token));
if let Some(acc_id) = account_id {
request_builder = request_builder.header("ChatGPT-Account-Id", acc_id);
}
}
}
let mut response = request_builder
.header("Accept", "text/event-stream")
.header(reqwest::header::ACCEPT_ENCODING, "identity")
.send()?;
let status = response.status();
let body = response.text()?;
let content_encoding = response
.headers()
.get(reqwest::header::CONTENT_ENCODING)
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string());
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string());
let content_length = response.content_length();
let transfer_encoding = response
.headers()
.get(reqwest::header::TRANSFER_ENCODING)
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string());
let response_headers = Self::format_headers_excerpt(response.headers());
if !status.is_success() {
let body = response
.text()
.unwrap_or_else(|e| format!("(failed to read body: {})", e));
if let Some(t) = tracer {
t.end_span(&self.model);
}
// Check for OAuth expiration
if status.as_u16() == 401 {
return Err(Box::new(OpenAIClientError::AuthExpired));
}
return Err(Box::new(OpenAIClientError::Api { status, body }));
}
let dto = match serde_json::from_str::<ResponseDTO>(&body) {
// Read SSE stream as raw bytes to avoid encoding issues with chunked transfer
let mut body_bytes = Vec::new();
if let Err(e) = response.copy_to(&mut body_bytes) {
let partial_body = if body_bytes.is_empty() {
None
} else {
Some(Self::format_body_excerpt(
&String::from_utf8_lossy(&body_bytes),
4000,
))
};
return Err(Box::new(OpenAIClientError::BodyRead {
source: e,
status,
content_encoding: content_encoding.clone(),
content_type: content_type.clone(),
content_length,
transfer_encoding: transfer_encoding.clone(),
response_headers: response_headers.clone(),
partial_body,
}));
}
let body = String::from_utf8_lossy(&body_bytes).into_owned();
log::debug!("SSE response length: {} bytes", body.len());
log::trace!("SSE response body: {}", &body[..body.len().min(2000)]);
let dto = match Self::parse_sse_response(&body) {
Ok(v) => v,
Err(e) => {
if let Some(t) = tracer {
t.end_span(&self.model);
}
return Err(Box::new(OpenAIClientError::Deserialize { source: e, body }));
return Err(e);
}
};
@ -179,9 +292,229 @@ impl OpenAIClient {
let result = build_llm_result(dto, tools);
if result.summary.is_empty() && result.tool_call.is_none() {
return Err(Box::new(OpenAIClientError::NoText { body }));
return Err(Box::new(OpenAIClientError::NoText {
body: format!(
"empty response from SSE stream; sse_body={}",
Self::format_body_excerpt(&body, 4000)
),
}));
}
Ok(result)
}
/// Parse an SSE response body, extracting the ResponseDTO from the `response.completed` event.
///
/// SSE format from OpenAI Responses API:
/// ```text
/// event: response.created
/// data: {"type":"response.created","response":{...}}
///
/// event: response.output_text.delta
/// data: {"type":"response.output_text.delta","delta":"Hello",...}
///
/// event: response.completed
/// data: {"type":"response.completed","response":{"output":[...],...}}
/// ```
fn parse_sse_response(body: &str) -> Result<ResponseDTO, Box<dyn Error + Send + Sync>> {
let mut current_event: Option<String> = None;
let mut completed_data: Option<String> = None;
for line in body.lines() {
if let Some(event_name) = line.strip_prefix("event:") {
current_event = Some(event_name.trim().to_string());
} else if let Some(data_payload) = line.strip_prefix("data:") {
let data = data_payload.trim_start();
if current_event.as_deref() == Some("response.completed") {
completed_data = Some(data.to_string());
break;
}
}
// Empty lines are event boundaries in SSE — reset current_event
if line.is_empty() {
current_event = None;
}
}
let data = match completed_data {
Some(d) => d,
None => {
// Fallback: maybe the body is plain JSON (non-SSE), try parsing directly
log::warn!(
"No response.completed event found in SSE stream, trying direct JSON parse"
);
return serde_json::from_str::<ResponseDTO>(body.trim()).map_err(|e| {
log::error!(
"Direct JSON parse also failed. Body prefix: {}",
&body[..body.len().min(500)]
);
Box::new(OpenAIClientError::Deserialize {
source: e,
body: format!("sse_body={}", Self::format_body_excerpt(body, 4000)),
}) as Box<dyn Error + Send + Sync>
});
}
};
// The response.completed data has shape: {"type":"response.completed","response":{...}}
let wrapper: serde_json::Value = serde_json::from_str(&data).map_err(|e| {
log::error!(
"Failed to parse response.completed JSON: {}",
&data[..data.len().min(500)]
);
Box::new(OpenAIClientError::Deserialize {
source: e,
body: format!(
"response.completed data={}; sse_body={}",
Self::format_body_excerpt(&data, 1000),
Self::format_body_excerpt(body, 4000)
),
}) as Box<dyn Error + Send + Sync>
})?;
// Extract the "response" field which contains the actual response object
let response_obj = wrapper.get("response").unwrap_or(&wrapper);
serde_json::from_value::<ResponseDTO>(response_obj.clone()).map_err(|e| {
log::error!(
"Failed to deserialize ResponseDTO from: {}",
&response_obj.to_string()[..response_obj.to_string().len().min(500)]
);
Box::new(OpenAIClientError::Deserialize {
source: e,
body: format!(
"response_obj={}; sse_body={}",
Self::format_body_excerpt(&response_obj.to_string(), 1000),
Self::format_body_excerpt(body, 4000)
),
}) as Box<dyn Error + Send + Sync>
})
}
fn format_body_excerpt(body: &str, max_len: usize) -> String {
let trimmed = body.trim();
let total = trimmed.chars().count();
if total <= max_len {
trimmed.to_string()
} else {
let excerpt: String = trimmed.chars().take(max_len).collect();
format!(
"{}...[truncated {} chars]",
excerpt,
total.saturating_sub(max_len)
)
}
}
fn format_headers_excerpt(headers: &reqwest::header::HeaderMap) -> String {
let mut rendered = String::new();
for (name, value) in headers.iter() {
if !rendered.is_empty() {
rendered.push_str(", ");
}
let value_str = value.to_str().unwrap_or("<non-utf8>");
rendered.push_str(name.as_str());
rendered.push('=');
rendered.push_str(value_str);
if rendered.len() > 2000 {
rendered.push_str("...[truncated]");
break;
}
}
if rendered.is_empty() {
"<missing>".to_string()
} else {
rendered
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_sse_text_response() {
let sse_body = "\
event: response.created\n\
data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"output\":[]}}\n\
\n\
event: response.output_text.delta\n\
data: {\"type\":\"response.output_text.delta\",\"delta\":\"Hello\"}\n\
\n\
event: response.completed\n\
data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello, world!\"}]}]}}\n\
\n";
let dto = OpenAIClient::parse_sse_response(sse_body).expect("should parse SSE");
let (summary, tool_call) = dto.extract_parts();
assert_eq!(summary, "Hello, world!");
assert!(tool_call.is_none());
}
#[test]
fn parse_sse_function_call_response() {
let sse_body = "\
event: response.created\n\
data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_2\",\"output\":[]}}\n\
\n\
event: response.completed\n\
data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_2\",\"output\":[{\"type\":\"function_call\",\"name\":\"shell_exec\",\"arguments\":\"{\\\"command\\\":\\\"ls\\\"}\",\"call_id\":\"call_abc\"}]}}\n\
\n";
let dto = OpenAIClient::parse_sse_response(sse_body).expect("should parse SSE");
let (summary, tool_call) = dto.extract_parts();
assert!(summary.is_empty());
let call = tool_call.expect("should have tool call");
assert_eq!(call.name, "shell_exec");
assert_eq!(call.call_id, "call_abc");
}
#[test]
fn parse_sse_mixed_text_and_function_call() {
let sse_body = "\
event: response.completed\n\
data: {\"type\":\"response.completed\",\"response\":{\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"Let me check.\"}]},{\"type\":\"function_call\",\"name\":\"read_file\",\"arguments\":\"{}\",\"call_id\":\"call_xyz\"}]}}\n\
\n";
let dto = OpenAIClient::parse_sse_response(sse_body).expect("should parse SSE");
let (summary, tool_call) = dto.extract_parts();
assert_eq!(summary, "Let me check.");
assert!(tool_call.is_some());
}
#[test]
fn parse_sse_no_completed_event_falls_back_to_direct_json() {
// If body is plain JSON (not SSE), should still work
let json_body = "{\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"Direct JSON\"}]}]}";
let dto = OpenAIClient::parse_sse_response(json_body).expect("should parse direct JSON");
let (summary, _) = dto.extract_parts();
assert_eq!(summary, "Direct JSON");
}
#[test]
fn parse_sse_empty_body_errors() {
let result = OpenAIClient::parse_sse_response("");
assert!(result.is_err());
}
#[test]
fn parse_sse_ignores_earlier_events() {
// Ensure we pick up data from response.completed, not from earlier events
let sse_body = "\
event: response.created\n\
data: {\"type\":\"response.created\",\"response\":{\"id\":\"r1\",\"output\":[]}}\n\
\n\
event: response.in_progress\n\
data: {\"type\":\"response.in_progress\"}\n\
\n\
event: response.completed\n\
data: {\"type\":\"response.completed\",\"response\":{\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"Final answer\"}]}]}}\n\
\n";
let dto = OpenAIClient::parse_sse_response(sse_body).expect("should parse");
let (summary, _) = dto.extract_parts();
assert_eq!(summary, "Final answer");
}
}

View file

@ -141,7 +141,7 @@ impl RequestDTO {
tool_choice: "auto".to_string(),
parallel_tool_calls: false,
store: false,
stream: false,
stream: true,
}
}
}

View file

@ -0,0 +1,2 @@
pub mod oauth;
pub use oauth::{initiate_oauth_flow, refresh_oauth_tokens};

View file

@ -0,0 +1,224 @@
use oauth2::{
AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge,
PkceCodeVerifier, RedirectUrl, RefreshToken, Scope, TokenResponse, TokenUrl,
};
use oauth2::basic::BasicClient;
use oauth2::reqwest::http_client;
use sha2::{Digest, Sha256};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use tiny_http::{Response, Server};
const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
const AUTH_URL: &str = "https://auth.openai.com/oauth/authorize";
const TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
const REDIRECT_URI: &str = "http://localhost:1455/auth/callback";
const SCOPES: &[&str] = &["openid", "profile", "email", "offline_access"];
pub struct OAuthTokens {
pub access_token: String,
pub refresh_token: String,
pub expires_in: i64,
pub account_id: Option<String>,
}
/// Generates a cryptographically secure random string for PKCE
fn generate_random_string(length: usize) -> String {
use rand::Rng;
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
let mut rng = rand::thread_rng();
(0..length)
.map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
.collect()
}
/// Initiates OAuth flow and returns tokens
pub fn initiate_oauth_flow() -> Result<OAuthTokens, String> {
// Generate PKCE code verifier and challenge
let code_verifier = generate_random_string(43);
let mut hasher = Sha256::new();
hasher.update(code_verifier.as_bytes());
use base64::Engine;
let _challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(hasher.finalize());
let pkce_verifier = PkceCodeVerifier::new(code_verifier);
let pkce_challenge = PkceCodeChallenge::from_code_verifier_sha256(&pkce_verifier);
// Build OAuth client
let client = BasicClient::new(
ClientId::new(CLIENT_ID.to_string()),
None,
AuthUrl::new(AUTH_URL.to_string()).map_err(|e| e.to_string())?,
Some(TokenUrl::new(TOKEN_URL.to_string()).map_err(|e| e.to_string())?),
)
.set_redirect_uri(RedirectUrl::new(REDIRECT_URI.to_string()).map_err(|e| e.to_string())?);
// Generate authorization URL with OpenCode-specific parameters
let (mut auth_url, csrf_state) = client
.authorize_url(CsrfToken::new_random)
.add_scopes(SCOPES.iter().map(|s| Scope::new(s.to_string())))
.set_pkce_challenge(pkce_challenge)
.url();
// Add OpenCode-specific query parameters
auth_url.query_pairs_mut()
.append_pair("id_token_add_organizations", "true")
.append_pair("codex_cli_simplified_flow", "true")
.append_pair("originator", "drastis");
// Start local HTTP server for callback
let server = Server::http("127.0.0.1:1455")
.map_err(|e| format!("Failed to start callback server on port 1455: {}", e))?;
log::info!("Starting OAuth flow, opening browser...");
// Open browser
open::that(auth_url.to_string())
.map_err(|e| format!("Failed to open browser: {}", e))?;
// Wait for callback with timeout
let (tx, rx) = mpsc::channel();
let csrf_secret = csrf_state.secret().clone();
thread::spawn(move || {
// Listen for callback (2 minute timeout)
if let Ok(Some(request)) = server.recv_timeout(Duration::from_secs(120)) {
let url_str = format!("http://localhost{}", request.url());
// Send success HTML response
let html = r#"
<html>
<head><title>Authentication Successful</title></head>
<body>
<h1>✓ Authentication Successful</h1>
<p>You can close this window and return to the CLI.</p>
<script>setTimeout(() => window.close(), 2000);</script>
</body>
</html>
"#;
let _ = request.respond(Response::from_string(html)
.with_header(tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html"[..]).unwrap()));
let _ = tx.send(url_str);
}
});
// Wait for callback
let callback_url = rx.recv_timeout(Duration::from_secs(120))
.map_err(|_| "OAuth timeout: No response received within 2 minutes".to_string())?;
// Parse callback URL
let url = url::Url::parse(&callback_url)
.map_err(|e| format!("Invalid callback URL: {}", e))?;
// Extract code and state from query parameters
let mut code = None;
let mut state = None;
for (key, value) in url.query_pairs() {
match key.as_ref() {
"code" => code = Some(value.to_string()),
"state" => state = Some(value.to_string()),
_ => {}
}
}
let code = code.ok_or("No authorization code in callback")?;
let state = state.ok_or("No state in callback")?;
// Verify CSRF token
if state != csrf_secret {
return Err("CSRF token mismatch - possible security attack".to_string());
}
// Exchange code for tokens
log::info!("Exchanging authorization code for tokens...");
let token_response = client
.exchange_code(AuthorizationCode::new(code))
.set_pkce_verifier(pkce_verifier)
.request(http_client)
.map_err(|e| format!("Token exchange failed: {}", e))?;
let access_token = token_response.access_token().secret().clone();
let refresh_token = token_response.refresh_token()
.ok_or("No refresh token received")?
.secret()
.clone();
let expires_in = token_response.expires_in()
.ok_or("No expiration time received")?
.as_secs() as i64;
// Extract account ID from access token (JWT)
let account_id = extract_account_id_from_token(&access_token);
log::info!("OAuth authentication successful!");
Ok(OAuthTokens {
access_token,
refresh_token,
expires_in,
account_id,
})
}
/// Refreshes OAuth tokens using refresh token
pub fn refresh_oauth_tokens(refresh_token: &str) -> Result<OAuthTokens, String> {
let client = BasicClient::new(
ClientId::new(CLIENT_ID.to_string()),
None,
AuthUrl::new(AUTH_URL.to_string()).map_err(|e| e.to_string())?,
Some(TokenUrl::new(TOKEN_URL.to_string()).map_err(|e| e.to_string())?),
);
log::info!("Refreshing OAuth tokens...");
let token_response = client
.exchange_refresh_token(&RefreshToken::new(refresh_token.to_string()))
.request(http_client)
.map_err(|e| format!("Token refresh failed: {}", e))?;
let access_token = token_response.access_token().secret().clone();
let refresh_token = token_response.refresh_token()
.map(|t| t.secret().clone())
.unwrap_or_else(|| refresh_token.to_string());
let expires_in = token_response.expires_in()
.ok_or("No expiration time received")?
.as_secs() as i64;
let account_id = extract_account_id_from_token(&access_token);
Ok(OAuthTokens {
access_token,
refresh_token,
expires_in,
account_id,
})
}
/// Extracts ChatGPT account ID from JWT token
fn extract_account_id_from_token(token: &str) -> Option<String> {
use base64::Engine;
// JWT format: header.payload.signature
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return None;
}
// Decode payload (base64url)
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(parts[1])
.ok()?;
// Parse JSON
let json: serde_json::Value = serde_json::from_slice(&payload).ok()?;
// Extract chatgpt_account_id
json.get("chatgpt_account_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}

View file

@ -1,3 +1,4 @@
pub mod change_settings;
pub mod insert_openai_key;
pub mod oauth_login;
pub mod select_openai_model;

View file

@ -0,0 +1,48 @@
use crate::infrastructure::auth::initiate_oauth_flow;
use crate::infrastructure::cli::repl::refresh_settings_from_db;
use crate::infrastructure::cli::state::UiState;
use crate::infrastructure::db::DbPool;
use crate::infrastructure::event_bus::{EventBus, UiToAgentEvent};
use crate::repository::UserSettingsRepository;
pub fn start_oauth_flow(
bus: &EventBus,
conn: &DbPool,
state: &mut UiState,
) -> Result<(), String> {
log::info!("Starting OpenAI OAuth login flow...");
// Initiate OAuth flow (blocks until complete or timeout)
let tokens = initiate_oauth_flow()?;
// Store tokens in database
let conn_guard = conn
.get()
.map_err(|e| format!("Database connection failed: {}", e))?;
let settings_repo = UserSettingsRepository::new(&*conn_guard);
settings_repo.update_oauth_tokens(
&tokens.access_token,
&tokens.refresh_token,
tokens.expires_in,
tokens.account_id.as_deref(),
)?;
log::info!("OAuth tokens stored successfully");
// Notify event controller to reload engine with OAuth credentials
let _ = bus.ui_to_agent_tx.send(UiToAgentEvent::SettingsUpdateEvent {
model: None,
openai_api_key: None,
use_behavior_trees: None,
openai_tracing_enabled: None,
web_search_enabled: None,
max_tool_calls_per_request: None,
brave_api_key: None,
});
// Refresh UI state from database
refresh_settings_from_db(conn, state)?;
Ok(())
}

View file

@ -35,7 +35,7 @@ pub fn handle_model_entry(
refresh_settings_from_db(conn, state)?;
state.mode = UiMode::Normal;
}
ModelEntry::OpenAi { name } => {
ModelEntry::OpenAi { name, .. } => {
update_model_selection_in_db(conn, &ModelSelection::OpenAiModel(name.clone()))?;
let _ = bus
.ui_to_agent_tx
@ -52,17 +52,7 @@ pub fn handle_model_entry(
state.mode = UiMode::Normal;
}
ModelEntry::OpenAiSelect => {
if insert_openai_key::api_key_missing(conn)? {
insert_openai_key::begin_prompt(state, false);
} else {
state.models.openai_available_status = LoadStatus::Loading;
state.openai_fetch_pending = true;
state.mode = UiMode::Popup(PopupState::OpenAiAvailable {
selected: 0,
filter: String::new(),
cursor: 0,
});
}
state.mode = UiMode::Popup(PopupState::AuthMethodSelect { selected: 0 });
}
ModelEntry::Loading | ModelEntry::Empty => {}
}
@ -70,6 +60,42 @@ pub fn handle_model_entry(
Ok(())
}
pub fn handle_auth_method_api_key(
conn: &DbPool,
state: &mut UiState,
) -> Result<(), String> {
if insert_openai_key::api_key_missing(conn)? {
insert_openai_key::begin_prompt(state, false);
} else {
state.models.openai_available_status = LoadStatus::Loading;
state.openai_fetch_pending = true;
state.mode = UiMode::Popup(PopupState::OpenAiAvailable {
selected: 0,
filter: String::new(),
cursor: 0,
});
}
Ok(())
}
pub fn handle_auth_method_oauth(
bus: &EventBus,
conn: &DbPool,
state: &mut UiState,
) -> Result<(), String> {
use crate::infrastructure::cli::actions::oauth_login;
oauth_login::start_oauth_flow(bus, conn, state)?;
// After successful OAuth, proceed to model selection
state.models.openai_available_status = LoadStatus::Loading;
state.openai_fetch_pending = true;
state.mode = UiMode::Popup(PopupState::OpenAiAvailable {
selected: 0,
filter: String::new(),
cursor: 0,
});
Ok(())
}
pub fn handle_openai_available_selection(
bus: &EventBus,
conn: &DbPool,
@ -91,4 +117,4 @@ pub fn handle_openai_available_selection(
refresh_settings_from_db(conn, state)?;
state.mode = UiMode::Normal;
Ok(())
}
}

View file

@ -16,7 +16,6 @@ pub fn render(
scope: &str,
selected: usize,
theme: Theme,
is_read_only: bool,
) {
let width = min(
(size.width as f32 * 0.7) as u16,
@ -58,7 +57,7 @@ pub fn render(
width: area.width,
height: 5,
};
let options = permission_options(command.is_some(), is_read_only);
let options = permission_options(command.is_some());
let items: Vec<ListItem> = options
.iter()
.map(|option| ListItem::new(Line::from(Span::raw(*option))))
@ -75,23 +74,17 @@ pub fn render(
frame.render_stateful_widget(list, options_area, &mut list_state);
}
fn permission_options(has_command: bool, is_read_only: bool) -> Vec<&'static str> {
fn permission_options(has_command: bool) -> Vec<&'static str> {
if has_command {
vec![
"[A] Allow",
"[C] Allow this Command for this Project",
"[D] Disallow",
]
} else if is_read_only {
vec![
"[A] Allow",
"[R] Allow All Reads in Project for this Session",
"[D] Disallow",
]
} else {
vec![
"[A] Allow",
"[W] Allow All Writes in Project for this Session",
"[W] Allow All Writes in this Project",
"[D] Disallow",
]
}

View file

@ -1,3 +1,4 @@
use crate::domain::AuthMethod;
use crate::infrastructure::cli::helpers::{checkbox_item, list_state, panel_block};
use crate::infrastructure::cli::theme::{Theme, PANEL_PADDING};
use ratatui::style::{Modifier, Style};
@ -13,8 +14,29 @@ pub fn render(
openai_tracing: bool,
web_search: bool,
max_tool_calls: i32,
auth_method: &AuthMethod,
oauth_token_expiry: Option<i64>,
theme: Theme,
) {
// Determine auth status display
let auth_status = match auth_method {
AuthMethod::OAuth => {
let expiry_msg = if let Some(exp) = oauth_token_expiry {
let now = chrono::Utc::now().timestamp();
if exp > now {
let mins_left = (exp - now) / 60;
format!(" (expires in {}m)", mins_left)
} else {
" (EXPIRED - re-login needed)".to_string()
}
} else {
"".to_string()
};
format!("OAuth{}", expiry_msg)
}
AuthMethod::ApiKey => "API Key".to_string(),
};
let items = vec![
checkbox_item("Behavior trees", behavior_trees),
checkbox_item("OpenAI tracing", openai_tracing),
@ -26,6 +48,13 @@ pub fn render(
Style::default().fg(theme.active),
),
])),
ListItem::new(Line::from(vec![
Span::raw("Auth: "),
Span::styled(
auth_status,
Style::default().fg(theme.active),
),
])),
];
let list = List::new(items)
.highlight_style(

View file

@ -1,4 +1,4 @@
use crate::domain::permissions::PermissionDecision;
use crate::domain::permissions::UserPermissionDecision;
use crate::domain::AgentModeType;
use crate::infrastructure::cli::actions::{
change_settings, insert_openai_key, select_openai_model,
@ -24,6 +24,13 @@ pub fn handle_key_event(
state: &mut UiState,
key: KeyEvent,
) -> Result<(), String> {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
if state.request_in_flight.is_some() {
let _ = bus.ui_to_agent_tx.send(UiToAgentEvent::CancelRequest);
}
return Ok(());
}
let mode_snapshot = state.mode.clone();
match mode_snapshot {
UiMode::Normal => handle_normal_key(bus, conn, state, key),
@ -213,8 +220,8 @@ fn handle_normal_key(
// Enter right_container review mode for whatever is displayed
let has_file_changes = state.file_changes.is_some()
&& !state.file_changes.as_ref().unwrap().changes.is_empty();
let has_todo_list = state.todo_list.is_some()
&& !state.todo_list.as_ref().unwrap().items.is_empty();
let has_todo_list =
state.todo_list.is_some() && !state.todo_list.as_ref().unwrap().items.is_empty();
// Priority: file changes first (if both exist), otherwise whichever exists
if has_file_changes {
@ -237,8 +244,8 @@ fn handle_normal_key(
// Enter right_container review mode for whatever is displayed
let has_file_changes = state.file_changes.is_some()
&& !state.file_changes.as_ref().unwrap().changes.is_empty();
let has_todo_list = state.todo_list.is_some()
&& !state.todo_list.as_ref().unwrap().items.is_empty();
let has_todo_list =
state.todo_list.is_some() && !state.todo_list.as_ref().unwrap().items.is_empty();
// Priority: file changes first (if both exist), otherwise whichever exists
if has_file_changes {
@ -568,12 +575,17 @@ fn handle_popup_key(
}
_ => {}
},
PopupState::PermissionPrompt { selected, is_read_only, command, .. } => {
PopupState::PermissionPrompt {
selected,
is_read_only,
command,
..
} => {
let options_len = permissions::option_count();
let has_command = command.is_some();
match key.code {
KeyCode::Esc | KeyCode::Char('d') | KeyCode::Char('D') => {
submit_permission_decision(bus, popup, PermissionDecision::Ask)?;
submit_permission_decision(bus, popup, UserPermissionDecision::Deny)?;
state.mode = UiMode::Normal;
}
KeyCode::Up => {
@ -587,39 +599,45 @@ fn handle_popup_key(
}
}
KeyCode::Char('a') | KeyCode::Char('A') => {
submit_permission_decision(bus, popup, PermissionDecision::AllowOnce)?;
submit_permission_decision(bus, popup, UserPermissionDecision::AllowOnce)?;
state.mode = UiMode::Normal;
}
KeyCode::Char('c') | KeyCode::Char('C') => {
if has_command {
submit_permission_decision(bus, popup, PermissionDecision::AllowCommandForProject)?;
submit_permission_decision(
bus,
popup,
UserPermissionDecision::AlwaysAllow,
)?;
state.mode = UiMode::Normal;
}
}
KeyCode::Char('r') | KeyCode::Char('R') => {
if !has_command && *is_read_only {
submit_permission_decision(bus, popup, PermissionDecision::AllowAllReadsInSession)?;
submit_permission_decision(
bus,
popup,
UserPermissionDecision::AlwaysAllow,
)?;
state.mode = UiMode::Normal;
}
}
KeyCode::Char('w') | KeyCode::Char('W') => {
if !has_command && !*is_read_only {
submit_permission_decision(bus, popup, PermissionDecision::AllowAllWritesInSession)?;
submit_permission_decision(
bus,
popup,
UserPermissionDecision::AlwaysAllow,
)?;
state.mode = UiMode::Normal;
}
}
KeyCode::Enter => {
let decision = match *selected {
0 => PermissionDecision::AllowOnce,
1 => if has_command {
PermissionDecision::AllowCommandForProject
} else if *is_read_only {
PermissionDecision::AllowAllReadsInSession
} else {
PermissionDecision::AllowAllWritesInSession
},
2 => PermissionDecision::Ask,
_ => PermissionDecision::Ask,
0 => UserPermissionDecision::AllowOnce,
1 => UserPermissionDecision::AlwaysAllow,
2 => UserPermissionDecision::Deny,
_ => UserPermissionDecision::Deny,
};
submit_permission_decision(bus, popup, decision)?;
state.mode = UiMode::Normal;
@ -627,6 +645,29 @@ fn handle_popup_key(
_ => {}
}
}
PopupState::AuthMethodSelect { selected } => match key.code {
KeyCode::Esc => state.mode = UiMode::Popup(PopupState::ModelSelect { selected: 0 }),
KeyCode::Up => {
if *selected > 0 {
*selected -= 1;
}
}
KeyCode::Down => {
if *selected < 1 {
*selected += 1;
}
}
KeyCode::Enter => match *selected {
0 => {
select_openai_model::handle_auth_method_api_key(conn, state)?;
}
1 => {
select_openai_model::handle_auth_method_oauth(bus, conn, state)?;
}
_ => {}
},
_ => {}
},
PopupState::ContinueSelect { sessions, selected } => {
let count = sessions.len();
match key.code {
@ -644,9 +685,9 @@ fn handle_popup_key(
KeyCode::Enter => {
let session_id = sessions[*selected].id;
state.session_id = Some(session_id);
let _ = bus.ui_to_agent_tx.send(UiToAgentEvent::SessionContinueEvent {
session_id,
});
let _ = bus
.ui_to_agent_tx
.send(UiToAgentEvent::SessionContinueEvent { session_id });
state.mode = UiMode::Normal;
}
_ => {}
@ -862,7 +903,7 @@ fn handle_popup_input(state: &mut UiState, key: KeyEvent) {
fn submit_permission_decision(
bus: &EventBus,
popup: &PopupState,
decision: PermissionDecision,
decision: UserPermissionDecision,
) -> Result<(), String> {
if let PopupState::PermissionPrompt { request_id, .. } = popup {
bus.ui_to_agent_tx
@ -1328,14 +1369,12 @@ fn open_continue_popup(conn: &DbPool, state: &mut UiState) {
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_else(|| "default".to_string());
let projects_repo =
crate::repository::ProjectsRepository::new(&*conn_guard);
let projects_repo = crate::repository::ProjectsRepository::new(&*conn_guard);
let project = projects_repo
.find_by_name(&project_name)?
.ok_or_else(|| "not_found".to_string())?;
let sessions_repo =
crate::repository::SessionsRepository::new(&*conn_guard);
let sessions_repo = crate::repository::SessionsRepository::new(&*conn_guard);
let rows = sessions_repo.find_by_project_recent(project.id, 5)?;
if rows.is_empty() {

View file

@ -1,4 +1,4 @@
use crate::domain::{AgentModeType, ModelType};
use crate::domain::{AgentModeType, ModelAuthType, ModelType};
use crate::infrastructure::cli::state::{
FileChangesDisplay, LoadStatus, PopupState, ProgressEntry, ProgressKind, RequestIndicator,
RequestStatusDisplay, UiMode, UiState,
@ -180,9 +180,7 @@ fn handle_agent_event(
let can_update = state
.last_progress_update
.map(|last| {
std::time::Instant::now()
.duration_since(last)
.as_millis()
std::time::Instant::now().duration_since(last).as_millis()
>= crate::infrastructure::cli::state::MIN_PROGRESS_DISPLAY_MS
})
.unwrap_or(true); // Update immediately if no previous update
@ -236,15 +234,29 @@ fn handle_agent_event(
}
}
// Only show summary for failures, skip for success
if let Some(summary) = summary {
// Show failures via summary; for successful requests, fallback to summary
// when final_message is missing/empty (common when resuming older sessions).
if let Some(summary_text) = summary {
if status != RequestStatus::Success {
state.push_progress(ProgressEntry {
text: summary,
text: summary_text,
kind: finish_kind,
active: false,
request_id: None,
});
} else {
let final_is_empty = final_message
.as_ref()
.map(|m| m.trim().is_empty())
.unwrap_or(true);
if final_is_empty {
state.push_progress(ProgressEntry {
text: summary_text,
kind: ProgressKind::Info,
active: false,
request_id: None,
});
}
}
}
if let Some(message) = final_message {
@ -339,6 +351,7 @@ pub(crate) fn refresh_models_from_db(conn: &DbPool, state: &mut UiState) -> Resu
.map(|name| crate::infrastructure::event_bus::OpenAiModelInfo {
_id: model.id,
name,
auth_type: model.auth_type.as_str().to_string(),
})
})
.collect();
@ -362,6 +375,8 @@ pub(crate) fn refresh_settings_from_db(conn: &DbPool, state: &mut UiState) -> Re
state.settings.openai_tracing_enabled = settings.openai_tracing_enabled;
state.settings.web_search_enabled = settings.web_search_enabled;
state.settings.max_tool_calls_per_request = settings.max_tool_calls_per_request;
state.settings.auth_method = crate::domain::AuthMethod::from_str(&settings.auth_method);
state.settings.oauth_token_expiry = settings.oauth_token_expiry;
state.settings.status = LoadStatus::Loaded;
// Get both model name and type
@ -437,10 +452,23 @@ pub(crate) fn update_model_selection_in_db(
})
});
let model_id = match existing_model {
Some(model) => model.id,
Some(model) => {
if model.auth_type != ModelAuthType::Local {
models_repo
.update_auth_type(model.id, ModelAuthType::Local)
.map_err(|e| e.to_string())?;
}
model.id
}
None => {
models_repo
.create(ModelType::Local, None, Some(&gguf_path), None)
.create(
ModelType::Local,
None,
Some(&gguf_path),
None,
ModelAuthType::Local,
)
.map_err(|e| e.to_string())?
.id
}
@ -457,14 +485,24 @@ pub(crate) fn update_model_selection_in_db(
let existing_models = models_repo
.find_by_type(ModelType::OpenAI)
.map_err(|e| e.to_string())?;
let settings = settings_repo.get_current().map_err(|e| e.to_string())?;
let auth_method = crate::domain::AuthMethod::from_str(&settings.auth_method);
let auth_type = ModelAuthType::from_auth_method(&auth_method);
let model_id = match existing_models
.iter()
.find(|model| model.model_name.as_deref() == Some(model_name))
{
Some(model) => model.id,
Some(model) => {
if model.auth_type != auth_type {
models_repo
.update_auth_type(model.id, auth_type)
.map_err(|e| e.to_string())?;
}
model.id
}
None => {
models_repo
.create(ModelType::OpenAI, None, None, Some(model_name))
.create(ModelType::OpenAI, None, None, Some(model_name), auth_type)
.map_err(|e| e.to_string())?
.id
}
@ -491,18 +529,63 @@ pub(crate) fn fetch_openai_available_models(
.map_err(|e| format!("Failed to get connection: {}", e))?;
let settings_repo = UserSettingsRepository::new(&*conn_guard);
let settings = settings_repo.get_current().map_err(|e| e.to_string())?;
let api_key = match settings.openai_api_key {
Some(key) => key,
None => {
state.models.openai_available =
vec!["OpenAI API key is required. Please set it in settings.".to_string()];
state.models.openai_available_status = LoadStatus::Loaded;
return Ok(());
let auth_method = crate::domain::AuthMethod::from_str(&settings.auth_method);
let auth_token = if auth_method == crate::domain::AuthMethod::OAuth {
match (&settings.oauth_access_token, &settings.oauth_account_id) {
(Some(token), account_id) => {
crate::infrastructure::api_clients::openai::client::AuthToken::OAuth {
token: token.clone(),
account_id: account_id.clone(),
}
}
_ => {
state.models.openai_available =
vec!["OAuth token not found. Please re-authenticate.".to_string()];
state.models.openai_available_status = LoadStatus::Loaded;
return Ok(());
}
}
} else {
match &settings.openai_api_key {
Some(key) if !key.trim().is_empty() => {
crate::infrastructure::api_clients::openai::client::AuthToken::ApiKey(key.clone())
}
_ => {
state.models.openai_available =
vec!["OpenAI API key is required. Please set it in settings.".to_string()];
state.models.openai_available_status = LoadStatus::Loaded;
return Ok(());
}
}
};
// OAuth tokens can't call /v1/models — return known Codex/ChatGPT models instead
if auth_method == crate::domain::AuthMethod::OAuth {
state.models.openai_available = vec![
"gpt-5.3-codex-spark".to_string(),
"gpt-5.3-codex".to_string(),
"gpt-5.2-codex".to_string(),
"gpt-5.2".to_string(),
"gpt-5.1-codex-max".to_string(),
"gpt-5.1-codex".to_string(),
"gpt-5.1".to_string(),
"gpt-5-codex".to_string(),
"gpt-5-codex-mini".to_string(),
"gpt-5".to_string(),
"gpt-4.1".to_string(),
"gpt-4.1-mini".to_string(),
"gpt-4o".to_string(),
"gpt-4o-mini".to_string(),
"o3".to_string(),
"o4-mini".to_string(),
];
state.models.openai_available_status = LoadStatus::Loaded;
return Ok(());
}
state.models.openai_available_status = LoadStatus::Loading;
match OpenAIEngine::new_general(&api_key, "gpt-4") {
match OpenAIEngine::new_general(auth_token, "gpt-4") {
Ok(openai) => match openai.fetch_available_models() {
Ok(models) => {
state.models.openai_available = models;
@ -637,6 +720,7 @@ mod tests {
state.models.openai = vec![OpenAiModelInfo {
_id: 1,
name: "gpt-4".to_string(),
auth_type: "api_key".to_string(),
}];
let entries = model_entries(&state);
assert_eq!(entries.len(), 3);
@ -734,4 +818,4 @@ mod tests {
assert_eq!(model.gguf_file_path.as_deref(), Some(canonical.as_str()));
});
}
}
}

View file

@ -1,4 +1,5 @@
use crate::domain::tools::FileChange;
use crate::domain::AuthMethod;
use crate::domain::AgentModeType;
use crate::infrastructure::cli::loading_bar::LoadingBar;
use crate::infrastructure::event_bus::{LocalModelInfo, OpenAiModelInfo};
@ -118,6 +119,8 @@ pub struct SettingsCache {
pub openai_tracing_enabled: bool,
pub web_search_enabled: bool,
pub max_tool_calls_per_request: i32,
pub auth_method: AuthMethod,
pub oauth_token_expiry: Option<i64>,
}
#[derive(Debug, Clone)]
@ -206,6 +209,9 @@ pub enum PopupState {
sessions: Vec<SessionPreview>,
selected: usize,
},
AuthMethodSelect {
selected: usize,
},
}
#[derive(Debug, Clone)]
@ -297,6 +303,8 @@ impl UiState {
openai_tracing_enabled: false,
web_search_enabled: false,
max_tool_calls_per_request: 50,
auth_method: AuthMethod::ApiKey,
oauth_token_expiry: None,
},
loading_bar: LoadingBar::new(),
openai_fetch_pending: false,

View file

@ -144,7 +144,13 @@ fn render_popup(
.map(|entry| {
let label = match entry {
ModelEntry::Local { name, .. } => format!("Local: {}", name),
ModelEntry::OpenAi { name } => format!("OpenAI: {}", name),
ModelEntry::OpenAi { name, auth_type } => {
if auth_type.trim().is_empty() {
format!("OpenAI: {}", name)
} else {
format!("OpenAI: {} ({})", name, auth_type)
}
}
ModelEntry::OpenAiSelect => "OpenAI: Select New Model".to_string(),
ModelEntry::Loading => "Loading models...".to_string(),
ModelEntry::Empty => "No models available".to_string(),
@ -272,6 +278,8 @@ fn render_popup(
*openai_tracing,
*web_search,
*max_tool_calls,
&state.settings.auth_method,
state.settings.oauth_token_expiry,
theme,
);
}
@ -423,7 +431,6 @@ fn render_popup(
paths,
scope,
selected,
is_read_only,
..
} => {
permissions::render(
@ -435,9 +442,36 @@ fn render_popup(
scope,
*selected,
theme,
*is_read_only,
);
}
PopupState::AuthMethodSelect { selected } => {
let height = 7u16;
let width = min(
(size.width as f32 * 0.7) as u16,
size.width.saturating_sub(2),
);
let area = centered_rect(size, width, height);
frame.render_widget(Clear, area);
let api_key_item = ListItem::new(Line::from(Span::styled(
"API Key",
Style::default().fg(theme.info_text),
)));
let oauth_item = ListItem::new(Line::from(Span::styled(
"ChatGPT Plus (OAuth)",
Style::default().fg(theme.info_text),
)));
let list = List::new(vec![api_key_item, oauth_item])
.highlight_style(
Style::default()
.fg(theme.active)
.add_modifier(Modifier::BOLD),
)
.block(panel_block(theme, theme.panel, PANEL_PADDING))
.highlight_symbol("> ");
let mut list_state = list_state(*selected);
frame.render_stateful_widget(list, area, &mut list_state);
}
PopupState::ContinueSelect { sessions, selected } => {
if sessions.is_empty() {
return;
@ -496,7 +530,7 @@ fn render_popup(
#[derive(Debug, Clone)]
pub enum ModelEntry {
Local { name: String, path: String },
OpenAi { name: String },
OpenAi { name: String, auth_type: String },
OpenAiSelect,
Loading,
Empty,
@ -517,6 +551,7 @@ pub fn model_entries(state: &UiState) -> Vec<ModelEntry> {
for model in &state.models.openai {
entries.push(ModelEntry::OpenAi {
name: model.name.clone(),
auth_type: model.auth_type.clone(),
});
}
entries.push(ModelEntry::OpenAiSelect);

View file

@ -846,6 +846,161 @@ fn run_migrations(conn: &Connection) -> Result<(), String> {
log::info!("Database migration to version 17 completed");
}
// Migration v18: Add OAuth columns to user_settings
if current_version < 18 {
log::info!("Migrating database from version {} to 18", current_version);
// Check and add auth_method column
let auth_method_exists = conn
.prepare(
"SELECT name FROM pragma_table_info('user_settings') WHERE name = 'auth_method'",
)
.and_then(|mut stmt| {
stmt.query_map([], |row| row.get::<_, String>(0))
.map(|mut rows| rows.next().is_some())
})
.unwrap_or(false);
if !auth_method_exists {
log::info!("Adding auth_method column to user_settings table");
conn.execute(
"ALTER TABLE user_settings ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'api_key'",
[],
)
.map_err(|e| format!("Failed to add auth_method column: {}", e))?;
}
// Check and add oauth_access_token column
let oauth_access_token_exists = conn
.prepare("SELECT name FROM pragma_table_info('user_settings') WHERE name = 'oauth_access_token'")
.and_then(|mut stmt| {
stmt.query_map([], |row| row.get::<_, String>(0))
.map(|mut rows| rows.next().is_some())
})
.unwrap_or(false);
if !oauth_access_token_exists {
log::info!("Adding oauth_access_token column to user_settings table");
conn.execute(
"ALTER TABLE user_settings ADD COLUMN oauth_access_token TEXT",
[],
)
.map_err(|e| format!("Failed to add oauth_access_token column: {}", e))?;
}
// Check and add oauth_refresh_token column
let oauth_refresh_token_exists = conn
.prepare("SELECT name FROM pragma_table_info('user_settings') WHERE name = 'oauth_refresh_token'")
.and_then(|mut stmt| {
stmt.query_map([], |row| row.get::<_, String>(0))
.map(|mut rows| rows.next().is_some())
})
.unwrap_or(false);
if !oauth_refresh_token_exists {
log::info!("Adding oauth_refresh_token column to user_settings table");
conn.execute(
"ALTER TABLE user_settings ADD COLUMN oauth_refresh_token TEXT",
[],
)
.map_err(|e| format!("Failed to add oauth_refresh_token column: {}", e))?;
}
// Check and add oauth_token_expiry column
let oauth_token_expiry_exists = conn
.prepare("SELECT name FROM pragma_table_info('user_settings') WHERE name = 'oauth_token_expiry'")
.and_then(|mut stmt| {
stmt.query_map([], |row| row.get::<_, String>(0))
.map(|mut rows| rows.next().is_some())
})
.unwrap_or(false);
if !oauth_token_expiry_exists {
log::info!("Adding oauth_token_expiry column to user_settings table");
conn.execute(
"ALTER TABLE user_settings ADD COLUMN oauth_token_expiry INTEGER",
[],
)
.map_err(|e| format!("Failed to add oauth_token_expiry column: {}", e))?;
}
// Check and add oauth_account_id column
let oauth_account_id_exists = conn
.prepare("SELECT name FROM pragma_table_info('user_settings') WHERE name = 'oauth_account_id'")
.and_then(|mut stmt| {
stmt.query_map([], |row| row.get::<_, String>(0))
.map(|mut rows| rows.next().is_some())
})
.unwrap_or(false);
if !oauth_account_id_exists {
log::info!("Adding oauth_account_id column to user_settings table");
conn.execute(
"ALTER TABLE user_settings ADD COLUMN oauth_account_id TEXT",
[],
)
.map_err(|e| format!("Failed to add oauth_account_id column: {}", e))?;
}
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', '18')",
[],
)
.map_err(|e| format!("Failed to update schema_version: {}", e))?;
log::info!("Database migration to version 18 completed");
}
// Migration v19: add auth_type to models
if current_version < 19 {
log::info!("Migrating database from version {} to 19", current_version);
let auth_type_exists = conn
.prepare("SELECT name FROM pragma_table_info('models') WHERE name = 'auth_type'")
.and_then(|mut stmt| {
stmt.query_map([], |row| row.get::<_, String>(0))
.map(|mut rows| rows.next().is_some())
})
.unwrap_or(false);
if !auth_type_exists {
log::info!("Adding auth_type column to models table");
conn.execute(
"ALTER TABLE models ADD COLUMN auth_type TEXT NOT NULL DEFAULT 'local'",
[],
)
.map_err(|e| format!("Failed to add auth_type column: {}", e))?;
}
let openai_auth_type: String = conn
.query_row(
"SELECT auth_method FROM user_settings WHERE id = 1",
[],
|row| row.get(0),
)
.unwrap_or_else(|_| "api_key".to_string());
conn.execute(
"UPDATE models SET auth_type = 'local' WHERE type = 'local'",
[],
)
.map_err(|e| format!("Failed to backfill local auth_type: {}", e))?;
conn.execute(
"UPDATE models SET auth_type = ? WHERE type = 'openai' AND (auth_type IS NULL OR auth_type = '')",
params![openai_auth_type],
)
.map_err(|e| format!("Failed to backfill OpenAI auth_type: {}", e))?;
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', '19')",
[],
)
.map_err(|e| format!("Failed to update schema_version: {}", e))?;
log::info!("Database migration to version 19 completed");
}
conn.execute(
"INSERT OR IGNORE INTO user_settings (id, openai_tracing_enabled, use_behavior_trees, web_search_enabled, max_tool_calls_per_request) VALUES (1, 0, 0, 0, 50)",
[],

View file

@ -1,8 +1,8 @@
use crate::domain::permissions::PermissionDecision;
use crate::domain::permissions::UserPermissionDecision;
use crate::domain::todo::TodoItem;
use crate::domain::tools::FileChange;
use crate::domain::AgentModeType;
use crossbeam_channel::{unbounded, Receiver, Sender};
use crate::domain::todo::TodoItem;
#[derive(Debug, Clone)]
pub enum StepPhase {
@ -33,6 +33,7 @@ pub struct LocalModelInfo {
pub struct OpenAiModelInfo {
pub _id: i64,
pub name: String,
pub auth_type: String,
}
#[derive(Debug, Clone)]
@ -48,12 +49,13 @@ pub enum UiToAgentEvent {
mode: AgentModeType, // NEW: Pass mode to agent
session_id: Option<i64>,
},
CancelRequest,
SessionContinueEvent {
session_id: i64,
},
PermissionUpdateEvent {
request_id: u64,
decision: PermissionDecision,
decision: UserPermissionDecision,
},
ShutdownEvent,
SettingsUpdateEvent {
@ -109,7 +111,7 @@ pub enum AgentToUiEvent {
#[derive(Debug, Clone)]
pub struct PermissionUpdate {
pub request_id: u64,
pub decision: PermissionDecision,
pub decision: UserPermissionDecision,
}
#[derive(Clone, Debug)]

View file

@ -1,13 +1,12 @@
use crate::domain::permissions::{PermissionConfig, PermissionDecision, PermissionPrompter};
use crate::domain::permissions::{PermissionConfig, PermissionPrompter, UserPermissionDecision};
use crate::domain::{CancellationToken, SessionService, StartupService};
use crate::infrastructure::db::DbPool;
use crate::infrastructure::event_bus::{
AgentToUiEvent, EventBus, ImageAttachment, PermissionUpdate, RequestStatus, StepPhase, UiToAgentEvent,
AgentToUiEvent, EventBus, ImageAttachment, PermissionUpdate, RequestStatus, StepPhase,
UiToAgentEvent,
};
use crate::infrastructure::inference::InferenceEngine;
use crate::infrastructure::init::{
apply_model_selection, update_openai_api_key,
};
use crate::infrastructure::init::{apply_model_selection, update_openai_api_key};
use crate::repository::UserSettingsRepository;
use crate::{domain, infrastructure};
use crossbeam_channel::{select, unbounded, Receiver, Sender};
@ -18,11 +17,7 @@ use std::sync::Arc;
use std::thread;
/// Helper function to send a failure event to the UI
fn send_failure_event(
tx: &Sender<AgentToUiEvent>,
request_id: u64,
error: impl Into<String>,
) {
fn send_failure_event(tx: &Sender<AgentToUiEvent>, request_id: u64, error: impl Into<String>) {
let _ = tx.send(AgentToUiEvent::RequestFinishedEvent {
request_id,
status: RequestStatus::Failure,
@ -85,7 +80,12 @@ pub struct EventController {
cancel_token: CancellationToken,
permission_response_tx: Option<Sender<PermissionUpdate>>,
request_counter: u64,
pending_request: Option<(String, Vec<ImageAttachment>, crate::domain::AgentModeType, Option<i64>)>,
pending_request: Option<(
String,
Vec<ImageAttachment>,
crate::domain::AgentModeType,
Option<i64>,
)>,
}
impl EventController {
@ -188,20 +188,74 @@ impl EventController {
}
};
if let Some(last_request) = requests.last() {
let label = request_label(&last_request.prompt);
let status = match &last_request.result_summary {
let steps_repo = crate::repository::SessionRequestStepsRepository::new(self.conn.clone());
for request in &requests {
let label = request_label(&request.prompt);
let status = match &request.result_summary {
Some(summary) if summary.starts_with("Success") => RequestStatus::Success,
Some(_) => RequestStatus::Failure,
None => RequestStatus::Failure,
};
let summary = last_request.result_summary.clone();
let summary = request.result_summary.clone();
let _ = self.bus.agent_to_ui_tx.send(AgentToUiEvent::RequestStartedEvent {
request_id: 0,
label,
prompt: last_request.prompt.clone(),
prompt: request.prompt.clone(),
});
let steps = match steps_repo.load_steps_for_request(request.id) {
Ok(steps) => steps,
Err(e) => {
log::warn!(
"Failed to load steps for request {}: {}",
request.id,
e
);
Vec::new()
}
};
if steps.is_empty() {
if let Some(log) = &request._steps_log {
for line in log.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let _ = self.bus.agent_to_ui_tx.send(AgentToUiEvent::ProgressEvent {
step_name: "assistant_response".to_string(),
phase: StepPhase::Before,
summary: trimmed.to_string(),
});
let _ = self.bus.agent_to_ui_tx.send(AgentToUiEvent::ProgressEvent {
step_name: "assistant_response".to_string(),
phase: StepPhase::After,
summary: trimmed.to_string(),
});
}
}
} else {
for step in steps {
let step_name = step
.tool_name
.clone()
.unwrap_or_else(|| step.step_type.clone());
let summary = step.summary.clone();
let _ = self.bus.agent_to_ui_tx.send(AgentToUiEvent::ProgressEvent {
step_name: step_name.clone(),
phase: StepPhase::Before,
summary: summary.clone(),
});
let _ = self.bus.agent_to_ui_tx.send(AgentToUiEvent::ProgressEvent {
step_name,
phase: StepPhase::After,
summary,
});
}
}
let _ = self.bus.agent_to_ui_tx.send(AgentToUiEvent::RequestFinishedEvent {
request_id: 0,
status,
@ -299,6 +353,12 @@ impl EventController {
let _ = worker_status_tx.send(());
});
}
UiToAgentEvent::CancelRequest => {
if worker_running {
log::info!("Cancelling current request via Ctrl+C");
self.cancel_token.cancel();
}
}
UiToAgentEvent::PermissionUpdateEvent {
request_id,
decision,
@ -332,7 +392,8 @@ impl EventController {
match apply_model_selection(&self.conn, selection) {
Ok(engine) => {
self.engine = Some(engine);
self.current_session_id = None;
// Keep current session when switching models so ongoing
// conversation continues with a different model.
}
Err(err) => {
send_failure_and_continue!(self, err.to_string());
@ -443,7 +504,7 @@ fn run_request_worker(
use_behavior_trees,
PermissionConfig::default(),
prompter,
event_bus.agent_to_ui_tx.clone()
event_bus.agent_to_ui_tx.clone(),
)
.map_err(|e| format!("Failed to create session service: {}", e))?;
@ -462,32 +523,58 @@ fn run_request_worker(
} else {
chain.fail_reason
};
let _ = event_bus.agent_to_ui_tx.send(AgentToUiEvent::RequestFinishedEvent {
request_id,
status: RequestStatus::Failure,
summary: Some(reason),
final_message: None,
});
let _ = event_bus
.agent_to_ui_tx
.send(AgentToUiEvent::RequestFinishedEvent {
request_id,
status: RequestStatus::Failure,
summary: Some(reason),
final_message: None,
});
} else {
let _ = event_bus.agent_to_ui_tx.send(AgentToUiEvent::RequestFinishedEvent {
request_id,
status: RequestStatus::Success,
summary: Some(chain.get_summary()),
final_message: chain.final_message.map(|msg| msg.to_string()),
});
let _ = event_bus
.agent_to_ui_tx
.send(AgentToUiEvent::RequestFinishedEvent {
request_id,
status: RequestStatus::Success,
summary: Some(chain.get_summary()),
final_message: chain.final_message.map(|msg| msg.to_string()),
});
}
Ok(())
}
Err(domain::session::ServiceError::Workflow(domain::workflow::Error::Cancelled)) => {
let _ = event_bus.agent_to_ui_tx.send(AgentToUiEvent::RequestFinishedEvent {
request_id,
status: RequestStatus::Cancelled,
summary: Some("Cancelled".to_string()),
final_message: None,
});
let _ = event_bus
.agent_to_ui_tx
.send(AgentToUiEvent::RequestFinishedEvent {
request_id,
status: RequestStatus::Cancelled,
summary: Some("Cancelled".to_string()),
final_message: None,
});
Ok(())
}
Err(err) => Err(format!("Workflow error: {}", err)),
Err(err) => {
let err_str = err.to_string();
// Check if error is due to expired OAuth token
if err_str.contains("AuthExpired") || err_str.contains("OAuth token expired") {
let _ = event_bus
.agent_to_ui_tx
.send(AgentToUiEvent::RequestFinishedEvent {
request_id,
status: RequestStatus::Failure,
summary: Some(
"OAuth token expired. Please run /oauth-login to re-authenticate."
.to_string(),
),
final_message: None,
});
return Ok(());
}
Err(format!("Workflow error: {}", err))
}
}
}
@ -533,7 +620,7 @@ fn emit_chain_progress(
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::permissions::{PermissionDecision, PermissionRequest, PermissionScope};
use crate::domain::permissions::{PermissionRequest, PermissionScope, UserPermissionDecision};
use crate::infrastructure::db;
use crate::infrastructure::event_bus::{ModelSelection, UiToAgentEvent};
use crate::repository::{MetaRepository, ModelsRepository, UserSettingsRepository};
@ -646,6 +733,7 @@ mod tests {
assert!(model.is_some());
let model = model.unwrap();
assert!(model._api_key.is_none());
assert_eq!(model.auth_type, domain::ModelAuthType::ApiKey);
let settings_repo = UserSettingsRepository::new(&*conn_guard);
let settings = settings_repo.get_current().expect("settings");
@ -663,7 +751,7 @@ mod tests {
vec![],
PermissionScope::Project,
Some(1),
false, // is_read_only
false, // is_read_only
PathBuf::from("/tmp"),
);
@ -681,11 +769,11 @@ mod tests {
let _ = update_tx.send(PermissionUpdate {
request_id,
decision: PermissionDecision::AllowOnce,
decision: UserPermissionDecision::AllowOnce,
});
let decision = handler.join().expect("join handler");
assert_eq!(decision, PermissionDecision::AllowOnce);
assert_eq!(decision, UserPermissionDecision::AllowOnce);
}
}
@ -709,7 +797,7 @@ impl PermissionPrompter for BusPermissionPrompter {
fn ask_permission(
&self,
request: &domain::permissions::PermissionRequest,
) -> Result<PermissionDecision, crate::utils::AskError> {
) -> Result<UserPermissionDecision, crate::utils::AskError> {
let request_id = self.counter.fetch_add(1, Ordering::SeqCst);
let scope = match request.scope {
domain::permissions::PermissionScope::Session => "session",

View file

@ -1,5 +1,6 @@
use super::{InferenceEngine, LLMInferenceResult};
use crate::domain::ModelType;
use crate::infrastructure::api_clients::openai::client::AuthToken;
use crate::infrastructure::api_clients::openai::OpenAIClient;
use crate::infrastructure::InfaError;
use openai_api_rust::models::ModelsApi;
@ -12,11 +13,17 @@ pub struct OpenAIEngine {
}
impl OpenAIEngine {
/// Create a new OpenAI engine with the given API key and model
pub fn new(api_key: &str, model: &str) -> Result<Arc<dyn InferenceEngine>, String> {
let auth = Auth::new(api_key);
/// Create a new OpenAI engine with the given auth token and model
pub fn new(auth_token: AuthToken, model: &str) -> Result<Arc<dyn InferenceEngine>, String> {
// Extract API key string for openai_api_rust client (used for model listing)
let api_key_str = match &auth_token {
AuthToken::ApiKey(key) => key.clone(),
AuthToken::OAuth { token, .. } => token.clone(),
};
let auth = Auth::new(&api_key_str);
let client = OpenAI::new(auth, "https://api.openai.com/v1/");
let responses_client = OpenAIClient::new(api_key.to_string(), model.to_string());
let responses_client = OpenAIClient::new(auth_token, model.to_string());
let engine = Arc::new(Self {
client,
@ -26,11 +33,17 @@ impl OpenAIEngine {
Ok(engine as Arc<dyn InferenceEngine>)
}
/// Create a new OpenAI engine with the given API key and model
pub fn new_general(api_key: &str, model: &str) -> Result<Self, String> {
let auth = Auth::new(api_key);
/// Create a new OpenAI engine with the given auth token and model
pub fn new_general(auth_token: AuthToken, model: &str) -> Result<Self, String> {
// Extract API key string for openai_api_rust client (used for model listing)
let api_key_str = match &auth_token {
AuthToken::ApiKey(key) => key.clone(),
AuthToken::OAuth { token, .. } => token.clone(),
};
let auth = Auth::new(&api_key_str);
let client = OpenAI::new(auth, "https://api.openai.com/v1/");
let responses_client = OpenAIClient::new(api_key.to_string(), model.to_string());
let responses_client = OpenAIClient::new(auth_token, model.to_string());
let engine = Self {
client,

View file

@ -1,4 +1,6 @@
use crate::domain::ModelType;
use crate::domain::{ModelAuthType, ModelType};
use crate::infrastructure::api_clients::openai::client::AuthToken;
use crate::infrastructure::auth::refresh_oauth_tokens;
use crate::infrastructure::db;
use crate::infrastructure::db::DbPool;
use crate::infrastructure::event_bus::{LocalModelInfo, ModelSelection};
@ -30,6 +32,12 @@ pub enum InitError {
#[error("OpenAI model missing api_key")]
MissingApiKey,
#[error("OAuth token missing")]
MissingOAuthToken,
#[error("OAuth token expired - please re-authenticate")]
OAuthExpired,
#[error("failed to load model engine: {0}")]
ModelLoadError(String),
@ -152,11 +160,63 @@ impl InfrastructureInitializer {
ModelType::OpenAI => {
let settings_repo = UserSettingsRepository::new(conn);
let settings = settings_repo.get_current().map_err(InitError::Repository)?;
let api_key = settings.openai_api_key.ok_or(InitError::MissingApiKey)?;
// Use saved model_name, fallback to gpt-4 for backward compatibility
let model_name = model.model_name.as_deref().unwrap_or("gpt-4");
use crate::infrastructure::inference::openai::OpenAIEngine;
OpenAIEngine::new(&api_key, model_name).map_err(|e| InitError::ModelLoadError(e))
let auth_token = match model.auth_type {
ModelAuthType::OAuth => {
let mut access_token = settings
.oauth_access_token
.ok_or(InitError::MissingOAuthToken)?;
let refresh_token_val = settings
.oauth_refresh_token
.ok_or(InitError::MissingOAuthToken)?;
let account_id = settings.oauth_account_id;
// Check if token is expired and refresh if needed
if settings
.oauth_token_expiry
.map(|exp| chrono::Utc::now().timestamp() >= exp)
.unwrap_or(true)
{
log::info!("OAuth token expired at startup, refreshing...");
let new_tokens =
refresh_oauth_tokens(&refresh_token_val).map_err(|e| {
InitError::ModelLoadError(format!(
"Token refresh failed: {}",
e
))
})?;
settings_repo
.update_oauth_tokens(
&new_tokens.access_token,
&new_tokens.refresh_token,
new_tokens.expires_in,
new_tokens.account_id.as_deref(),
)
.map_err(InitError::Repository)?;
access_token = new_tokens.access_token;
}
AuthToken::OAuth {
token: access_token,
account_id,
}
}
ModelAuthType::ApiKey => {
let api_key = settings.openai_api_key.ok_or(InitError::MissingApiKey)?;
AuthToken::ApiKey(api_key)
}
ModelAuthType::Local => {
return Err(InitError::ModelLoadError(
"OpenAI model has local auth_type".to_string(),
));
}
};
OpenAIEngine::new(auth_token, model_name).map_err(|e| InitError::ModelLoadError(e))
}
}
}
@ -290,6 +350,11 @@ pub fn apply_model_selection(
.unwrap_or_else(|_| PathBuf::from(existing_path));
if existing_abs == canonical_path {
model_id = Some(model.id);
if model.auth_type != ModelAuthType::Local {
models_repo
.update_auth_type(model.id, ModelAuthType::Local)
.map_err(InitError::Repository)?;
}
break;
}
}
@ -299,7 +364,13 @@ pub fn apply_model_selection(
Some(id) => id,
None => {
let model = models_repo
.create(ModelType::Local, None, Some(&gguf_path), None)
.create(
ModelType::Local,
None,
Some(&gguf_path),
None,
ModelAuthType::Local,
)
.map_err(InitError::Repository)?;
model.id
}
@ -324,12 +395,70 @@ pub fn apply_model_selection(
.map_err(InitError::Repository)?;
let settings = settings_repo.get_current().map_err(InitError::Repository)?;
let resolved_api_key = settings.openai_api_key.ok_or(InitError::MissingApiKey)?;
let auth_method = crate::domain::AuthMethod::from_str(&settings.auth_method);
let auth_type = ModelAuthType::from_auth_method(&auth_method);
// Determine auth token based on auth method
let auth_token = match auth_type {
ModelAuthType::OAuth => {
let mut access_token = settings
.oauth_access_token
.ok_or(InitError::MissingOAuthToken)?;
let refresh_token_val = settings
.oauth_refresh_token
.ok_or(InitError::MissingOAuthToken)?;
let account_id = settings.oauth_account_id;
// Check if token is expired and refresh if needed
if settings
.oauth_token_expiry
.map(|exp| chrono::Utc::now().timestamp() >= exp)
.unwrap_or(true)
{
log::info!("OAuth token expired, refreshing...");
let new_tokens = refresh_oauth_tokens(&refresh_token_val).map_err(|e| {
InitError::ModelLoadError(format!("Token refresh failed: {}", e))
})?;
// Update database with new tokens
settings_repo
.update_oauth_tokens(
&new_tokens.access_token,
&new_tokens.refresh_token,
new_tokens.expires_in,
new_tokens.account_id.as_deref(),
)
.map_err(InitError::Repository)?;
access_token = new_tokens.access_token;
}
AuthToken::OAuth {
token: access_token,
account_id: account_id,
}
}
ModelAuthType::ApiKey => {
let key = settings.openai_api_key.ok_or(InitError::MissingApiKey)?;
AuthToken::ApiKey(key)
}
ModelAuthType::Local => {
return Err(InitError::ModelLoadError(
"OpenAI model has local auth_type".to_string(),
));
}
};
let mut model_id = None;
for model in &existing_models {
if model.model_name.as_deref() == Some(&model_name) {
model_id = Some(model.id);
if model.auth_type != auth_type {
models_repo
.update_auth_type(model.id, auth_type)
.map_err(InitError::Repository)?;
}
break;
}
}
@ -342,6 +471,11 @@ pub fn apply_model_selection(
models_repo
.update_model_name(existing.id, Some(&model_name))
.map_err(InitError::Repository)?;
if existing.auth_type != auth_type {
models_repo
.update_auth_type(existing.id, auth_type)
.map_err(InitError::Repository)?;
}
model_id = Some(existing.id);
}
}
@ -350,14 +484,14 @@ pub fn apply_model_selection(
Some(id) => id,
None => {
models_repo
.create(ModelType::OpenAI, None, None, Some(&model_name))
.create(ModelType::OpenAI, None, None, Some(&model_name), auth_type)
.map_err(InitError::Repository)?
.id
}
};
let engine = OpenAIEngine::new(&resolved_api_key, &model_name)
.map_err(InitError::ModelLoadError)?;
let engine =
OpenAIEngine::new(auth_token, &model_name).map_err(InitError::ModelLoadError)?;
meta_repo
.set_last_used_model_id(model_id)

View file

@ -1,4 +1,5 @@
mod api_clients;
pub mod auth;
pub mod cli;
pub mod db;
pub mod event_bus;

View file

@ -1,4 +1,4 @@
use crate::domain::ModelType;
use crate::domain::{ModelAuthType, ModelType};
use rusqlite::{params, Connection, Row};
/// Raw database row for models table
@ -9,6 +9,7 @@ pub struct ModelRow {
pub _api_key: Option<String>,
pub gguf_file_path: Option<String>,
pub model_name: Option<String>,
pub auth_type: ModelAuthType,
pub _date_added: String,
}
@ -16,6 +17,17 @@ impl ModelRow {
fn from_row(row: &Row) -> Result<Self, rusqlite::Error> {
let model_type_str: String = row.get(1)?;
let model_type = ModelType::from_str(&model_type_str).unwrap_or(ModelType::Local);
let auth_type_str: Option<String> = row.get(5)?;
let auth_type = match auth_type_str.as_deref() {
Some(value) => ModelAuthType::from_str(value),
None => {
if model_type == ModelType::Local {
ModelAuthType::Local
} else {
ModelAuthType::ApiKey
}
}
};
Ok(Self {
id: row.get(0)?,
@ -23,7 +35,8 @@ impl ModelRow {
_api_key: row.get(2)?,
gguf_file_path: row.get(3)?,
model_name: row.get(4)?,
_date_added: row.get(5)?,
auth_type,
_date_added: row.get(6)?,
})
}
}
@ -40,7 +53,7 @@ impl<'a> ModelsRepository<'a> {
pub fn find_by_id(&self, id: i64) -> Result<Option<ModelRow>, String> {
let mut stmt = self
.conn
.prepare("SELECT id, type, api_key, gguf_file_path, model_name, date_added FROM models WHERE id = ?")
.prepare("SELECT id, type, api_key, gguf_file_path, model_name, auth_type, date_added FROM models WHERE id = ?")
.map_err(|e| e.to_string())?;
let result = stmt
@ -54,7 +67,7 @@ impl<'a> ModelsRepository<'a> {
pub fn find_by_type(&self, model_type: ModelType) -> Result<Vec<ModelRow>, String> {
let mut stmt = self
.conn
.prepare("SELECT id, type, api_key, gguf_file_path, model_name, date_added FROM models WHERE type = ? ORDER BY date_added DESC")
.prepare("SELECT id, type, api_key, gguf_file_path, model_name, auth_type, date_added FROM models WHERE type = ? ORDER BY date_added DESC")
.map_err(|e| e.to_string())?;
let rows = stmt
@ -75,13 +88,21 @@ impl<'a> ModelsRepository<'a> {
api_key: Option<&str>,
gguf_file_path: Option<&str>,
model_name: Option<&str>,
auth_type: ModelAuthType,
) -> Result<ModelRow, String> {
let date_added = chrono_now();
self.conn
.execute(
"INSERT INTO models (type, api_key, gguf_file_path, model_name, date_added) VALUES (?, ?, ?, ?, ?)",
params![model_type.as_str(), api_key, gguf_file_path, model_name, date_added],
"INSERT INTO models (type, api_key, gguf_file_path, model_name, auth_type, date_added) VALUES (?, ?, ?, ?, ?, ?)",
params![
model_type.as_str(),
api_key,
gguf_file_path,
model_name,
auth_type.as_str(),
date_added
],
)
.map_err(|e| e.to_string())?;
@ -93,10 +114,23 @@ impl<'a> ModelsRepository<'a> {
_api_key: api_key.map(|s| s.to_string()),
gguf_file_path: gguf_file_path.map(|s| s.to_string()),
model_name: model_name.map(|s| s.to_string()),
auth_type,
_date_added: date_added,
})
}
pub fn update_auth_type(&self, id: i64, auth_type: ModelAuthType) -> Result<bool, String> {
let rows_affected = self
.conn
.execute(
"UPDATE models SET auth_type = ? WHERE id = ?",
params![auth_type.as_str(), id],
)
.map_err(|e| e.to_string())?;
Ok(rows_affected > 0)
}
pub fn update_model_name(&self, id: i64, model_name: Option<&str>) -> Result<bool, String> {
let rows_affected = self
.conn

View file

@ -10,6 +10,11 @@ pub struct UserSettingsRow {
pub web_search_enabled: bool,
pub brave_api_key: Option<String>,
pub max_tool_calls_per_request: i32,
pub auth_method: String,
pub oauth_access_token: Option<String>,
pub oauth_refresh_token: Option<String>,
pub oauth_token_expiry: Option<i64>,
pub oauth_account_id: Option<String>,
}
impl UserSettingsRow {
@ -23,6 +28,13 @@ impl UserSettingsRow {
web_search_enabled: row.get::<_, i64>(5)? != 0,
brave_api_key: row.get(6)?,
max_tool_calls_per_request: row.get(7)?,
auth_method: row
.get::<_, Option<String>>(8)?
.unwrap_or_else(|| "api_key".to_string()),
oauth_access_token: row.get(9)?,
oauth_refresh_token: row.get(10)?,
oauth_token_expiry: row.get(11)?,
oauth_account_id: row.get(12)?,
})
}
}
@ -46,7 +58,7 @@ impl<'a> UserSettingsRepository<'a> {
let mut stmt = self
.conn
.prepare(
"SELECT id, openai_api_key, openai_tracing_enabled, use_behavior_trees, current_model_id, web_search_enabled, brave_api_key, max_tool_calls_per_request FROM user_settings WHERE id = ?",
"SELECT id, openai_api_key, openai_tracing_enabled, use_behavior_trees, current_model_id, web_search_enabled, brave_api_key, max_tool_calls_per_request, auth_method, oauth_access_token, oauth_refresh_token, oauth_token_expiry, oauth_account_id FROM user_settings WHERE id = ?",
)
.map_err(|e| e.to_string())?;
@ -62,7 +74,7 @@ impl<'a> UserSettingsRepository<'a> {
self.ensure_row()?;
self.conn
.execute(
"UPDATE user_settings SET openai_api_key = ? WHERE id = 1",
"UPDATE user_settings SET openai_api_key = ?, auth_method = 'api_key' WHERE id = 1",
params![api_key],
)
.map_err(|e| e.to_string())?;
@ -114,6 +126,49 @@ impl<'a> UserSettingsRepository<'a> {
Ok(())
}
pub fn update_oauth_tokens(
&self,
access_token: &str,
refresh_token: &str,
expires_in: i64,
account_id: Option<&str>,
) -> Result<(), String> {
self.ensure_row()?;
let expiry = chrono::Utc::now().timestamp() + expires_in;
self.conn
.execute(
"UPDATE user_settings SET
oauth_access_token = ?,
oauth_refresh_token = ?,
oauth_token_expiry = ?,
oauth_account_id = ?,
auth_method = 'oauth',
openai_api_key = NULL
WHERE id = 1",
params![access_token, refresh_token, expiry, account_id],
)
.map_err(|e| e.to_string())?;
Ok(())
}
#[allow(dead_code)]
pub fn clear_oauth_tokens(&self) -> Result<(), String> {
self.ensure_row()?;
self.conn
.execute(
"UPDATE user_settings SET
oauth_access_token = NULL,
oauth_refresh_token = NULL,
oauth_token_expiry = NULL,
oauth_account_id = NULL,
auth_method = 'api_key'
WHERE id = 1",
[],
)
.map_err(|e| e.to_string())?;
Ok(())
}
fn update_bool(&self, column: &str, enabled: bool) -> Result<(), String> {
self.ensure_row()?;
let sql = format!("UPDATE user_settings SET {} = ? WHERE id = 1", column);