use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use aes_gcm::Aes256Gcm;
use aes_gcm::aead::{Aead, Generate, KeyInit, Nonce};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use runx_contracts::JsonValue;
use runx_contracts::schema::NonEmptyString;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
use crate::credentials::SecretString;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunxConfigFile {
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<RunxAgentConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub public: Option<RunxPublicConfig>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunxAgentConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key_ref: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunxPublicConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub api_token_ref: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ConfigKey {
AgentProvider,
AgentModel,
AgentApiKey,
PublicApiToken,
}
pub mod managed_agent_provider {
pub const OPENAI: &str = "openai";
pub const ANTHROPIC: &str = "anthropic";
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ManagedAgentConfig {
pub provider: NonEmptyString,
pub model: String,
pub api_key: SecretString,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LocalProfileSource {
ProfileState,
SkillProfile,
WorkspaceBindings,
None,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolvedLocalProfile {
pub profile_document: Option<String>,
pub profile_source_path: Option<PathBuf>,
pub source: LocalProfileSource,
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("{path} is not valid JSON: {message}")]
InvalidJson { path: PathBuf, message: String },
#[error("{path} must contain a JSON object.")]
NonObjectJson { path: PathBuf },
#[error("unsupported runx config key {key}")]
UnsupportedKey { key: String },
#[error("runx local agent key corrupted or unreadable at {path}{suffix}")]
LocalAgentKeyCorrupt { path: PathBuf, suffix: String },
#[error("Skill profile state is not valid JSON: {path}")]
InvalidProfileStateJson { path: PathBuf },
#[error("Skill profile state must be an object: {path}")]
NonObjectProfileState { path: PathBuf },
#[error(
"Binding manifest skill '{manifest_skill}' does not match skill '{skill_name}': {path}"
)]
ManifestSkillMismatch {
manifest_skill: String,
skill_name: String,
path: PathBuf,
},
#[error(
"Skill package '{skill_directory}' resolves to binding path {owner}/{binding_skill}, but SKILL.md declares '{skill_name}'."
)]
BindingLocatorMismatch {
skill_directory: PathBuf,
owner: String,
binding_skill: String,
skill_name: String,
},
#[error("config crypto failed: {0}")]
Crypto(String),
#[error(transparent)]
Io(#[from] io::Error),
}
pub fn parse_config_key(key: &str) -> Result<ConfigKey, ConfigError> {
match key {
"agent.provider" => Ok(ConfigKey::AgentProvider),
"agent.model" => Ok(ConfigKey::AgentModel),
"agent.api_key" => Ok(ConfigKey::AgentApiKey),
"public.api_token" => Ok(ConfigKey::PublicApiToken),
_ => Err(ConfigError::UnsupportedKey {
key: key.to_owned(),
}),
}
}
pub fn resolve_path_from_user_input(
user_path: &str,
env: &BTreeMap<String, String>,
cwd: &Path,
prefer_existing: bool,
) -> PathBuf {
let path = Path::new(user_path);
if path.is_absolute() {
return path.to_path_buf();
}
if prefer_existing {
for base in [
env.get("RUNX_CWD").map(PathBuf::from),
env.get("INIT_CWD").map(PathBuf::from),
find_runx_workspace_root(cwd),
Some(cwd.to_path_buf()),
]
.into_iter()
.flatten()
{
let candidate = base.join(path);
if candidate.exists() {
return candidate;
}
}
}
resolve_runx_workspace_base(env, cwd).join(path)
}
pub fn resolve_runx_global_home_dir(env: &BTreeMap<String, String>, cwd: &Path) -> PathBuf {
env.get("RUNX_HOME").map_or_else(
|| home_dir().join(".runx"),
|home| resolve_path_from_user_input(home, env, cwd, false),
)
}
pub fn resolve_runx_home_dir(env: &BTreeMap<String, String>, cwd: &Path) -> PathBuf {
resolve_runx_global_home_dir(env, cwd)
}
pub fn load_runx_config_file(config_path: &Path) -> Result<RunxConfigFile, ConfigError> {
let contents = match fs::read_to_string(config_path) {
Ok(contents) => contents,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Ok(RunxConfigFile::default());
}
Err(error) => return Err(ConfigError::Io(error)),
};
let value =
serde_json::from_str::<JsonValue>(&contents).map_err(|error| ConfigError::InvalidJson {
path: config_path.to_path_buf(),
message: error.to_string(),
})?;
if !matches!(value, JsonValue::Object(_)) {
return Err(ConfigError::NonObjectJson {
path: config_path.to_path_buf(),
});
}
serde_json::from_str(&contents).map_err(|error| ConfigError::InvalidJson {
path: config_path.to_path_buf(),
message: error.to_string(),
})
}
pub fn write_runx_config_file(
config_path: &Path,
config: &RunxConfigFile,
) -> Result<(), ConfigError> {
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)?;
}
let contents =
serde_json::to_string_pretty(config).map_err(|error| ConfigError::InvalidJson {
path: config_path.to_path_buf(),
message: error.to_string(),
})?;
write_private_file(config_path, format!("{contents}\n").as_bytes())
}
pub fn update_runx_config_value(
mut config: RunxConfigFile,
key: ConfigKey,
value: &str,
config_dir: &Path,
) -> Result<RunxConfigFile, ConfigError> {
match key {
ConfigKey::AgentProvider => {
let mut agent = config.agent.unwrap_or_default();
agent.provider = Some(value.to_owned());
config.agent = Some(agent);
}
ConfigKey::AgentModel => {
let mut agent = config.agent.unwrap_or_default();
agent.model = Some(value.to_owned());
config.agent = Some(agent);
}
ConfigKey::AgentApiKey => {
let mut agent = config.agent.unwrap_or_default();
agent.api_key_ref = Some(store_local_agent_api_key(config_dir, value)?);
config.agent = Some(agent);
}
ConfigKey::PublicApiToken => {
let mut public = config.public.unwrap_or_default();
public.api_token_ref = Some(store_local_public_api_token(config_dir, value)?);
config.public = Some(public);
}
}
Ok(config)
}
pub fn lookup_runx_config_value(config: &RunxConfigFile, key: ConfigKey) -> Option<String> {
match key {
ConfigKey::AgentProvider => config.agent.as_ref()?.provider.clone(),
ConfigKey::AgentModel => config.agent.as_ref()?.model.clone(),
ConfigKey::AgentApiKey => config
.agent
.as_ref()?
.api_key_ref
.as_ref()
.map(|_| "[encrypted]".to_owned()),
ConfigKey::PublicApiToken => config
.public
.as_ref()?
.api_token_ref
.as_ref()
.map(|_| "[encrypted]".to_owned()),
}
}
pub fn mask_runx_config_file(config: &RunxConfigFile) -> RunxConfigFile {
let mut masked = config.clone();
if let Some(agent) = masked.agent.as_mut()
&& agent.api_key_ref.is_some()
{
agent.api_key_ref = Some("[encrypted]".to_owned());
}
if let Some(public) = masked.public.as_mut()
&& public.api_token_ref.is_some()
{
public.api_token_ref = Some("[encrypted]".to_owned());
}
masked
}
pub fn load_local_agent_api_key(config_dir: &Path, key_ref: &str) -> Result<String, ConfigError> {
load_local_config_secret_value(config_dir, key_ref)
}
pub fn load_local_public_api_token(
config_dir: &Path,
token_ref: &str,
) -> Result<String, ConfigError> {
load_local_config_secret_value(config_dir, token_ref)
}
fn load_local_config_secret_value(config_dir: &Path, key_ref: &str) -> Result<String, ConfigError> {
let key_path = config_dir.join("keys").join(format!("{key_ref}.json"));
let payload = load_key_payload(&key_path)?;
if payload.alg != "aes-256-gcm" {
return Err(config_key_read_error(&key_path, None));
}
let secret = load_or_create_local_config_secret(&config_dir.join("keys"))?;
let key = Sha256::digest(secret.as_bytes());
let cipher =
Aes256Gcm::new_from_slice(&key).map_err(|error| ConfigError::Crypto(error.to_string()))?;
let nonce_bytes = decode_key_part(&key_path, &payload.iv)?;
let ciphertext = decode_key_part(&key_path, &payload.ciphertext)?;
let auth_tag = decode_key_part(&key_path, &payload.auth_tag)?;
let mut sealed = ciphertext;
sealed.extend(auth_tag);
let nonce = config_nonce(&key_path, &nonce_bytes)?;
let plaintext = cipher
.decrypt(&nonce, sealed.as_ref())
.map_err(|error| config_key_read_error(&key_path, Some(error.to_string())))?;
String::from_utf8(plaintext)
.map_err(|error| config_key_read_error(&key_path, Some(error.to_string())))
}
pub fn load_managed_agent_config(
env: &BTreeMap<String, String>,
cwd: &Path,
) -> Result<Option<ManagedAgentConfig>, ConfigError> {
let config_dir = resolve_runx_home_dir(env, cwd);
let config = load_runx_config_file(&config_dir.join("config.json"))?;
let provider = env
.get("RUNX_AGENT_PROVIDER")
.or_else(|| {
config
.agent
.as_ref()
.and_then(|agent| agent.provider.as_ref())
})
.and_then(|value| normalize_managed_agent_provider(value));
let Some(provider) = provider else {
return Ok(None);
};
let model = env
.get("RUNX_AGENT_MODEL")
.or_else(|| config.agent.as_ref().and_then(|agent| agent.model.as_ref()))
.map(|value| value.trim().to_owned())
.unwrap_or_default();
if model.is_empty() {
return Ok(None);
}
let provider_env_var = managed_agent_provider_env_var(&provider);
let provider_key = env.get(&provider_env_var);
let mut api_key = env
.get("RUNX_AGENT_API_KEY")
.or(provider_key)
.map(|value| value.trim().to_owned())
.unwrap_or_default();
if api_key.is_empty()
&& let Some(key_ref) = config
.agent
.as_ref()
.and_then(|agent| agent.api_key_ref.as_ref())
.filter(|value| !value.is_empty())
{
api_key = load_local_agent_api_key(&config_dir, key_ref)?
.trim()
.to_owned();
}
if api_key.is_empty() {
return Ok(None);
}
Ok(Some(ManagedAgentConfig {
provider,
model,
api_key: SecretString::new(api_key),
}))
}
pub fn resolve_local_skill_profile(
skill_path: &Path,
skill_name: &str,
) -> Result<ResolvedLocalProfile, ConfigError> {
let metadata = fs::metadata(skill_path)?;
let skill_directory = if metadata.is_dir() {
skill_path.to_path_buf()
} else {
skill_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."))
};
if let Some(profile) = read_skill_profile(&skill_directory, skill_name)? {
return Ok(profile);
}
if let Some(profile) = read_profile_state(&skill_directory, skill_name)? {
return Ok(profile);
}
for binding_root in collect_binding_roots(&skill_directory) {
if let Some(profile) = read_workspace_profile(&skill_directory, &binding_root, skill_name)?
{
return Ok(profile);
}
}
Ok(ResolvedLocalProfile {
profile_document: None,
profile_source_path: None,
source: LocalProfileSource::None,
})
}
#[derive(Deserialize)]
struct LocalConfigSecretPayload {
alg: String,
iv: String,
ciphertext: String,
auth_tag: String,
}
#[derive(Serialize)]
struct StoredLocalConfigSecretPayload<'a> {
#[serde(rename = "ref")]
key_ref: &'a str,
alg: &'static str,
iv: String,
ciphertext: String,
auth_tag: String,
}
type ConfigNonce = Nonce<Aes256Gcm>;
fn config_nonce(key_path: &Path, nonce_bytes: &[u8]) -> Result<ConfigNonce, ConfigError> {
ConfigNonce::try_from(nonce_bytes).map_err(|_| {
config_key_read_error(
key_path,
Some(format!(
"expected 12-byte aes-256-gcm nonce, found {} bytes",
nonce_bytes.len()
)),
)
})
}
fn random_config_nonce() -> Result<ConfigNonce, ConfigError> {
ConfigNonce::try_generate().map_err(|error| ConfigError::Crypto(error.to_string()))
}
fn random_config_secret_bytes() -> Result<[u8; 32], ConfigError> {
<[u8; 32]>::try_generate().map_err(|error| ConfigError::Crypto(error.to_string()))
}
pub fn resolve_runx_workspace_base(env: &BTreeMap<String, String>, cwd: &Path) -> PathBuf {
env.get("RUNX_CWD")
.map(PathBuf::from)
.or_else(|| find_runx_workspace_root(cwd))
.or_else(|| env.get("INIT_CWD").map(PathBuf::from))
.unwrap_or_else(|| cwd.to_path_buf())
}
fn find_runx_workspace_root(start: &Path) -> Option<PathBuf> {
let mut current = start.to_path_buf();
loop {
if current.join("pnpm-workspace.yaml").exists() {
return Some(current);
}
if !current.pop() {
return None;
}
}
}
fn home_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
fn store_local_agent_api_key(config_dir: &Path, api_key: &str) -> Result<String, ConfigError> {
store_local_config_secret_value(config_dir, api_key, "local_agent_key")
}
fn store_local_public_api_token(config_dir: &Path, api_token: &str) -> Result<String, ConfigError> {
store_local_config_secret_value(config_dir, api_token, "local_public_api_token")
}
fn store_local_config_secret_value(
config_dir: &Path,
value: &str,
ref_prefix: &str,
) -> Result<String, ConfigError> {
let key_dir = config_dir.join("keys");
fs::create_dir_all(&key_dir)?;
let secret = load_or_create_local_config_secret(&key_dir)?;
let key = Sha256::digest(secret.as_bytes());
let cipher =
Aes256Gcm::new_from_slice(&key).map_err(|error| ConfigError::Crypto(error.to_string()))?;
let nonce = random_config_nonce()?;
let mut sealed = cipher
.encrypt(&nonce, value.as_bytes())
.map_err(|error| ConfigError::Crypto(error.to_string()))?;
let auth_tag = sealed.split_off(sealed.len().saturating_sub(16));
let key_ref = format!(
"{ref_prefix}_{}",
hex_prefix(
&Sha256::digest([nonce.as_slice(), sealed.as_slice()].concat()),
24
)
);
let payload = StoredLocalConfigSecretPayload {
key_ref: &key_ref,
alg: "aes-256-gcm",
iv: URL_SAFE_NO_PAD.encode(nonce),
ciphertext: URL_SAFE_NO_PAD.encode(sealed),
auth_tag: URL_SAFE_NO_PAD.encode(auth_tag),
};
let contents = serde_json::to_string_pretty(&payload)
.map_err(|error| ConfigError::Crypto(error.to_string()))?;
write_private_file(
&key_dir.join(format!("{key_ref}.json")),
format!("{contents}\n").as_bytes(),
)?;
Ok(key_ref)
}
fn load_or_create_local_config_secret(key_dir: &Path) -> Result<String, ConfigError> {
fs::create_dir_all(key_dir)?;
let key_path = key_dir.join("local-config-secret");
match fs::read_to_string(&key_path) {
Ok(secret) => Ok(secret),
Err(error) if error.kind() == io::ErrorKind::NotFound => {
let secret_bytes = random_config_secret_bytes()?;
let secret = URL_SAFE_NO_PAD.encode(secret_bytes);
match write_private_file_new(&key_path, secret.as_bytes()) {
Ok(()) => Ok(secret),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
Ok(fs::read_to_string(&key_path)?)
}
Err(error) => Err(ConfigError::Io(error)),
}
}
Err(error) => Err(ConfigError::Io(error)),
}
}
fn load_key_payload(key_path: &Path) -> Result<LocalConfigSecretPayload, ConfigError> {
let contents = fs::read_to_string(key_path)
.map_err(|error| config_key_read_error(key_path, Some(error.to_string())))?;
serde_json::from_str(&contents)
.map_err(|error| config_key_read_error(key_path, Some(error.to_string())))
}
fn decode_key_part(key_path: &Path, value: &str) -> Result<Vec<u8>, ConfigError> {
URL_SAFE_NO_PAD
.decode(value)
.map_err(|error| config_key_read_error(key_path, Some(error.to_string())))
}
fn config_key_read_error(path: &Path, cause: Option<String>) -> ConfigError {
ConfigError::LocalAgentKeyCorrupt {
path: path.to_path_buf(),
suffix: cause.map_or_else(String::new, |message| format!(": {message}")),
}
}
fn normalize_managed_agent_provider(value: &str) -> Option<NonEmptyString> {
NonEmptyString::new(value.trim().to_lowercase())
}
fn managed_agent_provider_env_var(provider: &NonEmptyString) -> String {
format!("{}_API_KEY", provider.as_ref().to_uppercase())
}
fn read_skill_profile(
skill_directory: &Path,
skill_name: &str,
) -> Result<Option<ResolvedLocalProfile>, ConfigError> {
let path = skill_directory.join("X.yaml");
let Some(document) = read_optional_file(&path)? else {
return Ok(None);
};
validate_manifest_skill(&path, &document, skill_name)?;
Ok(Some(ResolvedLocalProfile {
profile_document: Some(document),
profile_source_path: Some(path),
source: LocalProfileSource::SkillProfile,
}))
}
fn read_profile_state(
skill_directory: &Path,
skill_name: &str,
) -> Result<Option<ResolvedLocalProfile>, ConfigError> {
let path = skill_directory.join(".runx").join("profile.json");
let Some(document) = read_optional_file(&path)? else {
return Ok(None);
};
let value = serde_json::from_str::<JsonValue>(&document)
.map_err(|_| ConfigError::InvalidProfileStateJson { path: path.clone() })?;
let JsonValue::Object(object) = value else {
return Err(ConfigError::NonObjectProfileState { path });
};
let Some(JsonValue::Object(profile)) = object.get("profile") else {
return Ok(None);
};
let Some(profile_document) = profile
.get("document")
.and_then(JsonValue::as_str)
.filter(|value| !value.is_empty())
else {
return Ok(None);
};
validate_manifest_skill(&path, profile_document, skill_name)?;
Ok(Some(ResolvedLocalProfile {
profile_document: Some(profile_document.to_owned()),
profile_source_path: Some(path),
source: LocalProfileSource::ProfileState,
}))
}
fn collect_binding_roots(start: &Path) -> Vec<PathBuf> {
let mut roots = Vec::new();
let mut current = start.to_path_buf();
loop {
let candidate = current.join("bindings");
if candidate.exists() && !roots.contains(&candidate) {
roots.push(candidate);
}
if !current.pop() {
break;
}
}
roots
}
fn read_workspace_profile(
skill_directory: &Path,
binding_root: &Path,
skill_name: &str,
) -> Result<Option<ResolvedLocalProfile>, ConfigError> {
let Some((owner, binding_skill)) = resolve_binding_locator(skill_directory, binding_root)
else {
return Ok(None);
};
if binding_skill != skill_name {
return Err(ConfigError::BindingLocatorMismatch {
skill_directory: skill_directory.to_path_buf(),
owner,
binding_skill,
skill_name: skill_name.to_owned(),
});
}
let path = binding_root
.join(&owner)
.join(&binding_skill)
.join("X.yaml");
let Some(document) = read_optional_file(&path)? else {
return Ok(None);
};
validate_manifest_skill(&path, &document, skill_name)?;
Ok(Some(ResolvedLocalProfile {
profile_document: Some(document),
profile_source_path: Some(path),
source: LocalProfileSource::WorkspaceBindings,
}))
}
fn resolve_binding_locator(
skill_directory: &Path,
binding_root: &Path,
) -> Option<(String, String)> {
let binding_container = binding_root.parent()?;
let relative = skill_directory.strip_prefix(binding_container).ok()?;
let segments = relative
.components()
.map(|component| component.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>();
let skill_segments = (segments.first()? == "skills").then_some(&segments[1..])?;
match skill_segments {
[skill] => Some(("runx".to_owned(), skill.clone())),
[owner, skill] => Some((owner.clone(), skill.clone())),
_ => None,
}
}
fn validate_manifest_skill(
path: &Path,
manifest_text: &str,
skill_name: &str,
) -> Result<(), ConfigError> {
let value = serde_norway::from_str::<JsonValue>(manifest_text).map_err(|error| {
ConfigError::InvalidJson {
path: path.to_path_buf(),
message: error.to_string(),
}
})?;
let manifest_skill = match &value {
JsonValue::Object(object) => object.get("skill").and_then(JsonValue::as_str),
_ => None,
};
if let Some(manifest_skill) = manifest_skill
&& manifest_skill != skill_name
{
return Err(ConfigError::ManifestSkillMismatch {
manifest_skill: manifest_skill.to_owned(),
skill_name: skill_name.to_owned(),
path: path.to_path_buf(),
});
}
Ok(())
}
fn read_optional_file(path: &Path) -> Result<Option<String>, ConfigError> {
match fs::read_to_string(path) {
Ok(contents) => Ok(Some(contents)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(ConfigError::Io(error)),
}
}
fn write_private_file(path: &Path, contents: &[u8]) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, contents)?;
set_private_permissions(path)?;
Ok(())
}
fn write_private_file_new(path: &Path, contents: &[u8]) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
use std::io::Write;
let mut file = options.open(path)?;
file.write_all(contents)
}
fn set_private_permissions(path: &Path) -> Result<(), ConfigError> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
fn hex_prefix(bytes: &[u8], len: usize) -> String {
let mut value = String::new();
for byte in bytes {
value.push_str(&format!("{byte:02x}"));
}
value.chars().take(len).collect()
}