using System.Text.Json; using Nexus.Api.Helpers; namespace Nexus.Api.Services; public sealed class AgentConfigService : IAgentConfigService { private static readonly HashSet AllowedFiles = new(StringComparer.OrdinalIgnoreCase) { "IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md" }; public IReadOnlyList GetConfigFiles(string agentId) { var workspacePath = $"/mnt/workspace-{agentId}"; if (!Directory.Exists(workspacePath)) return Array.Empty(); return Directory.GetFiles(workspacePath, "*.md") .Select(f => new FileInfo(f)) .Where(f => AllowedFiles.Contains(f.Name)) .OrderBy(f => f.Name) .Select(f => new AgentConfigFileInfo(f.Name, f.Length, f.LastWriteTimeUtc)) .ToList(); } public async Task GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default) { if (!PathSecurityHelper.IsValidConfigFileName(fileName)) return null; if (!AllowedFiles.Contains(fileName)) return null; var workspacePath = $"/mnt/workspace-{agentId}"; if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !File.Exists(safePath)) return null; var content = await File.ReadAllTextAsync(safePath!, ct); var fi = new FileInfo(safePath!); return new AgentConfigFileContent(fileName, content, fi.Length, fi.LastWriteTimeUtc); } public async Task SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default) { var fileKind = DetermineFileKind(fileName); var validation = Validate(fileName, content, fileKind); var backup = new AgentConfigBackupResult("not_applicable", BackupCreated: false); var reload = CreateReloadCheck(); if (validation.Errors.Count > 0) return new AgentConfigSaveAttempt(null, new AgentConfigSaveFailure("validation_failed", validation, backup, reload)); var workspacePath = $"/mnt/workspace-{agentId}"; if (!Directory.Exists(workspacePath)) return new AgentConfigSaveAttempt( null, new AgentConfigSaveFailure( "workspace_not_found", new AgentConfigValidationResult("failed", fileKind, ["Agent workspace is not available on this node."]), backup, reload)); if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath)) return new AgentConfigSaveAttempt( null, new AgentConfigSaveFailure( "invalid_path", new AgentConfigValidationResult("failed", fileKind, ["Invalid filename or path."]), backup, reload)); var tempPath = safePath + ".tmp"; var backupPath = safePath + ".bak"; var backupCreated = false; try { if (File.Exists(safePath)) { File.Copy(safePath, backupPath, overwrite: true); backupCreated = true; } await File.WriteAllTextAsync(tempPath, content, ct); File.Move(tempPath, safePath!, overwrite: true); } catch { if (File.Exists(tempPath)) File.Delete(tempPath); throw; } var fi = new FileInfo(safePath!); return new AgentConfigSaveAttempt( new AgentConfigFileSaveResult( fileName, fi.Length, fi.LastWriteTimeUtc, new AgentConfigValidationResult("passed", fileKind, []), new AgentConfigBackupResult(backupCreated ? "created" : "not_applicable", backupCreated), CreateReloadCheck()), null); } private static AgentConfigValidationResult Validate(string fileName, string content, string fileKind) { var errors = new List(); if (!PathSecurityHelper.IsValidConfigFileName(fileName)) errors.Add("Filename is invalid."); else if (!AllowedFiles.Contains(fileName)) errors.Add("File is not allowed for Mission Control editing."); if (content.IndexOf('\0') >= 0) errors.Add("Content contains null bytes."); if (content.Length > IAgentConfigService.MaxConfigFileBytes) errors.Add($"Content exceeds maximum size of {IAgentConfigService.MaxConfigFileBytes / 1024}KB."); if (string.Equals(fileKind, "json", StringComparison.OrdinalIgnoreCase)) { try { JsonDocument.Parse(content); } catch (JsonException ex) { errors.Add($"JSON validation failed: {ex.Message}"); } } return new AgentConfigValidationResult(errors.Count == 0 ? "passed" : "failed", fileKind, errors); } private static string DetermineFileKind(string fileName) { if (fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) return "json"; if (fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) return "markdown"; return "text"; } private static AgentConfigReloadCheckResult CreateReloadCheck() => new( "not_supported", "Mission Control verified the file write locally, but agent hot reload is not available for workspace config files."); }