use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex};
use astrid_capsule::manifest::EnvDef;
use astrid_core::PrincipalId;
use astrid_core::dirs::AstridHome;
use astrid_events::{AstridEvent, EventBus, EventMetadata, EventReceiver};
use astrid_storage::{FileSecretStore, SecretStore};
use astrid_types::Topic;
use astrid_types::ipc::{IpcMessage, IpcPayload, OnboardingFieldType};
use super::install_prompts::order_env_keys;
pub(crate) fn write_headless_env_fields(
env_defs: &HashMap<String, EnvDef>,
env_path: &Path,
capsule_id: &str,
home: &AstridHome,
principal: &PrincipalId,
vars: &HashMap<String, String>,
) -> anyhow::Result<()> {
for key in vars.keys() {
if !env_defs.contains_key(key) {
anyhow::bail!("--var names no [env] field in {capsule_id}: {key}");
}
}
let mut values: serde_json::Map<String, serde_json::Value> = if env_path.exists() {
let content = std::fs::read_to_string(env_path)?;
serde_json::from_str(&content).unwrap_or_default()
} else {
serde_json::Map::new()
};
let secret_store =
FileSecretStore::new(home.secrets_dir().join(principal.as_str()).join(capsule_id));
for key in order_env_keys(env_defs) {
let def = &env_defs[&key];
let env_key = headless_env_key(&key);
let supplied = vars
.get(&key)
.cloned()
.or_else(|| std::env::var(&env_key).ok());
let existing = if def.env_type == "secret" {
secret_store.exists(&key)?
} else {
values.contains_key(&key)
};
if supplied.is_none() && existing {
continue;
}
let resolved = supplied
.or_else(|| def.default.as_ref().map(json_value_string))
.ok_or_else(|| {
anyhow::anyhow!(
"required value is missing for {capsule_id}.{key} \
(use --var {key}=… or set {env_key})"
)
})?;
if !def.enum_values.is_empty() && !def.enum_values.iter().any(|item| item == &resolved) {
anyhow::bail!(
"invalid value for {capsule_id}.{key}: expected one of {}, got {resolved:?}",
def.enum_values.join(", ")
);
}
if def.env_type == "secret" {
if resolved.is_empty() {
let _ = secret_store.delete(&key)?;
} else {
secret_store.set(&key, &resolved)?;
}
values.insert(key, serde_json::Value::String(String::new()));
} else {
values.insert(key, serde_json::Value::String(resolved));
}
}
if !env_defs.is_empty() {
if let Some(parent) = env_path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(&values)?;
std::fs::write(env_path, json)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(env_path, std::fs::Permissions::from_mode(0o600))?;
}
}
Ok(())
}
fn headless_env_key(key: &str) -> String {
format!(
"ASTRID_VAR_{}",
key.chars()
.map(|ch| if ch.is_ascii_alphanumeric() {
ch.to_ascii_uppercase()
} else {
'_'
})
.collect::<String>()
)
}
fn json_value_string(value: &serde_json::Value) -> String {
value
.as_str()
.map_or_else(|| value.to_string(), str::to_string)
}
pub(crate) async fn headless_elicit_handler(
mut receiver: EventReceiver,
event_bus: EventBus,
vars: HashMap<String, String>,
errors: Arc<Mutex<Vec<String>>>,
) {
loop {
let Some(event) = receiver.recv().await else {
return;
};
let AstridEvent::Ipc { message, .. } = &*event else {
continue;
};
let IpcPayload::ElicitRequest {
request_id,
capsule_id,
field,
} = &message.payload
else {
continue;
};
let resolved = resolve_headless_field(
&field.key,
&field.field_type,
field.default.as_deref(),
&vars,
);
let (value, values) = match resolved {
Ok(resolved) => resolved,
Err(error) => {
if let Ok(mut guard) = errors.lock() {
guard.push(format!("{capsule_id}.{}: {error}", field.key));
}
(None, None)
},
};
let response =
build_elicit_response_msg(*request_id, message.principal.as_deref(), value, values);
event_bus.publish(AstridEvent::Ipc {
message: response,
metadata: EventMetadata::default(),
});
}
}
fn resolve_headless_field(
key: &str,
field_type: &OnboardingFieldType,
default: Option<&str>,
vars: &HashMap<String, String>,
) -> Result<(Option<String>, Option<Vec<String>>), String> {
let env_key = headless_env_key(key);
let resolved = vars
.get(key)
.cloned()
.or_else(|| std::env::var(&env_key).ok())
.or_else(|| default.map(str::to_string))
.ok_or_else(|| format!("required value is missing (use --var {key}=… or set {env_key})"))?;
match field_type {
OnboardingFieldType::Enum(options) => {
if !options.iter().any(|option| option == &resolved) {
return Err(format!(
"value is not one of the declared options: {}",
options.join(", ")
));
}
Ok((Some(resolved), None))
},
OnboardingFieldType::Array => Ok((
None,
Some(
resolved
.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
.map(str::to_string)
.collect(),
),
)),
OnboardingFieldType::Text | OnboardingFieldType::Secret => Ok((Some(resolved), None)),
}
}
fn build_elicit_response_msg(
request_id: uuid::Uuid,
request_principal: Option<&str>,
value: Option<String>,
values: Option<Vec<String>>,
) -> IpcMessage {
let response = IpcPayload::ElicitResponse {
request_id,
value,
values,
};
let mut msg = IpcMessage::new(
Topic::elicit_response(request_id),
response,
uuid::Uuid::nil(),
);
if let Some(principal) = request_principal {
msg = msg.with_principal(principal);
}
msg
}
#[cfg(test)]
mod tests {
use super::*;
fn env(toml_src: &str) -> HashMap<String, EnvDef> {
toml::from_str(toml_src).expect("env table parses")
}
#[test]
fn value_prefers_explicit_input_and_validates_enums() {
let mut vars = HashMap::from([("auth_mode".to_string(), "subscription".to_string())]);
let options =
OnboardingFieldType::Enum(vec!["api_key".to_string(), "subscription".to_string()]);
assert_eq!(
resolve_headless_field("auth_mode", &options, Some("api_key"), &vars),
Ok((Some("subscription".to_string()), None))
);
vars.insert("auth_mode".to_string(), "invalid".to_string());
assert!(resolve_headless_field("auth_mode", &options, Some("api_key"), &vars).is_err());
}
#[test]
fn value_uses_manifest_default_without_guessing() {
let vars = HashMap::new();
assert_eq!(
resolve_headless_field(
"interaction_mode",
&OnboardingFieldType::Text,
Some("headless"),
&vars,
),
Ok((Some("headless".to_string()), None))
);
assert!(
resolve_headless_field(
"release_required_value_8c3f",
&OnboardingFieldType::Secret,
None,
&vars,
)
.is_err()
);
}
#[test]
fn array_uses_comma_separated_values() {
let vars = HashMap::from([("tags".to_string(), "one, two,,three".to_string())]);
assert_eq!(
resolve_headless_field("tags", &OnboardingFieldType::Array, None, &vars),
Ok((
None,
Some(vec![
"one".to_string(),
"two".to_string(),
"three".to_string()
])
))
);
}
#[test]
fn env_persists_modes_and_keeps_secret_out_of_json() {
let defs = env(r#"
[interaction_mode]
type = "select"
enum_values = ["headless", "repl"]
default = "headless"
[auth_mode]
type = "select"
enum_values = ["api_key", "subscription"]
default = "api_key"
[api_key]
type = "secret"
"#);
let dir = tempfile::tempdir().expect("tempdir");
let home = AstridHome::from_path(dir.path());
let principal = PrincipalId::new("claude-code").expect("principal");
let env_path = home
.principal_home(&principal)
.env_dir()
.join("claude-runner.env.json");
let vars = HashMap::from([
("auth_mode".to_string(), "api_key".to_string()),
("api_key".to_string(), "top-secret".to_string()),
]);
write_headless_env_fields(&defs, &env_path, "claude-runner", &home, &principal, &vars)
.expect("headless config");
let raw = std::fs::read_to_string(&env_path).expect("env json");
assert!(!raw.contains("top-secret"));
let values: serde_json::Value = serde_json::from_str(&raw).expect("valid json");
assert_eq!(values["interaction_mode"], "headless");
assert_eq!(values["auth_mode"], "api_key");
assert_eq!(values["api_key"], "");
let secrets = FileSecretStore::new(
home.secrets_dir()
.join(principal.as_str())
.join("claude-runner"),
);
assert_eq!(
secrets.get("api_key").expect("secret read").as_deref(),
Some("top-secret")
);
}
#[test]
fn env_requires_missing_secret_and_rejects_unknown_var() {
let defs = env("[api_key]\ntype = \"secret\"\n");
let dir = tempfile::tempdir().expect("tempdir");
let home = AstridHome::from_path(dir.path());
let principal = PrincipalId::default();
let env_path = dir.path().join("cap.env.json");
let missing =
write_headless_env_fields(&defs, &env_path, "cap", &home, &principal, &HashMap::new())
.expect_err("missing secret must fail");
assert!(missing.to_string().contains("required value is missing"));
let unknown = write_headless_env_fields(
&defs,
&env_path,
"cap",
&home,
&principal,
&HashMap::from([("typo".to_string(), "value".to_string())]),
)
.expect_err("unknown --var must fail");
assert!(unknown.to_string().contains("names no [env] field"));
}
#[test]
fn empty_json_marker_is_not_a_stored_secret() {
let defs = env("[api_key]\ntype = \"secret\"\n");
let dir = tempfile::tempdir().expect("tempdir");
let home = AstridHome::from_path(dir.path());
let principal = PrincipalId::default();
let env_path = dir.path().join("cap.env.json");
std::fs::write(&env_path, r#"{"api_key":""}"#).expect("secret marker");
let error =
write_headless_env_fields(&defs, &env_path, "cap", &home, &principal, &HashMap::new())
.expect_err("marker without secret material must fail");
assert!(error.to_string().contains("required value is missing"));
}
#[test]
fn elicit_reply_echoes_request_principal() {
let request_id = uuid::Uuid::new_v4();
let msg = build_elicit_response_msg(
request_id,
Some("default"),
Some("answer".to_string()),
None,
);
assert_eq!(msg.principal.as_deref(), Some("default"));
assert_eq!(msg.topic, Topic::elicit_response(request_id));
assert!(matches!(
msg.payload,
IpcPayload::ElicitResponse { request_id: got, .. } if got == request_id
));
}
#[test]
fn elicit_reply_unstamped_when_request_has_no_principal() {
let msg = build_elicit_response_msg(uuid::Uuid::new_v4(), None, None, None);
assert!(msg.principal.is_none());
}
}