Skip to main content

runx_runtime/
config.rs

1// rust-style-allow: large-file because local config, encrypted local key
2// storage, managed-agent overlay, and profile resolution are one parity slice.
3use std::collections::BTreeMap;
4use std::fs;
5use std::io;
6use std::path::{Path, PathBuf};
7
8use aes_gcm::Aes256Gcm;
9use aes_gcm::aead::{Aead, Generate, KeyInit, Nonce};
10use base64::Engine;
11use base64::engine::general_purpose::URL_SAFE_NO_PAD;
12use runx_contracts::JsonValue;
13use runx_contracts::schema::NonEmptyString;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use thiserror::Error;
17
18use crate::credentials::SecretString;
19
20#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct RunxConfigFile {
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub agent: Option<RunxAgentConfig>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub public: Option<RunxPublicConfig>,
27}
28
29#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct RunxAgentConfig {
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub provider: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub model: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub api_key_ref: Option<String>,
38}
39
40#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct RunxPublicConfig {
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub api_token_ref: Option<String>,
45}
46
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum ConfigKey {
49    AgentProvider,
50    AgentModel,
51    AgentApiKey,
52    PublicApiToken,
53}
54
55/// Canonical managed agent provider identifiers. The wire form on
56/// `ManagedAgentConfig::provider` is an open `NonEmptyString`; this module is
57/// for discoverability and shared default constants.
58pub mod managed_agent_provider {
59    /// OpenAI-compatible chat completion API.
60    pub const OPENAI: &str = "openai";
61    /// Anthropic Messages API.
62    pub const ANTHROPIC: &str = "anthropic";
63}
64
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct ManagedAgentConfig {
67    /// Open provider identifier (e.g. `managed_agent_provider::OPENAI`). Any
68    /// non-empty string is accepted; new providers do not need a code edit.
69    pub provider: NonEmptyString,
70    pub model: String,
71    pub api_key: SecretString,
72}
73
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub enum LocalProfileSource {
76    ProfileState,
77    SkillProfile,
78    WorkspaceBindings,
79    None,
80}
81
82#[derive(Clone, Debug, PartialEq, Eq)]
83pub struct ResolvedLocalProfile {
84    pub profile_document: Option<String>,
85    pub profile_source_path: Option<PathBuf>,
86    pub source: LocalProfileSource,
87}
88
89#[derive(Debug, Error)]
90pub enum ConfigError {
91    #[error("{path} is not valid JSON: {message}")]
92    InvalidJson { path: PathBuf, message: String },
93    #[error("{path} must contain a JSON object.")]
94    NonObjectJson { path: PathBuf },
95    #[error("unsupported runx config key {key}")]
96    UnsupportedKey { key: String },
97    #[error("runx local agent key corrupted or unreadable at {path}{suffix}")]
98    LocalAgentKeyCorrupt { path: PathBuf, suffix: String },
99    #[error("Skill profile state is not valid JSON: {path}")]
100    InvalidProfileStateJson { path: PathBuf },
101    #[error("Skill profile state must be an object: {path}")]
102    NonObjectProfileState { path: PathBuf },
103    #[error(
104        "Binding manifest skill '{manifest_skill}' does not match skill '{skill_name}': {path}"
105    )]
106    ManifestSkillMismatch {
107        manifest_skill: String,
108        skill_name: String,
109        path: PathBuf,
110    },
111    #[error(
112        "Skill package '{skill_directory}' resolves to binding path {owner}/{binding_skill}, but SKILL.md declares '{skill_name}'."
113    )]
114    BindingLocatorMismatch {
115        skill_directory: PathBuf,
116        owner: String,
117        binding_skill: String,
118        skill_name: String,
119    },
120    #[error("config crypto failed: {0}")]
121    Crypto(String),
122    #[error(transparent)]
123    Io(#[from] io::Error),
124}
125
126pub fn parse_config_key(key: &str) -> Result<ConfigKey, ConfigError> {
127    match key {
128        "agent.provider" => Ok(ConfigKey::AgentProvider),
129        "agent.model" => Ok(ConfigKey::AgentModel),
130        "agent.api_key" => Ok(ConfigKey::AgentApiKey),
131        "public.api_token" => Ok(ConfigKey::PublicApiToken),
132        _ => Err(ConfigError::UnsupportedKey {
133            key: key.to_owned(),
134        }),
135    }
136}
137
138pub fn resolve_path_from_user_input(
139    user_path: &str,
140    env: &BTreeMap<String, String>,
141    cwd: &Path,
142    prefer_existing: bool,
143) -> PathBuf {
144    let path = Path::new(user_path);
145    if path.is_absolute() {
146        return path.to_path_buf();
147    }
148    if prefer_existing {
149        for base in [
150            env.get("RUNX_CWD").map(PathBuf::from),
151            env.get("INIT_CWD").map(PathBuf::from),
152            find_runx_workspace_root(cwd),
153            Some(cwd.to_path_buf()),
154        ]
155        .into_iter()
156        .flatten()
157        {
158            let candidate = base.join(path);
159            if candidate.exists() {
160                return candidate;
161            }
162        }
163    }
164    resolve_runx_workspace_base(env, cwd).join(path)
165}
166
167pub fn resolve_runx_global_home_dir(env: &BTreeMap<String, String>, cwd: &Path) -> PathBuf {
168    env.get("RUNX_HOME").map_or_else(
169        || home_dir().join(".runx"),
170        |home| resolve_path_from_user_input(home, env, cwd, false),
171    )
172}
173
174pub fn resolve_runx_home_dir(env: &BTreeMap<String, String>, cwd: &Path) -> PathBuf {
175    resolve_runx_global_home_dir(env, cwd)
176}
177
178pub fn load_runx_config_file(config_path: &Path) -> Result<RunxConfigFile, ConfigError> {
179    let contents = match fs::read_to_string(config_path) {
180        Ok(contents) => contents,
181        Err(error) if error.kind() == io::ErrorKind::NotFound => {
182            return Ok(RunxConfigFile::default());
183        }
184        Err(error) => return Err(ConfigError::Io(error)),
185    };
186    let value =
187        serde_json::from_str::<JsonValue>(&contents).map_err(|error| ConfigError::InvalidJson {
188            path: config_path.to_path_buf(),
189            message: error.to_string(),
190        })?;
191    if !matches!(value, JsonValue::Object(_)) {
192        return Err(ConfigError::NonObjectJson {
193            path: config_path.to_path_buf(),
194        });
195    }
196    serde_json::from_str(&contents).map_err(|error| ConfigError::InvalidJson {
197        path: config_path.to_path_buf(),
198        message: error.to_string(),
199    })
200}
201
202pub fn write_runx_config_file(
203    config_path: &Path,
204    config: &RunxConfigFile,
205) -> Result<(), ConfigError> {
206    if let Some(parent) = config_path.parent() {
207        fs::create_dir_all(parent)?;
208    }
209    let contents =
210        serde_json::to_string_pretty(config).map_err(|error| ConfigError::InvalidJson {
211            path: config_path.to_path_buf(),
212            message: error.to_string(),
213        })?;
214    write_private_file(config_path, format!("{contents}\n").as_bytes())
215}
216
217pub fn update_runx_config_value(
218    mut config: RunxConfigFile,
219    key: ConfigKey,
220    value: &str,
221    config_dir: &Path,
222) -> Result<RunxConfigFile, ConfigError> {
223    match key {
224        ConfigKey::AgentProvider => {
225            let mut agent = config.agent.unwrap_or_default();
226            agent.provider = Some(value.to_owned());
227            config.agent = Some(agent);
228        }
229        ConfigKey::AgentModel => {
230            let mut agent = config.agent.unwrap_or_default();
231            agent.model = Some(value.to_owned());
232            config.agent = Some(agent);
233        }
234        ConfigKey::AgentApiKey => {
235            let mut agent = config.agent.unwrap_or_default();
236            agent.api_key_ref = Some(store_local_agent_api_key(config_dir, value)?);
237            config.agent = Some(agent);
238        }
239        ConfigKey::PublicApiToken => {
240            let mut public = config.public.unwrap_or_default();
241            public.api_token_ref = Some(store_local_public_api_token(config_dir, value)?);
242            config.public = Some(public);
243        }
244    }
245    Ok(config)
246}
247
248pub fn lookup_runx_config_value(config: &RunxConfigFile, key: ConfigKey) -> Option<String> {
249    match key {
250        ConfigKey::AgentProvider => config.agent.as_ref()?.provider.clone(),
251        ConfigKey::AgentModel => config.agent.as_ref()?.model.clone(),
252        ConfigKey::AgentApiKey => config
253            .agent
254            .as_ref()?
255            .api_key_ref
256            .as_ref()
257            .map(|_| "[encrypted]".to_owned()),
258        ConfigKey::PublicApiToken => config
259            .public
260            .as_ref()?
261            .api_token_ref
262            .as_ref()
263            .map(|_| "[encrypted]".to_owned()),
264    }
265}
266
267pub fn mask_runx_config_file(config: &RunxConfigFile) -> RunxConfigFile {
268    let mut masked = config.clone();
269    if let Some(agent) = masked.agent.as_mut()
270        && agent.api_key_ref.is_some()
271    {
272        agent.api_key_ref = Some("[encrypted]".to_owned());
273    }
274    if let Some(public) = masked.public.as_mut()
275        && public.api_token_ref.is_some()
276    {
277        public.api_token_ref = Some("[encrypted]".to_owned());
278    }
279    masked
280}
281
282pub fn load_local_agent_api_key(config_dir: &Path, key_ref: &str) -> Result<String, ConfigError> {
283    load_local_config_secret_value(config_dir, key_ref)
284}
285
286pub fn load_local_public_api_token(
287    config_dir: &Path,
288    token_ref: &str,
289) -> Result<String, ConfigError> {
290    load_local_config_secret_value(config_dir, token_ref)
291}
292
293fn load_local_config_secret_value(config_dir: &Path, key_ref: &str) -> Result<String, ConfigError> {
294    let key_path = config_dir.join("keys").join(format!("{key_ref}.json"));
295    let payload = load_key_payload(&key_path)?;
296    if payload.alg != "aes-256-gcm" {
297        return Err(config_key_read_error(&key_path, None));
298    }
299    let secret = load_or_create_local_config_secret(&config_dir.join("keys"))?;
300    let key = Sha256::digest(secret.as_bytes());
301    let cipher =
302        Aes256Gcm::new_from_slice(&key).map_err(|error| ConfigError::Crypto(error.to_string()))?;
303    let nonce_bytes = decode_key_part(&key_path, &payload.iv)?;
304    let ciphertext = decode_key_part(&key_path, &payload.ciphertext)?;
305    let auth_tag = decode_key_part(&key_path, &payload.auth_tag)?;
306    let mut sealed = ciphertext;
307    sealed.extend(auth_tag);
308    let nonce = config_nonce(&key_path, &nonce_bytes)?;
309    let plaintext = cipher
310        .decrypt(&nonce, sealed.as_ref())
311        .map_err(|error| config_key_read_error(&key_path, Some(error.to_string())))?;
312    String::from_utf8(plaintext)
313        .map_err(|error| config_key_read_error(&key_path, Some(error.to_string())))
314}
315
316pub fn load_managed_agent_config(
317    env: &BTreeMap<String, String>,
318    cwd: &Path,
319) -> Result<Option<ManagedAgentConfig>, ConfigError> {
320    let config_dir = resolve_runx_home_dir(env, cwd);
321    let config = load_runx_config_file(&config_dir.join("config.json"))?;
322    let provider = env
323        .get("RUNX_AGENT_PROVIDER")
324        .or_else(|| {
325            config
326                .agent
327                .as_ref()
328                .and_then(|agent| agent.provider.as_ref())
329        })
330        .and_then(|value| normalize_managed_agent_provider(value));
331    let Some(provider) = provider else {
332        return Ok(None);
333    };
334    let model = env
335        .get("RUNX_AGENT_MODEL")
336        .or_else(|| config.agent.as_ref().and_then(|agent| agent.model.as_ref()))
337        .map(|value| value.trim().to_owned())
338        .unwrap_or_default();
339    if model.is_empty() {
340        return Ok(None);
341    }
342    let provider_env_var = managed_agent_provider_env_var(&provider);
343    let provider_key = env.get(&provider_env_var);
344    let mut api_key = env
345        .get("RUNX_AGENT_API_KEY")
346        .or(provider_key)
347        .map(|value| value.trim().to_owned())
348        .unwrap_or_default();
349    if api_key.is_empty()
350        && let Some(key_ref) = config
351            .agent
352            .as_ref()
353            .and_then(|agent| agent.api_key_ref.as_ref())
354            .filter(|value| !value.is_empty())
355    {
356        api_key = load_local_agent_api_key(&config_dir, key_ref)?
357            .trim()
358            .to_owned();
359    }
360    if api_key.is_empty() {
361        return Ok(None);
362    }
363    Ok(Some(ManagedAgentConfig {
364        provider,
365        model,
366        api_key: SecretString::new(api_key),
367    }))
368}
369
370pub fn resolve_local_skill_profile(
371    skill_path: &Path,
372    skill_name: &str,
373) -> Result<ResolvedLocalProfile, ConfigError> {
374    let metadata = fs::metadata(skill_path)?;
375    let skill_directory = if metadata.is_dir() {
376        skill_path.to_path_buf()
377    } else {
378        skill_path
379            .parent()
380            .map(Path::to_path_buf)
381            .unwrap_or_else(|| PathBuf::from("."))
382    };
383    if let Some(profile) = read_skill_profile(&skill_directory, skill_name)? {
384        return Ok(profile);
385    }
386    if let Some(profile) = read_profile_state(&skill_directory, skill_name)? {
387        return Ok(profile);
388    }
389    for binding_root in collect_binding_roots(&skill_directory) {
390        if let Some(profile) = read_workspace_profile(&skill_directory, &binding_root, skill_name)?
391        {
392            return Ok(profile);
393        }
394    }
395    Ok(ResolvedLocalProfile {
396        profile_document: None,
397        profile_source_path: None,
398        source: LocalProfileSource::None,
399    })
400}
401
402#[derive(Deserialize)]
403struct LocalConfigSecretPayload {
404    alg: String,
405    iv: String,
406    ciphertext: String,
407    auth_tag: String,
408}
409
410#[derive(Serialize)]
411struct StoredLocalConfigSecretPayload<'a> {
412    #[serde(rename = "ref")]
413    key_ref: &'a str,
414    alg: &'static str,
415    iv: String,
416    ciphertext: String,
417    auth_tag: String,
418}
419
420type ConfigNonce = Nonce<Aes256Gcm>;
421
422fn config_nonce(key_path: &Path, nonce_bytes: &[u8]) -> Result<ConfigNonce, ConfigError> {
423    ConfigNonce::try_from(nonce_bytes).map_err(|_| {
424        config_key_read_error(
425            key_path,
426            Some(format!(
427                "expected 12-byte aes-256-gcm nonce, found {} bytes",
428                nonce_bytes.len()
429            )),
430        )
431    })
432}
433
434fn random_config_nonce() -> Result<ConfigNonce, ConfigError> {
435    ConfigNonce::try_generate().map_err(|error| ConfigError::Crypto(error.to_string()))
436}
437
438fn random_config_secret_bytes() -> Result<[u8; 32], ConfigError> {
439    <[u8; 32]>::try_generate().map_err(|error| ConfigError::Crypto(error.to_string()))
440}
441
442pub fn resolve_runx_workspace_base(env: &BTreeMap<String, String>, cwd: &Path) -> PathBuf {
443    env.get("RUNX_CWD")
444        .map(PathBuf::from)
445        .or_else(|| find_runx_workspace_root(cwd))
446        .or_else(|| env.get("INIT_CWD").map(PathBuf::from))
447        .unwrap_or_else(|| cwd.to_path_buf())
448}
449
450fn find_runx_workspace_root(start: &Path) -> Option<PathBuf> {
451    let mut current = start.to_path_buf();
452    loop {
453        if current.join("pnpm-workspace.yaml").exists() {
454            return Some(current);
455        }
456        if !current.pop() {
457            return None;
458        }
459    }
460}
461
462fn home_dir() -> PathBuf {
463    std::env::var_os("HOME")
464        .map(PathBuf::from)
465        .unwrap_or_else(|| PathBuf::from("."))
466}
467
468fn store_local_agent_api_key(config_dir: &Path, api_key: &str) -> Result<String, ConfigError> {
469    store_local_config_secret_value(config_dir, api_key, "local_agent_key")
470}
471
472fn store_local_public_api_token(config_dir: &Path, api_token: &str) -> Result<String, ConfigError> {
473    store_local_config_secret_value(config_dir, api_token, "local_public_api_token")
474}
475
476fn store_local_config_secret_value(
477    config_dir: &Path,
478    value: &str,
479    ref_prefix: &str,
480) -> Result<String, ConfigError> {
481    let key_dir = config_dir.join("keys");
482    fs::create_dir_all(&key_dir)?;
483    let secret = load_or_create_local_config_secret(&key_dir)?;
484    let key = Sha256::digest(secret.as_bytes());
485    let cipher =
486        Aes256Gcm::new_from_slice(&key).map_err(|error| ConfigError::Crypto(error.to_string()))?;
487    let nonce = random_config_nonce()?;
488    let mut sealed = cipher
489        .encrypt(&nonce, value.as_bytes())
490        .map_err(|error| ConfigError::Crypto(error.to_string()))?;
491    let auth_tag = sealed.split_off(sealed.len().saturating_sub(16));
492    let key_ref = format!(
493        "{ref_prefix}_{}",
494        hex_prefix(
495            &Sha256::digest([nonce.as_slice(), sealed.as_slice()].concat()),
496            24
497        )
498    );
499    let payload = StoredLocalConfigSecretPayload {
500        key_ref: &key_ref,
501        alg: "aes-256-gcm",
502        iv: URL_SAFE_NO_PAD.encode(nonce),
503        ciphertext: URL_SAFE_NO_PAD.encode(sealed),
504        auth_tag: URL_SAFE_NO_PAD.encode(auth_tag),
505    };
506    let contents = serde_json::to_string_pretty(&payload)
507        .map_err(|error| ConfigError::Crypto(error.to_string()))?;
508    write_private_file(
509        &key_dir.join(format!("{key_ref}.json")),
510        format!("{contents}\n").as_bytes(),
511    )?;
512    Ok(key_ref)
513}
514
515fn load_or_create_local_config_secret(key_dir: &Path) -> Result<String, ConfigError> {
516    fs::create_dir_all(key_dir)?;
517    let key_path = key_dir.join("local-config-secret");
518    match fs::read_to_string(&key_path) {
519        Ok(secret) => Ok(secret),
520        Err(error) if error.kind() == io::ErrorKind::NotFound => {
521            let secret_bytes = random_config_secret_bytes()?;
522            let secret = URL_SAFE_NO_PAD.encode(secret_bytes);
523            match write_private_file_new(&key_path, secret.as_bytes()) {
524                Ok(()) => Ok(secret),
525                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
526                    Ok(fs::read_to_string(&key_path)?)
527                }
528                Err(error) => Err(ConfigError::Io(error)),
529            }
530        }
531        Err(error) => Err(ConfigError::Io(error)),
532    }
533}
534
535fn load_key_payload(key_path: &Path) -> Result<LocalConfigSecretPayload, ConfigError> {
536    let contents = fs::read_to_string(key_path)
537        .map_err(|error| config_key_read_error(key_path, Some(error.to_string())))?;
538    serde_json::from_str(&contents)
539        .map_err(|error| config_key_read_error(key_path, Some(error.to_string())))
540}
541
542fn decode_key_part(key_path: &Path, value: &str) -> Result<Vec<u8>, ConfigError> {
543    URL_SAFE_NO_PAD
544        .decode(value)
545        .map_err(|error| config_key_read_error(key_path, Some(error.to_string())))
546}
547
548fn config_key_read_error(path: &Path, cause: Option<String>) -> ConfigError {
549    ConfigError::LocalAgentKeyCorrupt {
550        path: path.to_path_buf(),
551        suffix: cause.map_or_else(String::new, |message| format!(": {message}")),
552    }
553}
554
555fn normalize_managed_agent_provider(value: &str) -> Option<NonEmptyString> {
556    NonEmptyString::new(value.trim().to_lowercase())
557}
558
559/// Derive the env var name that carries the API key for a given managed agent
560/// provider. Follows the `<UPPERCASED>_API_KEY` convention (e.g. `OPENAI_API_KEY`,
561/// `ANTHROPIC_API_KEY`), so new providers work without a code edit. Callers can
562/// always override via `RUNX_AGENT_API_KEY`.
563fn managed_agent_provider_env_var(provider: &NonEmptyString) -> String {
564    format!("{}_API_KEY", provider.as_ref().to_uppercase())
565}
566
567fn read_skill_profile(
568    skill_directory: &Path,
569    skill_name: &str,
570) -> Result<Option<ResolvedLocalProfile>, ConfigError> {
571    let path = skill_directory.join("X.yaml");
572    let Some(document) = read_optional_file(&path)? else {
573        return Ok(None);
574    };
575    validate_manifest_skill(&path, &document, skill_name)?;
576    Ok(Some(ResolvedLocalProfile {
577        profile_document: Some(document),
578        profile_source_path: Some(path),
579        source: LocalProfileSource::SkillProfile,
580    }))
581}
582
583fn read_profile_state(
584    skill_directory: &Path,
585    skill_name: &str,
586) -> Result<Option<ResolvedLocalProfile>, ConfigError> {
587    let path = skill_directory.join(".runx").join("profile.json");
588    let Some(document) = read_optional_file(&path)? else {
589        return Ok(None);
590    };
591    let value = serde_json::from_str::<JsonValue>(&document)
592        .map_err(|_| ConfigError::InvalidProfileStateJson { path: path.clone() })?;
593    let JsonValue::Object(object) = value else {
594        return Err(ConfigError::NonObjectProfileState { path });
595    };
596    let Some(JsonValue::Object(profile)) = object.get("profile") else {
597        return Ok(None);
598    };
599    let Some(profile_document) = profile
600        .get("document")
601        .and_then(JsonValue::as_str)
602        .filter(|value| !value.is_empty())
603    else {
604        return Ok(None);
605    };
606    validate_manifest_skill(&path, profile_document, skill_name)?;
607    Ok(Some(ResolvedLocalProfile {
608        profile_document: Some(profile_document.to_owned()),
609        profile_source_path: Some(path),
610        source: LocalProfileSource::ProfileState,
611    }))
612}
613
614fn collect_binding_roots(start: &Path) -> Vec<PathBuf> {
615    let mut roots = Vec::new();
616    let mut current = start.to_path_buf();
617    loop {
618        let candidate = current.join("bindings");
619        if candidate.exists() && !roots.contains(&candidate) {
620            roots.push(candidate);
621        }
622        if !current.pop() {
623            break;
624        }
625    }
626    roots
627}
628
629fn read_workspace_profile(
630    skill_directory: &Path,
631    binding_root: &Path,
632    skill_name: &str,
633) -> Result<Option<ResolvedLocalProfile>, ConfigError> {
634    let Some((owner, binding_skill)) = resolve_binding_locator(skill_directory, binding_root)
635    else {
636        return Ok(None);
637    };
638    if binding_skill != skill_name {
639        return Err(ConfigError::BindingLocatorMismatch {
640            skill_directory: skill_directory.to_path_buf(),
641            owner,
642            binding_skill,
643            skill_name: skill_name.to_owned(),
644        });
645    }
646    let path = binding_root
647        .join(&owner)
648        .join(&binding_skill)
649        .join("X.yaml");
650    let Some(document) = read_optional_file(&path)? else {
651        return Ok(None);
652    };
653    validate_manifest_skill(&path, &document, skill_name)?;
654    Ok(Some(ResolvedLocalProfile {
655        profile_document: Some(document),
656        profile_source_path: Some(path),
657        source: LocalProfileSource::WorkspaceBindings,
658    }))
659}
660
661fn resolve_binding_locator(
662    skill_directory: &Path,
663    binding_root: &Path,
664) -> Option<(String, String)> {
665    let binding_container = binding_root.parent()?;
666    let relative = skill_directory.strip_prefix(binding_container).ok()?;
667    let segments = relative
668        .components()
669        .map(|component| component.as_os_str().to_string_lossy().to_string())
670        .collect::<Vec<_>>();
671    let skill_segments = (segments.first()? == "skills").then_some(&segments[1..])?;
672    match skill_segments {
673        [skill] => Some(("runx".to_owned(), skill.clone())),
674        [owner, skill] => Some((owner.clone(), skill.clone())),
675        _ => None,
676    }
677}
678
679fn validate_manifest_skill(
680    path: &Path,
681    manifest_text: &str,
682    skill_name: &str,
683) -> Result<(), ConfigError> {
684    let value = serde_norway::from_str::<JsonValue>(manifest_text).map_err(|error| {
685        ConfigError::InvalidJson {
686            path: path.to_path_buf(),
687            message: error.to_string(),
688        }
689    })?;
690    let manifest_skill = match &value {
691        JsonValue::Object(object) => object.get("skill").and_then(JsonValue::as_str),
692        _ => None,
693    };
694    if let Some(manifest_skill) = manifest_skill
695        && manifest_skill != skill_name
696    {
697        return Err(ConfigError::ManifestSkillMismatch {
698            manifest_skill: manifest_skill.to_owned(),
699            skill_name: skill_name.to_owned(),
700            path: path.to_path_buf(),
701        });
702    }
703    Ok(())
704}
705
706fn read_optional_file(path: &Path) -> Result<Option<String>, ConfigError> {
707    match fs::read_to_string(path) {
708        Ok(contents) => Ok(Some(contents)),
709        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
710        Err(error) => Err(ConfigError::Io(error)),
711    }
712}
713
714fn write_private_file(path: &Path, contents: &[u8]) -> Result<(), ConfigError> {
715    if let Some(parent) = path.parent() {
716        fs::create_dir_all(parent)?;
717    }
718    fs::write(path, contents)?;
719    set_private_permissions(path)?;
720    Ok(())
721}
722
723fn write_private_file_new(path: &Path, contents: &[u8]) -> io::Result<()> {
724    if let Some(parent) = path.parent() {
725        fs::create_dir_all(parent)?;
726    }
727    let mut options = fs::OpenOptions::new();
728    options.write(true).create_new(true);
729    #[cfg(unix)]
730    {
731        use std::os::unix::fs::OpenOptionsExt;
732        options.mode(0o600);
733    }
734    use std::io::Write;
735    let mut file = options.open(path)?;
736    file.write_all(contents)
737}
738
739fn set_private_permissions(path: &Path) -> Result<(), ConfigError> {
740    #[cfg(unix)]
741    {
742        use std::os::unix::fs::PermissionsExt;
743        fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
744    }
745    Ok(())
746}
747
748fn hex_prefix(bytes: &[u8], len: usize) -> String {
749    let mut value = String::new();
750    for byte in bytes {
751        value.push_str(&format!("{byte:02x}"));
752    }
753    value.chars().take(len).collect()
754}