use std::collections::HashSet;
use std::io::Error;
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
use serde::Deserialize;
use serde::Serialize;
use url::Url;
const CONFIG_FILE_NAME: &str = "daemon.toml";
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct AccountProfile {
pub(crate) name: String,
pub(crate) label: String,
pub(crate) codex_home: PathBuf,
pub(crate) proxy: Option<String>,
pub(crate) use_ssh_tunnel: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct SshTunnelConfig {
pub(crate) destination: String,
pub(crate) identity_file: Option<PathBuf>,
pub(crate) ssh_port: Option<u16>,
pub(crate) local_port: Option<u16>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct DaemonConfig {
#[serde(skip)]
pub(crate) source_path: PathBuf,
pub(crate) port: Option<u16>,
pub(crate) mcp_oauth_callback_url: Option<String>,
pub(crate) accounts: Vec<AccountProfile>,
pub(crate) ssh_tunnel: Option<SshTunnelConfig>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ConfigFile {
port: Option<u16>,
mcp_oauth_callback_url: Option<String>,
#[serde(default)]
accounts: Vec<AccountFile>,
ssh_tunnel: Option<SshTunnelFile>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct AccountFile {
name: String,
label: Option<String>,
codex_home: PathBuf,
proxy: Option<String>,
#[serde(default)]
use_ssh_tunnel: bool,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SshTunnelFile {
destination: String,
identity_file: Option<PathBuf>,
ssh_port: Option<u16>,
local_port: Option<u16>,
}
impl DaemonConfig {
pub(crate) fn load(explicit_path: Option<&Path>) -> std::io::Result<Self> {
let default_home = codex_utils_home_dir::find_codex_home()?.to_path_buf();
let default_path = default_home
.join("typeduck-codex-web")
.join(CONFIG_FILE_NAME);
let path = explicit_path.unwrap_or(&default_path);
if !path.exists() && explicit_path.is_none() {
return Ok(Self::single(default_home, default_path));
}
let encoded = std::fs::read_to_string(path).map_err(|error| {
Error::new(
error.kind(),
format!("failed to read daemon config {}: {error}", path.display()),
)
})?;
let parsed: ConfigFile = toml::from_str(&encoded).map_err(|error| {
Error::new(
ErrorKind::InvalidData,
format!("invalid daemon config {}: {error}", path.display()),
)
})?;
parsed.validate(default_home, path.to_path_buf())
}
fn single(codex_home: PathBuf, source_path: PathBuf) -> Self {
Self {
source_path,
port: None,
mcp_oauth_callback_url: None,
accounts: vec![AccountProfile {
name: "default".to_string(),
label: "Default".to_string(),
codex_home,
proxy: None,
use_ssh_tunnel: false,
}],
ssh_tunnel: None,
}
}
pub(crate) fn save_accounts(&mut self, accounts: Vec<AccountProfile>) -> std::io::Result<()> {
validate_accounts(&accounts, self.ssh_tunnel.as_ref())?;
let previous = std::mem::replace(&mut self.accounts, accounts);
let encoded = toml::to_string_pretty(self).map_err(|error| {
Error::new(
ErrorKind::InvalidData,
format!("could not encode daemon config: {error}"),
)
})?;
if let Some(parent) = self.source_path.parent() {
std::fs::create_dir_all(parent)?;
}
if let Err(error) = std::fs::write(&self.source_path, encoded) {
self.accounts = previous;
return Err(error);
}
Ok(())
}
}
impl ConfigFile {
fn validate(
self,
default_home: PathBuf,
source_path: PathBuf,
) -> std::io::Result<DaemonConfig> {
if self.port == Some(0) {
return Err(invalid("daemon port must be greater than zero"));
}
if let Some(callback_url) = self.mcp_oauth_callback_url.as_deref() {
validate_callback_url(callback_url)?;
}
if self.accounts.is_empty() {
let mut config = DaemonConfig::single(default_home, source_path);
config.port = self.port;
config.mcp_oauth_callback_url = self.mcp_oauth_callback_url;
return Ok(config);
}
let mut accounts = Vec::with_capacity(self.accounts.len());
for account in self.accounts {
accounts.push(AccountProfile {
label: account.label.unwrap_or_else(|| account.name.clone()),
name: account.name,
codex_home: account.codex_home,
proxy: account.proxy,
use_ssh_tunnel: account.use_ssh_tunnel,
});
}
let ssh_tunnel = self.ssh_tunnel.map(SshTunnelFile::validate).transpose()?;
validate_accounts(&accounts, ssh_tunnel.as_ref())?;
Ok(DaemonConfig {
source_path,
port: self.port,
mcp_oauth_callback_url: self.mcp_oauth_callback_url,
accounts,
ssh_tunnel,
})
}
}
fn validate_accounts(
accounts: &[AccountProfile],
ssh_tunnel: Option<&SshTunnelConfig>,
) -> std::io::Result<()> {
if accounts.is_empty() {
return Err(invalid("at least one account is required"));
}
let mut names = HashSet::new();
for account in accounts {
if !valid_name(&account.name) {
return Err(invalid(
"account names may contain only letters, numbers, '-' and '_'",
));
}
if !names.insert(account.name.clone()) {
return Err(invalid(format!(
"duplicate account name {:?}",
account.name
)));
}
if !account.codex_home.is_absolute() {
return Err(invalid(format!(
"codex_home for account {:?} must be absolute",
account.name
)));
}
if let Some(proxy) = account.proxy.as_deref() {
validate_proxy(proxy)?;
}
if account.proxy.is_some() && account.use_ssh_tunnel {
return Err(invalid(format!(
"account {:?} cannot set both proxy and use_ssh_tunnel",
account.name
)));
}
if account.use_ssh_tunnel && ssh_tunnel.is_none() {
return Err(invalid(format!(
"account {:?} enables the SSH tunnel but [ssh_tunnel] is missing",
account.name
)));
}
}
Ok(())
}
impl SshTunnelFile {
fn validate(self) -> std::io::Result<SshTunnelConfig> {
if self.destination.is_empty()
|| self.destination.starts_with('-')
|| self.destination.chars().any(char::is_whitespace)
{
return Err(invalid(
"ssh_tunnel.destination must be one SSH destination argument",
));
}
if self.ssh_port == Some(0) || self.local_port == Some(0) {
return Err(invalid("SSH ports must be greater than zero"));
}
if let Some(path) = &self.identity_file
&& !path.is_absolute()
{
return Err(invalid("ssh_tunnel.identity_file must be absolute"));
}
Ok(SshTunnelConfig {
destination: self.destination,
identity_file: self.identity_file,
ssh_port: self.ssh_port,
local_port: self.local_port,
})
}
}
fn validate_proxy(proxy: &str) -> std::io::Result<()> {
let parsed =
Url::parse(proxy).map_err(|error| invalid(format!("invalid proxy URL: {error}")))?;
if !matches!(parsed.scheme(), "http" | "https" | "socks5" | "socks5h") {
return Err(invalid(
"proxy URL scheme must be http, https, socks5, or socks5h",
));
}
if parsed.host().is_none() {
return Err(invalid("proxy URL must include a host"));
}
Ok(())
}
fn validate_callback_url(callback_url: &str) -> std::io::Result<()> {
let parsed = Url::parse(callback_url)
.map_err(|error| invalid(format!("invalid MCP OAuth callback URL: {error}")))?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(invalid("MCP OAuth callback URL must use http or https"));
}
if parsed.host().is_none() || parsed.port_or_known_default().is_none() {
return Err(invalid(
"MCP OAuth callback URL must include a host and port",
));
}
if parsed.path() != "/oauth/callback" || parsed.query().is_some() || parsed.fragment().is_some()
{
return Err(invalid(
"MCP OAuth callback URL must end at /oauth/callback without a query or fragment",
));
}
Ok(())
}
fn valid_name(name: &str) -> bool {
!name.is_empty()
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
}
fn invalid(message: impl Into<String>) -> Error {
Error::new(ErrorKind::InvalidData, message.into())
}
#[cfg(test)]
#[path = "daemon_config_tests.rs"]
mod tests;