using System.Net; using System.Text.RegularExpressions; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; using YamlDotNet.RepresentationModel; namespace Agent.App.Configuration; public static partial class ConfigurationLoader { public static AgentConfiguration Load(string path) { string absolutePath = Path.GetFullPath(path); if (!File.Exists(absolutePath)) { throw new ConfigurationException($"Configuration file does not exist: {absolutePath}"); } try { string yaml = File.ReadAllText(absolutePath); string localPath = Path.Combine(Path.GetDirectoryName(absolutePath)!, "agent.local.yaml"); if (!string.Equals(absolutePath, localPath, StringComparison.OrdinalIgnoreCase) && File.Exists(localPath)) yaml = MergeYaml(yaml, File.ReadAllText(localPath)); RejectLegacySandbox(yaml); var deserializer = new DeserializerBuilder() .WithNamingConvention(UnderscoredNamingConvention.Instance) .Build(); AgentConfiguration configuration = deserializer.Deserialize(yaml) ?? throw new ConfigurationException("Configuration document is empty."); Validate(configuration, Path.GetDirectoryName(absolutePath)!); return configuration; } catch (ConfigurationException) { throw; } catch (Exception ex) { throw new ConfigurationException($"Could not parse YAML: {ex.Message}", ex); } } public static void Validate(AgentConfiguration value, string baseDirectory) { if (value.SchemaVersion != 1) Throw("schema_version must be 1."); if (string.IsNullOrWhiteSpace(value.Application.Language)) Throw("application.language is required."); if (value.Mcp.Transport is not ("stdio" or "http")) Throw("mcp.transport must be stdio or http."); if (!IPAddress.TryParse(value.Mcp.Http.Host, out IPAddress? host) || !IPAddress.IsLoopback(host)) Throw("mcp.http.host must be 127.0.0.1 or ::1."); if (value.Mcp.Http.Port is < 1 or > 65535) Throw("mcp.http.port is outside 1..65535."); if (value.Mcp.Images.MaxCount < 0 || value.Mcp.Images.MaxBytesEach <= 0 || value.Mcp.Images.MaxTotalBytes <= 0) Throw("mcp.images limits must be positive."); if (value.Mcp.Images.MaxTotalBytes < value.Mcp.Images.MaxBytesEach) Throw("mcp.images.max_total_bytes must be >= max_bytes_each."); if (value.Mcp.Tasks.TimeToLiveHours <= 0 || value.Mcp.Tasks.PollIntervalMs <= 0) Throw("mcp.tasks TTL and poll interval must be positive."); if (value.Agent.MaxIterations <= 0 || value.Agent.SameFailureLimit <= 0) Throw("agent iteration limits must be positive."); if (!Uri.TryCreate(value.Ollama.Endpoint, UriKind.Absolute, out Uri? endpoint) || endpoint.Scheme is not ("http" or "https")) Throw("ollama.endpoint must be an absolute HTTP(S) URI."); if (string.IsNullOrWhiteSpace(value.Ollama.Model)) Throw("ollama.model is required."); if (value.Ollama.Think is string booleanText && bool.TryParse(booleanText, out bool booleanThink)) value.Ollama.Think = booleanThink; if (value.Ollama.Think is not bool && (value.Ollama.Think is not string thinkLevel || thinkLevel is not ("low" or "medium" or "high" or "max"))) Throw("ollama.think must be true, false, low, medium, high, or max."); if (value.Ollama.Options.NumCtx is <= 0) Throw("ollama.options.num_ctx must be positive."); if (value.Ollama.Options.Temperature is < 0 or > 2) Throw("ollama.options.temperature must be in 0..2."); if (value.Planner.MaxTasks is < 1 or > 32) Throw("planner.max_tasks must be in 1..32."); ValidateSandbox(value.Sandbox); if (value.FileEdit.MemoryBackupLimitMb <= 0 || value.Limits.WholeReadBytes <= 0 || value.Limits.SearchMaxResults <= 0 || value.Limits.TailMaxLines <= 0 || value.Limits.ContextSummaryRatio is <= 0 or >= 1) Throw("file/context limits are invalid."); if (value.Logging.MaxFileBytes <= 0 || value.Logging.RetainedFiles < 1) Throw("logging limits are invalid."); if (value.Logging.Level is not ("information" or "warning" or "error" or "fatal")) Throw("logging.level must be information, warning, error, or fatal."); RequireFile(baseDirectory, value.Agent.UserSystemPrompt, "agent.user_system_prompt"); RequireDirectory(baseDirectory, value.Localization.Directory, "localization.directory"); RequireDirectory(baseDirectory, value.Skills.Directory, "skills.directory"); } private static void RequireFile(string baseDirectory, string path, string key) { if (!File.Exists(Resolve(baseDirectory, path))) Throw($"{key} does not exist: {path}"); } private static void RequireDirectory(string baseDirectory, string path, string key) { if (!Directory.Exists(Resolve(baseDirectory, path))) Throw($"{key} does not exist: {path}"); } public static string Resolve(string baseDirectory, string path) => Path.GetFullPath(path, baseDirectory); private static void ValidateSandbox(SandboxOptions sandbox) { if (sandbox.Profile is not ("readonly" or "development")) Throw("sandbox.profile must be readonly or development."); if (sandbox.Workspaces.Any(string.IsNullOrWhiteSpace)) Throw("sandbox.workspaces contains an empty Logical Path."); foreach (MountOptions mount in sandbox.Mounts) { if (string.IsNullOrWhiteSpace(mount.Path) || mount.Access is not ("read_only" or "read_write" or "read_write_delete")) Throw("sandbox.mounts requires a Logical Path and access read_only, read_write, or read_write_delete."); if (sandbox.Profile == "readonly" && mount.Access != "read_only") Throw("readonly sandbox.profile permits only read_only mounts."); } string[] aliases = sandbox.Execution.Executables.Concat(sandbox.Execution.Shells).ToArray(); if (aliases.Any(alias => !AliasRegex().IsMatch(alias))) Throw("Executable Alias must match ^[a-z0-9][a-z0-9._-]{0,63}$."); if (aliases.Distinct(StringComparer.OrdinalIgnoreCase).Count() != aliases.Length) Throw("Executable Alias values must be unique across sandbox.execution.executables and shells."); if (sandbox.Network.Access is not ("none" or "local" or "restricted")) Throw("sandbox.network.access must be none, local, or restricted."); if (sandbox.Network.LocalEndpoints.Any(static endpoint => endpoint.Port is < 1 or > 65535)) Throw("sandbox.network.local_endpoints contains an invalid port."); foreach (NetworkAllowance endpoint in sandbox.Network.RestrictedEndpoints) if (string.IsNullOrWhiteSpace(endpoint.Host) || endpoint.Ports.Count == 0 || endpoint.Ports.Any(static port => port is < 1 or > 65535)) Throw("sandbox.network.restricted_endpoints contains an invalid host or port."); if (sandbox.Network.Access == "none" && (sandbox.Network.LocalEndpoints.Count != 0 || sandbox.Network.RestrictedEndpoints.Count != 0)) Throw("sandbox.network.access none cannot contain endpoints."); if (sandbox.Network.Access == "local" && (sandbox.Network.LocalEndpoints.Count == 0 || sandbox.Network.RestrictedEndpoints.Count != 0)) Throw("sandbox.network.access local requires local_endpoints and forbids restricted_endpoints."); if (sandbox.Network.Access == "restricted" && (sandbox.Network.RestrictedEndpoints.Count == 0 || sandbox.Network.LocalEndpoints.Count != 0)) Throw("sandbox.network.access restricted requires restricted_endpoints and forbids local_endpoints."); if (sandbox.Resources.Profile is not ("constrained" or "normal" or "extended")) Throw("sandbox.resources.profile must be constrained, normal, or extended."); if (sandbox.Resources.MemoryMb is <= 0 || sandbox.Resources.ProcessLimit is <= 0 || sandbox.Resources.ExecutionTimeoutSeconds is <= 0) Throw("sandbox resource overrides must be positive."); } private static void RejectLegacySandbox(string yaml) { YamlNode root = LoadRoot(yaml, "merged configuration"); if (root is not YamlMappingNode rootMap || !TryGet(rootMap, "sandbox", out YamlNode? sandboxNode) || sandboxNode is not YamlMappingNode sandbox) return; if (TryGet(sandbox, "filesystem", out _) || TryGet(sandbox, "process", out _)) Throw("Legacy sandbox.filesystem/sandbox.process configuration is no longer supported. Migrate to sandbox.profile, workspaces, mounts, execution, network, and resources; then run 'sandbox resolve' and 'sandbox confirm'."); } private static bool TryGet(YamlMappingNode map, string name, out YamlNode? value) { foreach (KeyValuePair pair in map.Children) { if (pair.Key is YamlScalarNode key && key.Value == name) { value = pair.Value; return true; } } value = null; return false; } private static string MergeYaml(string baseYaml, string localYaml) { YamlNode baseRoot = LoadRoot(baseYaml, "base configuration"); YamlNode localRoot = LoadRoot(localYaml, "agent.local.yaml"); YamlNode merged = MergeNode(baseRoot, localRoot); var stream = new YamlStream(new YamlDocument(merged)); using var writer = new StringWriter(System.Globalization.CultureInfo.InvariantCulture); stream.Save(writer, assignAnchors: false); return writer.ToString(); } private static YamlNode LoadRoot(string yaml, string source) { var stream = new YamlStream(); using var reader = new StringReader(yaml); stream.Load(reader); if (stream.Documents.Count != 1) Throw($"{source} must contain exactly one YAML document."); return stream.Documents[0].RootNode; } private static YamlNode MergeNode(YamlNode baseNode, YamlNode localNode) { if (baseNode is not YamlMappingNode baseMap || localNode is not YamlMappingNode localMap) return Clone(localNode); var result = (YamlMappingNode)Clone(baseMap); foreach ((YamlNode key, YamlNode localValue) in localMap.Children) { YamlNode? existingKey = result.Children.Keys.FirstOrDefault(candidate => candidate is YamlScalarNode a && key is YamlScalarNode b && string.Equals(a.Value, b.Value, StringComparison.Ordinal)); if (existingKey is null) result.Add(Clone(key), Clone(localValue)); else result.Children[existingKey] = MergeNode(result.Children[existingKey], localValue); } return result; } private static YamlNode Clone(YamlNode node) { var stream = new YamlStream(new YamlDocument(node)); using var writer = new StringWriter(System.Globalization.CultureInfo.InvariantCulture); stream.Save(writer, assignAnchors: false); return LoadRoot(writer.ToString(), "merged configuration"); } private static void Throw(string message) => throw new ConfigurationException(message); [GeneratedRegex("^[a-z0-9][a-z0-9._-]{0,63}$", RegexOptions.CultureInvariant)] private static partial Regex AliasRegex(); }