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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
273 changes: 265 additions & 8 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@ serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
sha2 = "0.11.0"
thiserror = "2.0.18"
ureq = { version = "3.3.0", features = ["json"] }
ureq = { version = "3.3.0", features = ["json", "platform-verifier"] }
url = "2.5.8"
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ LANE_OAUTH_SCOPE=api
and refresh tokens are stored under the Lane config directory with owner-only
file permissions on Unix.

TLS certificate verification defaults to the platform trust store. For internal
GitLab instances behind a corporate CA, install the corporate root certificate in
the OS trust store and run Lane normally. Set `LANE_TLS_ROOTS=webpki` to force the
old bundled Mozilla roots behavior. As a last-resort diagnostic only,
`LANE_TLS_DISABLE_VERIFICATION=true` disables certificate verification and should
not be used with real tokens.

Press `?` inside the app for the full keyboard reference (`/` search,
`n` new issue, `g` generate stories, `s` sync, `hjkl`/arrows to navigate).

Expand Down
51 changes: 51 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ pub enum AuthMode {
OAuth,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum TlsRoots {
Platform,
WebPki,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TlsPolicy {
pub roots: TlsRoots,
pub disable_verification: bool,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitLabConfig {
pub instance_url: Url,
Expand All @@ -65,6 +77,7 @@ pub struct LlmConfig {
pub struct AppConfig {
pub gitlab: GitLabConfig,
pub llm: LlmConfig,
pub tls: TlsPolicy,
}

#[derive(Clone, Debug, Default, Eq, PartialEq)]
Expand All @@ -83,13 +96,16 @@ pub struct EnvConfig {
pub oauth_scope: Option<String>,
pub oauth_refresh_token: Option<String>,
pub oauth_access_expires_at: Option<String>,
pub tls_roots: Option<String>,
pub tls_disable_verification: Option<String>,
}

const DEFAULT_LLM_TEMPERATURE: f32 = 0.4;
const DEFAULT_LLM_MODEL: &str = "gpt-oss:120b";
const DEFAULT_LLM_BASE_URL: &str = "https://ollama.com/v1";
const DEFAULT_LLM_ENABLED: bool = false;
const DEFAULT_OAUTH_SCOPE: &str = "api";
const DEFAULT_TLS_ROOTS: TlsRoots = TlsRoots::Platform;

impl EnvConfig {
pub fn from_env() -> Self {
Expand All @@ -108,6 +124,8 @@ impl EnvConfig {
oauth_scope: env::var("LANE_OAUTH_SCOPE").ok(),
oauth_refresh_token: env::var("LANE_OAUTH_REFRESH_TOKEN").ok(),
oauth_access_expires_at: env::var("LANE_OAUTH_ACCESS_EXPIRES_AT").ok(),
tls_roots: env::var("LANE_TLS_ROOTS").ok(),
tls_disable_verification: env::var("LANE_TLS_DISABLE_VERIFICATION").ok(),
}
}
}
Expand Down Expand Up @@ -145,6 +163,14 @@ impl AppConfig {
env.llm_base_url
.unwrap_or_else(|| DEFAULT_LLM_BASE_URL.to_string()),
)?;
let tls_roots = match env.tls_roots {
Some(value) => parse_tls_roots("LANE_TLS_ROOTS", &value)?,
None => DEFAULT_TLS_ROOTS,
};
let tls_disable_verification = match env.tls_disable_verification {
Some(value) => parse_bool("LANE_TLS_DISABLE_VERIFICATION", &value)?,
None => false,
};

let oauth_requested = env.oauth_client_id.is_some()
|| env.oauth_client_secret.is_some()
Expand Down Expand Up @@ -211,10 +237,25 @@ impl AppConfig {
enabled: llm_enabled,
temperature: llm_temperature,
},
tls: TlsPolicy {
roots: tls_roots,
disable_verification: tls_disable_verification,
},
})
}
}

fn parse_tls_roots(name: &'static str, value: &str) -> Result<TlsRoots, ConfigError> {
match value.to_lowercase().as_str() {
"platform" | "native" | "os" | "system" => Ok(TlsRoots::Platform),
"webpki" | "mozilla" | "bundled" => Ok(TlsRoots::WebPki),
other => Err(ConfigError::Invalid {
name,
reason: format!("expected platform or webpki but got {other}"),
}),
}
}

fn parse_bool(name: &'static str, value: &str) -> Result<bool, ConfigError> {
match value.to_lowercase().as_str() {
"1" | "true" | "t" | "yes" | "y" | "on" => Ok(true),
Expand Down Expand Up @@ -276,6 +317,8 @@ mod tests {
oauth_scope: None,
oauth_refresh_token: None,
oauth_access_expires_at: None,
tls_roots: None,
tls_disable_verification: None,
};

let cfg = AppConfig::from_env_values(env).expect("config");
Expand All @@ -294,6 +337,8 @@ mod tests {
assert_eq!(cfg.llm.model, "gpt-oss:120b");
assert!(!cfg.llm.enabled);
assert_eq!(cfg.llm.temperature, Some(0.4));
assert_eq!(cfg.tls.roots, TlsRoots::Platform);
assert!(!cfg.tls.disable_verification);
}

#[test]
Expand All @@ -313,6 +358,8 @@ mod tests {
oauth_scope: None,
oauth_refresh_token: None,
oauth_access_expires_at: None,
tls_roots: None,
tls_disable_verification: None,
};

let err = AppConfig::from_env_values(env).expect_err("missing token");
Expand All @@ -337,6 +384,8 @@ mod tests {
oauth_scope: None,
oauth_refresh_token: Some("refresh".into()),
oauth_access_expires_at: Some("1234".into()),
tls_roots: Some("webpki".into()),
tls_disable_verification: Some("true".into()),
};

let cfg = AppConfig::from_env_values(env).unwrap();
Expand Down Expand Up @@ -372,6 +421,8 @@ mod tests {
cfg.gitlab.oauth.as_ref().unwrap().access_expires_at_epoch,
Some(1234)
);
assert_eq!(cfg.tls.roots, TlsRoots::WebPki);
assert!(cfg.tls.disable_verification);
}

#[test]
Expand Down
19 changes: 14 additions & 5 deletions src/gitlab.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use serde::{Deserialize, Serialize};
use std::time::Duration;
use thiserror::Error;
use url::Url;

use crate::config::TlsPolicy;
use crate::http_client;
use crate::model::{GitLabIssue, GitLabLabel};

#[derive(Clone)]
Expand All @@ -12,6 +13,7 @@ pub struct GitLabClientConfig {
pub timeout_ms: u64,
pub max_pages: usize,
pub max_items: usize,
pub tls: TlsPolicy,
}

impl std::fmt::Debug for GitLabClientConfig {
Expand All @@ -22,6 +24,7 @@ impl std::fmt::Debug for GitLabClientConfig {
.field("timeout_ms", &self.timeout_ms)
.field("max_pages", &self.max_pages)
.field("max_items", &self.max_items)
.field("tls", &self.tls)
.finish()
}
}
Expand Down Expand Up @@ -414,16 +417,14 @@ impl GitLabClient {
}

fn agent(&self) -> ureq::Agent {
ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_millis(self.cfg.timeout_ms)))
.build()
.into()
http_client::agent(self.cfg.timeout_ms, &self.cfg.tls)
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::config::{TlsPolicy, TlsRoots};

fn client() -> GitLabClient {
GitLabClient::new(GitLabClientConfig {
Expand All @@ -432,6 +433,10 @@ mod tests {
timeout_ms: 30_000,
max_pages: 50,
max_items: 5000,
tls: TlsPolicy {
roots: TlsRoots::Platform,
disable_verification: false,
},
})
}

Expand All @@ -455,6 +460,10 @@ mod tests {
timeout_ms: 30_000,
max_pages: 50,
max_items: 5000,
tls: TlsPolicy {
roots: TlsRoots::Platform,
disable_verification: false,
},
});

let url = client.api_url("/projects/42/issues", &[]).expect("url");
Expand Down
22 changes: 22 additions & 0 deletions src/http_client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
use std::time::Duration;

use ureq::tls::{RootCerts, TlsConfig};

use crate::config::{TlsPolicy, TlsRoots};

pub fn agent(timeout_ms: u64, tls: &TlsPolicy) -> ureq::Agent {
let root_certs = match tls.roots {
TlsRoots::Platform => RootCerts::PlatformVerifier,
TlsRoots::WebPki => RootCerts::WebPki,
};
let tls_config = TlsConfig::builder()
.root_certs(root_certs)
.disable_verification(tls.disable_verification)
.build();

ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_millis(timeout_ms)))
.tls_config(tls_config)
.build()
.into()
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use crate::sync::SourceIssueKey;

