use std::collections::{BTreeMap, BTreeSet};
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::process::Command;
use serde::Serialize;
use crate::cli::{DefaultArgsMode, LaunchRequest};
use crate::config::{Config, DomainConfig, DomainName, HarnessName, InvalidName, ModelName};
use crate::context::{
ContextError, ResolvedContext, read_directory_config, resolve_context,
resolve_context_with_directory,
};
use crate::prompt::{PromptError, compose_and_inject};
const CONTEXT_PLACEHOLDER: &str = "{context}";
pub(crate) const SESSION_ACTIVE_ENVIRONMENT: &str = "CLANKER_SESSION";
pub(crate) const SESSION_MARKER_VALUE: &str = "1";
pub(crate) const SESSION_VERSION_ENVIRONMENT: &str = "CLANKER_SESSION_VERSION";
pub(crate) const SESSION_VERSION: &str = "2";
pub(crate) const SESSION_INVOCATION_ENVIRONMENT: &str = "CLANKER_SESSION_INVOCATION";
pub(crate) const SESSION_HARNESS_ENVIRONMENT: &str = "CLANKER_SESSION_HARNESS";
pub(crate) const SESSION_CONTEXT_ENVIRONMENT: &str = "CLANKER_SESSION_CONTEXT";
pub(crate) const SESSION_CONTEXT_SOURCE_ENVIRONMENT: &str = "CLANKER_SESSION_CONTEXT_SOURCE";
pub(crate) const SESSION_DOMAINS_ENVIRONMENT: &str = "CLANKER_SESSION_DOMAINS";
pub(crate) const SESSION_MODEL_ENVIRONMENT: &str = "CLANKER_SESSION_MODEL";
pub(crate) const SESSION_FAMILY_ENVIRONMENT: &str = "CLANKER_SESSION_FAMILY";
#[derive(Debug, Clone, Copy)]
pub struct LaunchInputs<'a> {
pub home: &'a Path,
pub current_dir: &'a Path,
pub hostname: &'a str,
pub context_override: Option<&'a str>,
pub context_environment: Option<&'a str>,
pub domain_override: Option<&'a str>,
pub domain_environment: Option<&'a str>,
pub model_environment: Option<&'a str>,
pub inherited_path: Option<&'a OsStr>,
pub inherited_environment: &'a BTreeMap<OsString, OsString>,
}
#[derive(Debug, Clone)]
pub struct LaunchPlan {
pub invocation: String,
pub harness: HarnessName,
pub context: ResolvedContext,
pub domains: Vec<DomainName>,
pub model: Option<ModelName>,
pub family: Option<String>,
pub executable: OsString,
pub args: Vec<OsString>,
pub environment: BTreeMap<OsString, OsString>,
pub secret_environment: BTreeSet<OsString>,
pub prompt: Option<String>,
pub prompt_file: Option<PathBuf>,
}
#[derive(Debug, Serialize)]
pub struct LaunchPlanOutput {
invocation: String,
harness: String,
context: String,
context_source: &'static str,
domains: Vec<String>,
model: Option<String>,
family: Option<String>,
executable: String,
args: Vec<String>,
environment: BTreeMap<String, String>,
prompt: Option<String>,
prompt_file: Option<String>,
}
impl From<&LaunchPlan> for LaunchPlanOutput {
fn from(plan: &LaunchPlan) -> Self {
Self {
invocation: plan.invocation.clone(),
harness: plan.harness.to_string(),
context: plan.context.name.to_string(),
context_source: plan.context.source.label(),
domains: plan.domains.iter().map(ToString::to_string).collect(),
model: plan.model.as_ref().map(ToString::to_string),
family: plan.family.clone(),
executable: plan.executable.to_string_lossy().into_owned(),
args: plan
.args
.iter()
.map(|argument| argument.to_string_lossy().into_owned())
.collect(),
environment: plan
.environment
.iter()
.map(|(key, value)| {
(
key.to_string_lossy().into_owned(),
if plan.secret_environment.contains(key) {
"<redacted>".to_string()
} else {
value.to_string_lossy().into_owned()
},
)
})
.collect(),
prompt: plan.prompt.clone(),
prompt_file: plan
.prompt_file
.as_ref()
.map(|path| path.display().to_string()),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum LaunchError {
#[error("invoked as `{0}`; expected a <harness>-launch symlink")]
InvalidInvocation(String),
#[error("unknown harness `{0}` in clanker config")]
UnknownHarness(String),
#[error("unknown domain `{0}` in clanker config")]
UnknownDomain(String),
#[error("unknown model `{0}` in clanker config")]
UnknownModel(String),
#[error("model `{model}` requires harness `{required}`, not `{selected}`")]
ModelHarnessMismatch {
model: ModelName,
required: HarnessName,
selected: HarnessName,
},
#[error(
"model `{model}` requires environment variable `{source_variable}` for `{target}`; add `{source_variable}` to the SOPS-backed shell environment"
)]
MissingModelSecret {
model: ModelName,
target: String,
source_variable: String,
},
#[error("invalid {axis} from {origin}: {error}")]
InvalidAxisName {
axis: &'static str,
origin: &'static str,
error: InvalidName,
},
#[error(transparent)]
Context(#[from] ContextError),
#[error(
"{invocation}: config directory {} does not exist; create it or select a different context",
path.display()
)]
MissingConfigDirectory {
invocation: String,
path: PathBuf,
},
#[error(transparent)]
Prompt(#[from] PromptError),
#[error("unsupported configured path `{0}`; use `~` or `~/...`")]
UnsupportedTilde(String),
#[error("failed to build launch PATH: {0}")]
PathList(#[from] std::env::JoinPathsError),
#[error("failed to exec `{executable}`: {source}")]
Exec {
executable: String,
source: std::io::Error,
},
}
pub fn build_symlink_plan(
invocation: &str,
args: Vec<OsString>,
config: &Config,
inputs: LaunchInputs<'_>,
) -> Result<LaunchPlan, LaunchError> {
let harness = invocation
.strip_suffix("-launch")
.filter(|name| !name.is_empty())
.ok_or_else(|| LaunchError::InvalidInvocation(invocation.to_string()))?;
let harness = HarnessName::new(harness)
.map_err(|_| LaunchError::InvalidInvocation(invocation.to_string()))?;
build_harness_plan(invocation, harness, args, config, inputs)
}
pub fn build_harness_plan(
invocation: &str,
harness: HarnessName,
args: Vec<OsString>,
config: &Config,
inputs: LaunchInputs<'_>,
) -> Result<LaunchPlan, LaunchError> {
let context = resolve_context(
inputs.current_dir,
None,
inputs.context_override,
inputs.context_environment,
inputs.hostname,
config,
)?;
let mut plan =
build_harness_plan_for_context(invocation, harness, args, config, inputs, context)?;
apply_session_environment(&mut plan);
Ok(plan)
}
fn build_harness_plan_for_context(
invocation: &str,
harness: HarnessName,
args: Vec<OsString>,
config: &Config,
inputs: LaunchInputs<'_>,
context: ResolvedContext,
) -> Result<LaunchPlan, LaunchError> {
let harness_config = config
.harness
.get(&harness)
.ok_or_else(|| LaunchError::UnknownHarness(harness.to_string()))?;
let shim_path = expand_path(&config.defaults.shim_path, inputs.home)?;
let mut path_parts = vec![shim_path];
if let Some(inherited) = inputs.inherited_path {
path_parts.extend(std::env::split_paths(inherited));
}
let launch_path = std::env::join_paths(path_parts)?;
let mut environment = BTreeMap::new();
environment.insert(OsString::from("PATH"), launch_path);
environment.insert(
OsString::from("CONTEXT"),
OsString::from(context.name.as_str()),
);
if let Some(config_dir) = &harness_config.config_dir {
let configured = config_dir
.path
.replace(CONTEXT_PLACEHOLDER, context.name.as_str());
let path = expand_path(&configured, inputs.home)?;
if !path.is_dir() {
return Err(LaunchError::MissingConfigDirectory {
invocation: invocation.to_string(),
path,
});
}
environment.insert(
OsString::from(&config_dir.environment),
path.as_os_str().to_os_string(),
);
}
Ok(LaunchPlan {
invocation: invocation.to_string(),
harness,
context,
domains: Vec::new(),
model: None,
family: None,
executable: OsString::from(&harness_config.bin),
args,
environment,
secret_environment: BTreeSet::new(),
prompt: None,
prompt_file: None,
})
}
pub fn build_command_plan(
invocation: &str,
request: &LaunchRequest,
config: &Config,
inputs: LaunchInputs<'_>,
persist_prompt_file: bool,
) -> Result<LaunchPlan, LaunchError> {
let directory_config = read_directory_config(inputs.current_dir)?;
let context = resolve_context_with_directory(
request.context.as_ref(),
inputs.context_override,
inputs.context_environment,
inputs.hostname,
config,
directory_config.as_ref(),
)?;
let domains = resolve_domains(request, directory_config.as_ref(), config, inputs)?;
let domain_configs = domains
.iter()
.map(|domain| {
config
.domain
.get(domain)
.ok_or_else(|| LaunchError::UnknownDomain(domain.to_string()))
})
.collect::<Result<Vec<_>, _>>()?;
let model = resolve_model(request, directory_config.as_ref(), &domain_configs, inputs)?;
if let Some(model) = &model {
if !config.model.contains_key(model) {
return Err(LaunchError::UnknownModel(model.to_string()));
}
}
let model_config = model.as_ref().and_then(|name| config.model.get(name));
let mut plan = build_harness_plan_for_context(
invocation,
request.harness.clone(),
request.passthrough.clone(),
config,
inputs,
context,
)?;
plan.domains.clone_from(&domains);
if let (Some(model_name), Some(model_config)) = (&model, model_config) {
if model_config.harness == request.harness {
plan.model = Some(model_name.clone());
} else {
return Err(LaunchError::ModelHarnessMismatch {
model: model_name.clone(),
required: model_config.harness.clone(),
selected: request.harness.clone(),
});
}
}
let harness_config = config
.harness
.get(&request.harness)
.ok_or_else(|| LaunchError::UnknownHarness(request.harness.to_string()))?;
let applied_model = plan.model.as_ref().and_then(|name| config.model.get(name));
let family = applied_model
.and_then(|model| model.family.as_ref())
.unwrap_or(&harness_config.family);
plan.family = Some(family.as_prompter_family().as_str().to_string());
let mut arguments: Vec<OsString> = match request.default_args {
DefaultArgsMode::Suppress => Vec::new(),
DefaultArgsMode::Apply => harness_config
.default_args
.iter()
.map(OsString::from)
.collect(),
};
if !request.no_prompt {
let mut profiles = domain_configs
.iter()
.flat_map(|domain| domain.profiles.iter().cloned())
.collect::<Vec<_>>();
profiles.extend(request.profiles.iter().cloned());
let injection = compose_and_inject(
&profiles,
family,
harness_config,
config,
inputs.home,
persist_prompt_file,
)?;
arguments.extend(injection.arguments);
plan.environment.extend(injection.environment);
plan.prompt = injection.prompt;
plan.prompt_file = injection.cache_path;
}
if let Some(model_config) = applied_model {
arguments.extend(model_config.harness_args.iter().map(OsString::from));
}
arguments.append(&mut plan.args);
plan.args = arguments;
apply_domain_environment(&mut plan, &domain_configs);
if let (Some(model_name), Some(model_config)) = (plan.model.clone(), applied_model) {
apply_model_environment(&mut plan, &model_name, model_config, inputs)?;
}
if request.sandbox {
let wrapper = expand_path(&config.defaults.sandbox_wrapper, inputs.home)?;
let mut sandbox_arguments = vec![plan.executable.clone()];
sandbox_arguments.append(&mut plan.args);
plan.executable = wrapper.into_os_string();
plan.args = sandbox_arguments;
}
apply_session_environment(&mut plan);
Ok(plan)
}
fn resolve_domains(
request: &LaunchRequest,
directory_config: Option<&crate::context::DirectoryConfig>,
config: &Config,
inputs: LaunchInputs<'_>,
) -> Result<Vec<DomainName>, LaunchError> {
let domains = if request.domains.is_empty() {
vec![resolve_fallback_domain(directory_config, config, inputs)?]
} else {
request.domains.clone()
};
for domain in &domains {
if !config.domain.contains_key(domain) {
return Err(LaunchError::UnknownDomain(domain.to_string()));
}
}
Ok(domains)
}
fn resolve_fallback_domain(
directory_config: Option<&crate::context::DirectoryConfig>,
config: &Config,
inputs: LaunchInputs<'_>,
) -> Result<DomainName, LaunchError> {
if let Some(domain) = parse_optional_domain(inputs.domain_override, "CLANKER_DOMAIN_OVERRIDE")?
{
Ok(domain)
} else if let Some(domain) = directory_config.and_then(|directory| directory.domain.clone()) {
Ok(domain)
} else if let Some(domain) = parse_optional_domain(inputs.domain_environment, "CLANKER_DOMAIN")?
{
Ok(domain)
} else {
Ok(config.defaults.domain.clone())
}
}
fn resolve_model(
request: &LaunchRequest,
directory_config: Option<&crate::context::DirectoryConfig>,
domains: &[&DomainConfig],
inputs: LaunchInputs<'_>,
) -> Result<Option<ModelName>, LaunchError> {
if let Some(model) = &request.model {
return Ok(Some(model.clone()));
}
if let Some(model) = directory_config.and_then(|directory| directory.model.clone()) {
return Ok(Some(model));
}
if let Some(model) = parse_optional_model(inputs.model_environment, "CLANKER_MODEL")? {
return Ok(Some(model));
}
Ok(domains
.iter()
.find_map(|domain| domain.default_model.clone()))
}
fn parse_optional_domain(
value: Option<&str>,
origin: &'static str,
) -> Result<Option<DomainName>, LaunchError> {
parse_optional_axis(value, "domain", origin, DomainName::new)
}
fn parse_optional_model(
value: Option<&str>,
origin: &'static str,
) -> Result<Option<ModelName>, LaunchError> {
parse_optional_axis(value, "model", origin, ModelName::new)
}
fn parse_optional_axis<T>(
value: Option<&str>,
axis: &'static str,
origin: &'static str,
parse: impl FnOnce(String) -> Result<T, InvalidName>,
) -> Result<Option<T>, LaunchError> {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
parse(value.to_string())
.map(Some)
.map_err(|error| LaunchError::InvalidAxisName {
axis,
origin,
error,
})
}
fn apply_domain_environment(plan: &mut LaunchPlan, domains: &[&DomainConfig]) {
let mut environment = BTreeMap::new();
for domain in domains {
for (key, value) in &domain.env {
environment
.entry(OsString::from(key))
.or_insert_with(|| OsString::from(value));
}
}
plan.environment.extend(environment);
}
fn apply_model_environment(
plan: &mut LaunchPlan,
model_name: &ModelName,
model: &crate::config::ModelConfig,
inputs: LaunchInputs<'_>,
) -> Result<(), LaunchError> {
plan.environment.extend(
model
.env
.iter()
.map(|(key, value)| (OsString::from(key), OsString::from(value))),
);
for (target, source) in &model.env_from_secrets {
let source_key = OsStr::new(source);
let value = inputs
.inherited_environment
.get(source_key)
.ok_or_else(|| LaunchError::MissingModelSecret {
model: model_name.clone(),
target: target.clone(),
source_variable: source.clone(),
})?;
let target = OsString::from(target);
plan.environment.insert(target.clone(), value.clone());
plan.secret_environment.insert(target);
}
Ok(())
}
fn apply_session_environment(plan: &mut LaunchPlan) {
let domains = plan
.domains
.iter()
.map(DomainName::as_str)
.collect::<Vec<_>>()
.join(",");
let values = [
(SESSION_ACTIVE_ENVIRONMENT, SESSION_MARKER_VALUE),
(SESSION_VERSION_ENVIRONMENT, SESSION_VERSION),
(SESSION_INVOCATION_ENVIRONMENT, plan.invocation.as_str()),
(SESSION_HARNESS_ENVIRONMENT, plan.harness.as_str()),
(SESSION_CONTEXT_ENVIRONMENT, plan.context.name.as_str()),
(
SESSION_CONTEXT_SOURCE_ENVIRONMENT,
plan.context.source.label(),
),
(SESSION_DOMAINS_ENVIRONMENT, domains.as_str()),
(
SESSION_MODEL_ENVIRONMENT,
plan.model.as_ref().map_or("", ModelName::as_str),
),
(
SESSION_FAMILY_ENVIRONMENT,
plan.family.as_deref().unwrap_or(""),
),
];
plan.environment.extend(
values
.into_iter()
.map(|(key, value)| (OsString::from(key), OsString::from(value))),
);
}
#[cfg(unix)]
pub fn execute_plan(plan: &LaunchPlan) -> Result<i32, LaunchError> {
use std::os::unix::process::CommandExt;
let mut command = Command::new(&plan.executable);
command.args(&plan.args);
for (key, value) in &plan.environment {
command.env(key, value);
}
let executable = plan.executable.to_string_lossy().into_owned();
let source = command.exec();
Err(LaunchError::Exec { executable, source })
}
pub(crate) fn expand_path(value: &str, home: &Path) -> Result<PathBuf, LaunchError> {
if value == "~" {
return Ok(home.to_path_buf());
}
if let Some(relative) = value.strip_prefix("~/") {
return Ok(home.join(relative));
}
if value.starts_with('~') {
return Err(LaunchError::UnsupportedTilde(value.to_string()));
}
Ok(PathBuf::from(value))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expands_only_supported_tilde_forms() {
let home = Path::new("/tmp/home");
assert_eq!(expand_path("~", home).unwrap(), home);
assert_eq!(
expand_path("~/.config/tool", home).unwrap(),
home.join(".config/tool")
);
assert!(expand_path("~someone/tool", home).is_err());
}
}