use std::{
env, fs,
io::Write,
path::{Path, PathBuf},
process::Command,
};
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::DEFAULT_GATEWAY;
const MAX_CONFIG_BYTES: u64 = 64 * 1024;
const MAX_CREDENTIAL_BYTES: u64 = 64 * 1024;
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct LocalConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gateway: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum GatewaySource {
CommandLine,
Environment,
Config,
Default,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct GatewayResolution {
pub value: String,
pub source: GatewaySource,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CredentialSource {
CommandLine,
Environment,
SkyCredentials,
SkyLoginEncrypted,
None,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SkyCredentialProbe {
pub source: CredentialSource,
pub usable: bool,
pub sky_cli_available: bool,
pub credential_file: Option<PathBuf>,
pub message: String,
}
#[derive(Clone, Debug)]
pub(crate) struct ConfigStore {
path: PathBuf,
}
impl ConfigStore {
pub(crate) fn discover(explicit: Option<&Path>) -> Result<Self> {
let path = match explicit {
Some(path) => path.to_path_buf(),
None => default_config_path()?,
};
Ok(Self { path })
}
pub(crate) fn path(&self) -> &Path {
&self.path
}
pub(crate) fn load(&self) -> Result<LocalConfig> {
let metadata = match fs::metadata(&self.path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(LocalConfig::default());
}
Err(error) => {
return Err(error)
.with_context(|| format!("failed to inspect {}", self.path.display()));
}
};
if metadata.len() > MAX_CONFIG_BYTES {
bail!("config {} exceeds 64 KiB", self.path.display());
}
let bytes = fs::read(&self.path)
.with_context(|| format!("failed to read {}", self.path.display()))?;
serde_json::from_slice(&bytes)
.with_context(|| format!("invalid Infra CLI config at {}", self.path.display()))
}
pub(crate) fn save(&self, config: &LocalConfig) -> Result<()> {
let parent = self
.path
.parent()
.ok_or_else(|| anyhow!("config path has no parent: {}", self.path.display()))?;
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
restrict_directory(parent)?;
let temporary = parent.join(format!(
".{}.tmp-{}",
self.path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("config.json"),
std::process::id()
));
let bytes = serde_json::to_vec_pretty(config)?;
let result = (|| -> Result<()> {
let mut options = fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
restrict_file(&mut options);
let mut file = options
.open(&temporary)
.with_context(|| format!("failed to create {}", temporary.display()))?;
file.write_all(&bytes)?;
file.write_all(b"\n")?;
file.sync_all()?;
fs::rename(&temporary, &self.path).with_context(|| {
format!(
"failed to atomically replace {} with {}",
self.path.display(),
temporary.display()
)
})?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result
}
pub(crate) fn set_gateway(&self, gateway: &str) -> Result<LocalConfig> {
validate_gateway(gateway)?;
let mut config = self.load()?;
config.gateway = Some(gateway.trim_end_matches('/').to_string());
self.save(&config)?;
Ok(config)
}
pub(crate) fn unset_gateway(&self) -> Result<LocalConfig> {
let mut config = self.load()?;
config.gateway = None;
self.save(&config)?;
Ok(config)
}
pub(crate) fn resolve_gateway(&self, explicit: Option<&str>) -> Result<GatewayResolution> {
if let Some(value) = nonempty(explicit) {
validate_gateway(value)?;
return Ok(GatewayResolution {
value: value.trim_end_matches('/').to_string(),
source: GatewaySource::CommandLine,
});
}
if let Some(value) = env_nonempty("SKY_GATEWAY_URL") {
validate_gateway(&value)?;
return Ok(GatewayResolution {
value: value.trim_end_matches('/').to_string(),
source: GatewaySource::Environment,
});
}
if let Some(value) = self.load()?.gateway {
validate_gateway(&value)?;
return Ok(GatewayResolution {
value,
source: GatewaySource::Config,
});
}
Ok(GatewayResolution {
value: DEFAULT_GATEWAY.into(),
source: GatewaySource::Default,
})
}
}
pub(crate) fn resolve_credential(
explicit: Option<&str>,
sky_home: Option<&Path>,
) -> Result<(Option<String>, SkyCredentialProbe)> {
if let Some(value) = nonempty(explicit) {
return Ok((
Some(value.to_string()),
credential_probe(CredentialSource::CommandLine, true, None, "using --token"),
));
}
if let Some(value) = env_nonempty("SKY_GATEWAY_TOKEN") {
return Ok((
Some(value),
credential_probe(
CredentialSource::Environment,
true,
None,
"using SKY_GATEWAY_TOKEN",
),
));
}
let sky_home = match sky_home {
Some(path) => Some(path.to_path_buf()),
None => home_dir().map(|home| home.join(".sky")),
};
let Some(sky_home) = sky_home else {
return Ok((
None,
credential_probe(
CredentialSource::None,
false,
None,
"no home directory or Sky credential source found",
),
));
};
let credential_file = sky_home.join("credentials");
let sky_cli_available = command_available("sky");
match read_sky_credential(&credential_file)? {
SkyCredential::Token(token) => Ok((
Some(token),
SkyCredentialProbe {
source: CredentialSource::SkyCredentials,
usable: true,
sky_cli_available,
credential_file: Some(credential_file),
message: "using a directly readable local Sky credential".into(),
},
)),
SkyCredential::Encrypted => Ok((
None,
SkyCredentialProbe {
source: CredentialSource::SkyLoginEncrypted,
usable: false,
sky_cli_available,
credential_file: Some(credential_file),
message: "encrypted Sky login detected; use SKY_GATEWAY_TOKEN or --token because Infra CLI does not decrypt another CLI's credential store".into(),
},
)),
SkyCredential::Missing => Ok((
None,
SkyCredentialProbe {
source: CredentialSource::None,
usable: false,
sky_cli_available,
credential_file: None,
message: "no Gateway token found; run `sky login` and issue a user API token, or set SKY_GATEWAY_TOKEN".into(),
},
)),
}
}
fn credential_probe(
source: CredentialSource,
usable: bool,
credential_file: Option<PathBuf>,
message: &str,
) -> SkyCredentialProbe {
SkyCredentialProbe {
source,
usable,
sky_cli_available: command_available("sky"),
credential_file,
message: message.into(),
}
}
enum SkyCredential {
Token(String),
Encrypted,
Missing,
}
fn read_sky_credential(path: &Path) -> Result<SkyCredential> {
let metadata = match fs::metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(SkyCredential::Missing);
}
Err(error) => {
return Err(error)
.with_context(|| format!("failed to inspect Sky credential {}", path.display()));
}
};
if metadata.len() > MAX_CREDENTIAL_BYTES {
bail!("Sky credential {} exceeds 64 KiB", path.display());
}
let value = fs::read_to_string(path).with_context(|| {
format!(
"failed to read Sky credential metadata at {}",
path.display()
)
})?;
let value = value.trim();
if value.starts_with("ENCv1:") {
return Ok(SkyCredential::Encrypted);
}
if valid_token(value) {
return Ok(SkyCredential::Token(value.to_string()));
}
if let Ok(json) = serde_json::from_str::<serde_json::Value>(value) {
for key in ["token", "accessToken", "access_token", "gatewayToken"] {
if let Some(token) = json.get(key).and_then(serde_json::Value::as_str)
&& valid_token(token)
{
return Ok(SkyCredential::Token(token.to_string()));
}
}
}
for line in value.lines() {
let Some((key, token)) = line.split_once('=') else {
continue;
};
let token = token.trim().trim_matches(['\'', '"']);
if key.trim() == "SKY_GATEWAY_TOKEN" && valid_token(token) {
return Ok(SkyCredential::Token(token.to_string()));
}
}
Ok(SkyCredential::Missing)
}
fn valid_token(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 16 * 1024
&& !value.bytes().any(|byte| byte.is_ascii_whitespace())
&& (value.starts_with("eyJ") || value.starts_with("sky_pat_"))
}
fn validate_gateway(value: &str) -> Result<()> {
let url = Url::parse(value).with_context(|| format!("invalid Gateway URL {value:?}"))?;
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
bail!("Gateway URL must be an absolute http:// or https:// URL");
}
if !url.username().is_empty() || url.password().is_some() {
bail!("Gateway URL must not contain credentials");
}
if url.query().is_some() || url.fragment().is_some() {
bail!("Gateway URL must not contain a query or fragment");
}
Ok(())
}
fn nonempty(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn env_nonempty(name: &str) -> Option<String> {
env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn command_available(command: &str) -> bool {
Command::new(command)
.arg("--version")
.output()
.is_ok_and(|output| output.status.success())
}
fn default_config_path() -> Result<PathBuf> {
if let Some(path) = env_nonempty("AGENT_INFRA_CONFIG") {
return Ok(PathBuf::from(path));
}
#[cfg(target_os = "macos")]
if let Some(home) = home_dir() {
return Ok(home
.join("Library")
.join("Application Support")
.join("agent-infra")
.join("config.json"));
}
#[cfg(target_os = "windows")]
if let Some(app_data) = env_nonempty("APPDATA") {
return Ok(PathBuf::from(app_data)
.join("agent-infra")
.join("config.json"));
}
if let Some(xdg) = env_nonempty("XDG_CONFIG_HOME") {
return Ok(PathBuf::from(xdg).join("agent-infra").join("config.json"));
}
home_dir()
.map(|home| home.join(".config/agent-infra/config.json"))
.ok_or_else(|| anyhow!("cannot determine config directory; pass --config PATH"))
}
fn home_dir() -> Option<PathBuf> {
env::var_os("HOME")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
#[cfg(unix)]
fn restrict_directory(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(path, fs::Permissions::from_mode(0o700))
.with_context(|| format!("failed to restrict permissions on {}", path.display()))
}
#[cfg(not(unix))]
fn restrict_directory(_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(unix)]
fn restrict_file(options: &mut fs::OpenOptions) {
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
#[cfg(not(unix))]
fn restrict_file(_options: &mut fs::OpenOptions) {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_round_trip_and_unset_are_atomic() {
let directory = tempfile::tempdir().unwrap();
let store = ConfigStore::discover(Some(&directory.path().join("config.json"))).unwrap();
store.set_gateway("https://infra.example.com/").unwrap();
assert_eq!(
store.load().unwrap().gateway.as_deref(),
Some("https://infra.example.com")
);
store.unset_gateway().unwrap();
assert_eq!(store.load().unwrap().gateway, None);
}
#[test]
fn explicit_gateway_overrides_stored_gateway() {
let directory = tempfile::tempdir().unwrap();
let store = ConfigStore::discover(Some(&directory.path().join("config.json"))).unwrap();
store.set_gateway("https://stored.example.com").unwrap();
let resolved = store
.resolve_gateway(Some("https://explicit.example.com"))
.unwrap();
assert_eq!(resolved.value, "https://explicit.example.com");
assert_eq!(resolved.source, GatewaySource::CommandLine);
}
#[test]
fn probes_plaintext_and_encrypted_sky_credentials_without_exposing_values() {
let directory = tempfile::tempdir().unwrap();
let credential = directory.path().join("credentials");
fs::write(&credential, "sky_pat_test-secret").unwrap();
assert!(matches!(
read_sky_credential(&credential).unwrap(),
SkyCredential::Token(token) if token == "sky_pat_test-secret"
));
fs::write(&credential, "ENCv1:opaque").unwrap();
assert!(matches!(
read_sky_credential(&credential).unwrap(),
SkyCredential::Encrypted
));
}
#[test]
fn rejects_gateway_credentials_and_query_parameters() {
assert!(validate_gateway("https://user:secret@example.com").is_err());
assert!(validate_gateway("https://example.com?token=secret").is_err());
}
}