pub mod config;
pub mod gitlab;
pub mod http_client;
pub mod jobs;
pub mod labels;
pub mod llm;
Expand Down
15 changes: 10 additions & 5 deletions src/llm.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use serde::{Deserialize, Serialize};
use std::time::Duration;
use thiserror::Error;
use url::Url;

use crate::config::TlsPolicy;
use crate::http_client;
use crate::model::GeneratedStory;

#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
Expand All @@ -18,6 +19,7 @@ pub struct LlmClientConfig {
pub model: String,
pub temperature: Option<f32>,
pub timeout_ms: u64,
pub tls: TlsPolicy,
}

impl std::fmt::Debug for LlmClientConfig {
Expand All @@ -28,6 +30,7 @@ impl std::fmt::Debug for LlmClientConfig {
.field("model", &self.model)
.field("temperature", &self.temperature)
.field("timeout_ms", &self.timeout_ms)
.field("tls", &self.tls)
.finish()
}
}
Expand Down Expand Up @@ -86,10 +89,7 @@ impl LlmClient {
stream: true,
temperature: self.cfg.temperature,
};
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_millis(self.cfg.timeout_ms)))
.build()
.into();
let agent = http_client::agent(self.cfg.timeout_ms, &self.cfg.tls);
let mut request = agent
.post(endpoint.as_str())
.header("Accept", "text/event-stream")
Expand Down Expand Up @@ -280,6 +280,7 @@ fn empty(value: &str) -> &str {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{TlsPolicy, TlsRoots};

#[test]
fn parses_sse_chat_chunks() {
Expand Down Expand Up @@ -317,6 +318,10 @@ mod tests {
model: "gpt-oss:120b".into(),
temperature: None,
timeout_ms: 30_000,
tls: TlsPolicy {
roots: TlsRoots::Platform,
disable_verification: false,
},
});

assert_eq!(
Expand Down
5 changes: 5 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ fn maybe_start_job(
timeout_ms: 30_000,
max_pages: 50,
max_items: 5000,
tls: config.tls.clone(),
});
let llm = LlmClient::new(LlmClientConfig {
base_url: config.llm.base_url.clone(),
Expand All @@ -301,6 +302,7 @@ fn maybe_start_job(
model: config.llm.model.clone(),
temperature: config.llm.temperature,
timeout_ms: 120_000,
tls: config.tls.clone(),
});
let result = jobs::execute_job(
JobContext {
Expand Down Expand Up @@ -387,6 +389,7 @@ fn oauth_client_config(config: &AppConfig) -> Result<OAuthClientConfig, String>
.map_err(|error| format!("invalid LANE_OAUTH_REDIRECT_URI: {error}"))?,
scope: oauth.scope.clone(),
timeout_ms: 120_000,
tls: config.tls.clone(),
})
}

Expand Down Expand Up @@ -841,6 +844,8 @@ mod tests {
oauth_scope: None,
oauth_refresh_token: None,
oauth_access_expires_at: None,
tls_roots: None,
tls_disable_verification: None,
})
.expect("config");
let mut app = App::demo();
Expand Down
14 changes: 10 additions & 4 deletions src/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
use thiserror::Error;
use url::Url;

use crate::config::TlsPolicy;
use crate::http_client;

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct OAuthClientConfig {
pub instance_url: Url,
Expand All @@ -15,6 +18,7 @@ pub struct OAuthClientConfig {
pub redirect_uri: Url,
pub scope: String,
pub timeout_ms: u64,
pub tls: TlsPolicy,
}

#[derive(Clone, Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -189,10 +193,7 @@ fn post_token_form(
form.push(("client_secret".to_string(), secret.clone()));
}
let endpoint = oauth_endpoint(&config.instance_url, "oauth/token")?;
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_millis(config.timeout_ms)))
.build()
.into();
let agent = http_client::agent(config.timeout_ms, &config.tls);
let mut response = agent
.post(endpoint.as_str())
.config()
Expand Down Expand Up @@ -374,6 +375,7 @@ fn now_epoch() -> u64 {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{TlsPolicy, TlsRoots};

fn config() -> OAuthClientConfig {
OAuthClientConfig {
Expand All @@ -383,6 +385,10 @@ mod tests {
redirect_uri: Url::parse("http://127.0.0.1:8910/oauth/callback").unwrap(),
scope: "api".into(),
timeout_ms: 60_000,
tls: TlsPolicy {
roots: TlsRoots::Platform,
disable_verification: false,
},
}
}

Expand Down