diff --git a/docs/PluginDevelopment.md b/docs/PluginDevelopment.md index e54ae4f1..5f66c4a3 100644 --- a/docs/PluginDevelopment.md +++ b/docs/PluginDevelopment.md @@ -505,6 +505,13 @@ created through `SoftwareProjectFactory`, which the creator dialog uses too. - `DownloadAndExtractArchiveAsync(url, location, progress, timeout, cancellationToken)`. - `DownloadImageAsync(url, timeout, cancellationToken)`, `DownloadTextAsync(url, timeout, cancellationToken)`. +#### `ISecretStore` (src/OneWare.Essentials/Services/ISecretStore.cs) + +- `Get(service, account)`, `Set(service, account, secret)`, `Remove(service, account)`: secrets in the OS + credential store (Keychain, Secret Service, Windows: DPAPI-encrypted files in the app data folder). +- Calls may block (unlock prompts, D-Bus), so call them off the UI thread. Pick a `service` name unique to + your plugin. The OneWare Cloud login and the Copilot connectors use it. + #### `IChatService` (src/OneWare.Essentials/Services/IChatService.cs) - `InitializeAsync()`: setup and authenticate. @@ -547,6 +554,8 @@ created through `SoftwareProjectFactory`, which the creator dialog uses too. - Set `OneWareAiFunction.InvocationHandler` when a tool needs an `AiFunctionInvocationContext` for invocation-scoped progress reporting. Keep `Handler` as the typed delegate used to generate the tool schema. +- Set `OneWareAiFunction.AlwaysLoaded` only on tools the model needs in almost every turn. When a chat has + many tools, the others are deferred behind the runtime's tool search and found by name and description. #### `ISerialMonitorService` (src/OneWare.Essentials/Services/ISerialMonitorService.cs) @@ -766,6 +775,10 @@ Key points: wrote the message, and `ChatIdleEvent.Model` for turns whose messages carry no model. The chat shows it beneath the message that ended the turn. `ChatSubAgentStartedEvent.Model` does the same for a delegated task, shown in the header of its block. +- Show images a tool returned with `ChatImageEvent` (encoded bytes plus MIME type, optional description and + tool name). The chat shows them below the tool call, saves them under `/Chat/Images//` + and deletes them with their chat. The Copilot chats raise it for image contents and binary results of tool + calls, including images referenced through `session.binary_asset` events. - Call `IChatManagerService.RegisterChatService` during module initialization. - OneWare functions (tools from `IAiFunctionProvider`) report `AiFunctionEvent.SessionId` so the chat shows them in the right conversation. Put an object with a public string `SessionId` property (like the Copilot SDK's `ToolInvocation`) in @@ -1007,6 +1020,104 @@ Names must be unique; registering an existing name replaces the previous agent o directories generated from `RegisterSkill` are cleaned up automatically when a skill is renamed or no longer registered. +### User skills, MCP servers and repository instructions + +The GitHub Copilot, BYOK and OneWare Agents chats reuse Copilot CLI customization discovery. +They read the user's Copilot configuration directory (`COPILOT_HOME`, or `~/.copilot` by default), +including user skills. MCP servers come from OneWare's own list (see Connectors below); the servers in +`~/.copilot/mcp-config.json` are not started in OneWare chats but can be imported. Provider-specific chat +history remains separate. + +Add arbitrary skill roots in **Settings > AI Chat > Copilot CLI > Additional Skill Directories**. +These are merged with plugin skill directories, not substituted for them. Paths can be absolute, +start with `~/`, or be relative to the active project. Missing directories are logged and skipped. +Start or reopen a chat after changing customization settings. + +New and resumed sessions use the active project's root as their working directory, falling back to +the default projects directory if no project is active. The runtime discovers project skills and +repository instructions, including `AGENTS.md` and `.github/copilot-instructions.md`, from that context. +The assistant is also instructed to read scoped `AGENTS.md` files when working in subdirectories, +because OneWare's `readFile` tool replaces the CLI's built-in file viewer. + +#### Connectors (IDE-wide MCP servers) + +**Settings > AI Chat > Connectors** lists the MCP servers available in every chat, independent of the open +projects, for GitHub Copilot and OneWare Agents alike (both run on the Copilot CLI runtime). The same list opens +from the chat's ⋯ menu (*Manage MCP Servers…*). Each server can be added, +edited, enabled or disabled, tested, signed in to and removed. *Import…* adds the servers of an existing file +(Copilot CLI `mcpServers`, VS Code `servers` or Claude Code layout), and the editor fills its form from a +pasted snippet, e.g. from a server's README. + +- **Storage**: `McpConnectorHub` (`src/OneWare.Copilot/Connectors`) owns `/Copilot/mcp.json` in the + Copilot CLI format (`mcpServers`), plus OneWare's fields `id`, `disabled`, `secretEnv` and `secretHeaders`. + The file can be edited by hand (*Open mcp.json*); changes are picked up while OneWare runs, entries without + an `id` get one, and a file with errors is reported without being overwritten. +- **Secrets**: environment variables and headers marked secret (the key toggle; credentials such as + `Authorization` or `*_TOKEN` are marked automatically) are left out of `mcp.json` and stored as one + `ISecretStore` entry per server (service `oneware-mcp`, account = the server's `id`). The OAuth client + secret of a confidential app is stored there as well. Other values may reference `${VAR}`; the runtime + expands them from its environment, and the editor warns about variables that are not set. +- **Environment**: OneWare starts the runtime with the login shell's environment (Linux/macOS, so tokens + exported in `~/.profile`, `~/.bashrc` or `~/.zshrc` and tools such as nvm's `npx` are found when OneWare is + started from a desktop launcher), overlaid with OneWare's own environment. `PATH` keeps the login shell's + entries first. Secret values are never put into that environment, because every local server inherits it. +- **Sessions**: every chat gets the enabled servers, with their secrets filled in, as session MCP servers + (`SessionConfig.McpServers`; SSE servers are started through `session.Rpc.Mcp.StartServerAsync`). The + runtime does not persist them. Servers of the Copilot CLI user configuration are disabled in OneWare chats + unless a connector has the same name, and the list offers to import them. +- **Live updates**: changing the list only touches the servers that changed in open chats (compared by a + fingerprint of their resolved configuration): new ones are started, changed ones restarted, removed or + disabled ones stopped. +- **Per chat**: the chat's ⋯ menu shows the servers of the current chat with their status, and turns servers + off or on for that chat only. Problems (failed, sign-in required) show as a warning dot on the ⋯ button + instead of being posted as chat messages. +- **Tool search**: chats enable the runtime's tool search (`SessionConfig.ToolSearch`), so servers with many + tools (Microsoft 365 has about 400) are looked up on demand instead of filling the model's context. Above + about 30 tools, MCP tools and plugin AI functions are deferred; the model finds them by name and + description, so give plugin functions precise, searchable descriptions. Functions flagged + `IOneWareAiFunction.AlwaysLoaded` (OneWare's file, terminal and IDE-context functions such as `readFile`, + `editFile` and `runTerminalCommand`) are never deferred. +- **Sign-in**: nothing is checked automatically when the list opens; *Test Connection* starts a server in a + separate helper session. *Sign In* runs the runtime's OAuth flow in the browser on the loopback redirect + `http://127.0.0.1:41347/` (in an open chat, through that chat's session); the runtime keeps the tokens in the + system keychain (`McpOAuthTokenStorage = Persistent`), and open chats reconnect. Only one sign-in runs at a + time. Servers without dynamic client registration (e.g. Asana) need an OAuth app registered with the + provider using that redirect URL: enter its client ID and, for confidential apps, its client secret under + *Advanced*. + +A project's own `.mcp.json` (or `.vscode/mcp.json`) is only loaded by the Copilot runtime for the working +directory, and only if the Copilot CLI trusts that folder. When an untrusted project has one, the chat asks +once whether to trust the folder and start its servers. OneWare does not start MCP servers from other open +projects. + +#### Multiple open projects + +The runtime only knows one working directory, so OneWare adds the following from the other open projects +itself and keeps open chats in sync while projects are opened or closed: + +- **Skills** in `.github/skills`, `.agents/skills` and `.claude/skills` of every open project are added. +- **Instructions**: the root `AGENTS.md` and `.github/copilot-instructions.md` of the other projects + are sent with the next message, together with the list of open projects. They are sent again only when the + open projects or those files change. + +MCP tools use the normal permission flow, including per-chat approvals and the selected approval +mode. Ask/Plan and other read-only agents only allow MCP tools that the server marks read-only +(`readOnlyHint`). Host-capable built-in CLI tools remain unavailable, and customization discovery does not +enable repository command hooks. + +#### Auto routing tiers and HydraFusion + +The GitHub Copilot chat starts the runtime with the `DYNAMIC_AUTO_TIERS` feature flag +(`COPILOT_CLI_ENABLED_FEATURE_FLAGS`, merged with flags already set) and enables the HydraFusion session +feature flags (`CopilotSessionConfiguration.ApplyFusion`). The tiers of the **Auto** model picker come from +`client.Rpc.Models.ListAsync` (`ModelList.Auto`) and include Fusion tiers when the account offers them +(`CopilotAutoTiers.Build`); if the runtime offers none, the built-in Default, Fast, Efficiency, Balance and +Intelligence tiers are used. BYOK and OneWare Agents chats always use the built-in tiers and never enable Fusion. + +During a Fusion turn, the root phases stay in the normal message flow. Review phases (judge, critic, …) are shown +as sub-agent blocks named *Fusion <phase>*, with their model, verdict, duration and token usage +(`CopilotChatService.Fusion.cs`). + ### Skills for external agents Studio chat skills and functions are not automatically available to external agents. To contribute diff --git a/docs/changelog.md b/docs/changelog.md index 90becd79..a316ed3f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -6,6 +6,8 @@ - Values of variables, registers and memory can be edited while the debugger is paused - Extensions can now depend on other extensions, which are installed automatically, and removing an extension also offers to remove the extensions that need it - Added a copy button and a "Copy Message" context menu entry to AI chat messages +- The Copilot auto model routing now offers the tiers available to your account, including HydraFusion, and shows the Fusion review steps in the chat +- Images returned by tools, such as screenshots from MCP servers, are now shown in the AI chat and can be opened or saved - Enabled the Yosys flow for Gowin boards - Fixed a crash when selecting text across a table in Markdown views - Fixed a crash while highlighting text in the editor diff --git a/src/OneWare.Chat/Services/AiBuiltInFunctions.cs b/src/OneWare.Chat/Services/AiBuiltInFunctions.cs index 22cdea00..a89917d1 100644 --- a/src/OneWare.Chat/Services/AiBuiltInFunctions.cs +++ b/src/OneWare.Chat/Services/AiBuiltInFunctions.cs @@ -46,6 +46,7 @@ public static void Register( functionProvider.RegisterFunction(new OneWareAiFunction { Name = "readFile", + AlwaysLoaded = true, IsReadOnly = true, FriendlyName = "Read File", RunOnUiThread = true, @@ -69,6 +70,7 @@ public static void Register( functionProvider.RegisterFunction(new OneWareAiFunction { Name = "editFile", + AlwaysLoaded = true, FriendlyName = "Edit File", RunOnUiThread = true, Description = @@ -99,6 +101,7 @@ await EditFileAsync(projectExplorerService, aiFileEditService, functionProvider.RegisterFunction(new OneWareAiFunction { Name = "getActiveProject", + AlwaysLoaded = true, IsReadOnly = true, FriendlyName = "Get Active Project", RunOnUiThread = true, @@ -114,6 +117,7 @@ await EditFileAsync(projectExplorerService, aiFileEditService, functionProvider.RegisterFunction(new OneWareAiFunction { Name = "getOpenFiles", + AlwaysLoaded = true, IsReadOnly = true, FriendlyName = "Get Open Files", RunOnUiThread = true, @@ -132,27 +136,10 @@ Do not assume or invent open files. DetailExtractor = _ => "open files" }); - functionProvider.RegisterFunction(new OneWareAiFunction - { - Name = "getFocusedFile", - IsReadOnly = true, - FriendlyName = "Get Focused File", - RunOnUiThread = true, - Description = """ - Returns the absolute full path of the currently focused editor file. - This is the ONLY way to know which file is active. - """, - Handler = () => new - { - currentFile = ResolvePath(projectExplorerService, dockService.CurrentDocument?.FullPath) - }, - DetailExtractor = _ => GetRelativePath(projectExplorerService, dockService.CurrentDocument?.FullPath) - ?? "focused file" - }); - functionProvider.RegisterFunction(new OneWareAiFunction { Name = "openFile", + AlwaysLoaded = true, FriendlyName = "Open File", RunOnUiThread = true, Description = """ @@ -177,6 +164,7 @@ Optionally jumps to a specific line. Always pass an absolute path. functionProvider.RegisterFunction(new OneWareAiFunction { Name = "getErrorsForFile", + AlwaysLoaded = true, IsReadOnly = true, FriendlyName = "Get Errors for File", RunOnUiThread = true, @@ -189,6 +177,7 @@ Optionally jumps to a specific line. Always pass an absolute path. functionProvider.RegisterFunction(new OneWareAiFunction { Name = "getAllErrors", + AlwaysLoaded = true, IsReadOnly = true, FriendlyName = "Get Errors", RunOnUiThread = true, @@ -203,6 +192,7 @@ Optionally jumps to a specific line. Always pass an absolute path. functionProvider.RegisterFunction(new OneWareAiFunction { Name = "runTerminalCommand", + AlwaysLoaded = true, FriendlyName = "Execute In Terminal", RunOnUiThread = true, Description = """ diff --git a/src/OneWare.Chat/Services/AiFunctionProvider.cs b/src/OneWare.Chat/Services/AiFunctionProvider.cs index 0ddb6f66..7d458453 100644 --- a/src/OneWare.Chat/Services/AiFunctionProvider.cs +++ b/src/OneWare.Chat/Services/AiFunctionProvider.cs @@ -275,6 +275,17 @@ private static string ToYamlString(string value) } } + public bool IsFunctionAlwaysLoaded(string functionName) + { + EnsureBuiltInsRegistered(); + lock (_registrationLock) + { + return _registeredFunctions + .FirstOrDefault(f => string.Equals(f.Name, functionName, StringComparison.Ordinal)) + ?.AlwaysLoaded == true; + } + } + public string? GetFriendlyName(string functionName) { EnsureBuiltInsRegistered(); diff --git a/src/OneWare.Chat/Services/ChatImageStore.cs b/src/OneWare.Chat/Services/ChatImageStore.cs new file mode 100644 index 00000000..d8db638e --- /dev/null +++ b/src/OneWare.Chat/Services/ChatImageStore.cs @@ -0,0 +1,65 @@ +using System.Security.Cryptography; +using System.Text.RegularExpressions; + +namespace OneWare.Chat.Services; + +/// +/// Stores images that tools returned in a chat, one folder per session, so they survive restarts without bloating +/// the chat history files and are deleted together with their chat. +/// +internal sealed partial class ChatImageStore(string rootDirectory) +{ + private const string UnassignedFolder = "_unassigned"; + + private static readonly Dictionary Extensions = new(StringComparer.OrdinalIgnoreCase) + { + ["image/png"] = ".png", + ["image/jpeg"] = ".jpg", + ["image/jpg"] = ".jpg", + ["image/gif"] = ".gif", + ["image/webp"] = ".webp", + ["image/bmp"] = ".bmp", + ["image/svg+xml"] = ".svg" + }; + + public string RootDirectory { get; } = rootDirectory; + + public static string GetExtension(string? mimeType) => + mimeType != null && Extensions.TryGetValue(mimeType.Split(';')[0].Trim(), out var extension) + ? extension + : ".img"; + + /// Writes the image (once per content) and returns its path. + public string Store(string? sessionId, byte[] data, string? mimeType) + { + var directory = GetSessionDirectory(sessionId); + Directory.CreateDirectory(directory); + + var hash = Convert.ToHexStringLower(SHA256.HashData(data))[..32]; + var path = Path.Combine(directory, hash + GetExtension(mimeType)); + if (!File.Exists(path)) + { + var temporary = path + ".tmp"; + File.WriteAllBytes(temporary, data); + File.Move(temporary, path, true); + } + + return path; + } + + public void DeleteSession(string? sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) return; + + var directory = GetSessionDirectory(sessionId); + if (Directory.Exists(directory)) Directory.Delete(directory, true); + } + + public string GetSessionDirectory(string? sessionId) => + Path.Combine(RootDirectory, string.IsNullOrWhiteSpace(sessionId) + ? UnassignedFolder + : UnsafeCharacters().Replace(sessionId, "_")); + + [GeneratedRegex("[^A-Za-z0-9_.-]|^\\.+$")] + private static partial Regex UnsafeCharacters(); +} diff --git a/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageImageViewModel.cs b/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageImageViewModel.cs new file mode 100644 index 00000000..a37fde6b --- /dev/null +++ b/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageImageViewModel.cs @@ -0,0 +1,82 @@ +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using OneWare.Essentials.Controls; +using OneWare.Essentials.Helpers; +using OneWare.Essentials.Services; + +namespace OneWare.Chat.ViewModels.ChatMessages; + +/// An image a tool returned to the AI, stored next to the chat history. +public class ChatMessageImageViewModel : ObservableObject, IChatMessage, IEstimatedHeightItem +{ + /// Maximum width the preview is decoded to, which keeps large screenshots cheap in memory. + private const int PreviewDecodeWidth = 1024; + + private const double PreviewMaxHeight = 320; + + private bool _loaded; + + public ChatMessageImageViewModel(string filePath, string mimeType) + { + FilePath = filePath; + MimeType = mimeType; + } + + public string FilePath { get; } + + public string MimeType { get; } + + public string? Description { get; init; } + + public string? ToolName { get; init; } + + public string Caption => !string.IsNullOrWhiteSpace(Description) ? Description + : !string.IsNullOrWhiteSpace(ToolName) ? $"Image from {ToolName}" + : "Image"; + + public string FileName => Path.GetFileName(FilePath); + + /// The decoded preview, or null when the file is missing or cannot be decoded. + public Bitmap? Image + { + get + { + if (_loaded) return field; + _loaded = true; + + try + { + if (!File.Exists(FilePath)) return null; + + using var stream = File.OpenRead(FilePath); + var bitmap = new Bitmap(stream); + if (bitmap.PixelSize.Width > PreviewDecodeWidth) + { + var height = Math.Max(1, bitmap.PixelSize.Height * PreviewDecodeWidth / bitmap.PixelSize.Width); + var scaled = bitmap.CreateScaledBitmap(new Avalonia.PixelSize(PreviewDecodeWidth, height)); + bitmap.Dispose(); + bitmap = scaled; + } + + field = bitmap; + } + catch (Exception e) + { + ContainerLocator.Container.Resolve() + ?.Warning($"Showing chat image '{FilePath}' failed", e); + field = null; + } + + return field; + } + } + + public bool HasImage => Image != null; + + public void Open() + { + if (File.Exists(FilePath)) PlatformHelper.OpenHyperLink(FilePath); + } + + public double EstimateHeight(double width) => PreviewMaxHeight + 24; +} diff --git a/src/OneWare.Chat/ViewModels/ChatSessionViewModel.cs b/src/OneWare.Chat/ViewModels/ChatSessionViewModel.cs index 89a2f2b6..89c7070c 100644 --- a/src/OneWare.Chat/ViewModels/ChatSessionViewModel.cs +++ b/src/OneWare.Chat/ViewModels/ChatSessionViewModel.cs @@ -28,6 +28,12 @@ internal interface IChatSessionHost /// The service switched the chat to another session (new chat, replacement or reset). void OnSessionChanged(ChatSessionViewModel session); + + /// + /// Saves an image a tool returned next to the chat history and returns its path, or null when it could not be + /// saved. Called from the thread that raised the event. + /// + string? StoreImage(ChatSessionViewModel session, ChatImageEvent image); } /// @@ -1284,6 +1290,22 @@ private void OnEventReceived(object? sender, ChatEvent e) }); break; } + case ChatImageEvent x: + { + var path = _host.StoreImage(this, x); + if (path == null) break; + + Dispatcher.UIThread.Post(() => + { + AddMessage(new ChatMessageImageViewModel(path, x.MimeType) + { + Description = x.Description, + ToolName = x.ToolName + }, x.AgentId); + NotifyContentAdded(); + }); + break; + } case ChatIdleEvent x: { FinishTurn(x.Model); diff --git a/src/OneWare.Chat/ViewModels/ChatTranscript.cs b/src/OneWare.Chat/ViewModels/ChatTranscript.cs index 3de5146e..35fc1c77 100644 --- a/src/OneWare.Chat/ViewModels/ChatTranscript.cs +++ b/src/OneWare.Chat/ViewModels/ChatTranscript.cs @@ -35,6 +35,14 @@ internal static class ChatTranscript ToolOutput = tool.ToolOutput, IsSuccessful = tool.IsSuccessful }; + case ChatMessageImageViewModel image: + return new ChatMessageState(ChatMessageKind.Image) + { + ImagePath = image.FilePath, + MimeType = image.MimeType, + Message = image.Description, + ToolName = image.ToolName + }; case ChatMessageErrorViewModel: // Error chat messages are intentionally not serialized. return null; @@ -98,6 +106,19 @@ public static bool TryCreateMessage(ChatMessageState state, out IChatMessage mes IsToolRunning = false }; return true; + case ChatMessageKind.Image: + if (string.IsNullOrWhiteSpace(state.ImagePath)) + { + message = null!; + return false; + } + + message = new ChatMessageImageViewModel(state.ImagePath, state.MimeType ?? "image/png") + { + Description = state.Message, + ToolName = state.ToolName + }; + return true; case ChatMessageKind.Skill: if (string.IsNullOrWhiteSpace(state.SkillName)) { @@ -186,6 +207,11 @@ public ChatMessageState(ChatMessageKind kind) public bool ShowModel { get; set; } public bool IsSuccessful { get; set; } + /// File of an image message, stored by . + public string? ImagePath { get; set; } + + public string? MimeType { get; set; } + /// Messages nested inside a sub-agent block. public List Children { get; set; } = []; } @@ -197,5 +223,6 @@ internal enum ChatMessageKind Reasoning, Tool, Skill, - SubAgent + SubAgent, + Image } diff --git a/src/OneWare.Chat/ViewModels/ChatViewModel.cs b/src/OneWare.Chat/ViewModels/ChatViewModel.cs index 0efa14ed..efbeeaad 100644 --- a/src/OneWare.Chat/ViewModels/ChatViewModel.cs +++ b/src/OneWare.Chat/ViewModels/ChatViewModel.cs @@ -42,6 +42,7 @@ public class ChatViewModel : ExtendedTool, IChatManagerService, IChatSessionHost private readonly ISettingsService _settingsService; private readonly string _statePath; private readonly string _historyRootPath; + private readonly ChatImageStore _imageStore; private readonly Dictionary> _historyByService = new(StringComparer.Ordinal); @@ -79,6 +80,7 @@ public ChatViewModel(IAiFunctionProvider aiFunctionProvider, IMainDockService ma var chatDirectory = Path.Combine(paths.AppDataDirectory, "Chat"); _statePath = Path.Combine(chatDirectory, "ChatState.json"); _historyRootPath = Path.Combine(chatDirectory, "History"); + _imageStore = new ChatImageStore(Path.Combine(chatDirectory, "Images")); AiFileEditService = aiFileEditService; aiFileEditService.ActiveEdits.CollectionChanged += (_, _) => Dispatcher.UIThread.Post(() => @@ -682,6 +684,21 @@ private HashSet GetOpenSessionIds() void IChatSessionHost.SaveNow() => SaveState(); + string? IChatSessionHost.StoreImage(ChatSessionViewModel session, ChatImageEvent image) + { + try + { + return _imageStore.Store(session.CurrentSessionId ?? session.RememberedSessionId, image.Data, + image.MimeType); + } + catch (Exception e) + { + ContainerLocator.Container.Resolve() + ?.Warning("Saving a chat image failed", e); + return null; + } + } + void IChatSessionHost.OnSending(ChatSessionViewModel session) { if (ReferenceEquals(session, DraftSession)) Show(session); @@ -782,6 +799,20 @@ private void ClearServiceHistory(IChatService service) { var serviceName = service.Name; + if (_historyByService.TryGetValue(serviceName, out var removedItems)) + foreach (var item in removedItems) + { + try + { + _imageStore.DeleteSession(item.SessionId); + } + catch (Exception ex) + { + ContainerLocator.Container.Resolve() + ?.Warning($"Deleting the images of chat '{item.Name}' failed", ex); + } + } + _historyByService.Remove(serviceName); var serviceDirectory = GetServiceHistoryDirectory(serviceName); @@ -1008,13 +1039,15 @@ private void PruneSessionHistory(string serviceName) } } - private static bool TryDeleteSessionFile(ChatSessionHistoryItem item) + private bool TryDeleteSessionFile(ChatSessionHistoryItem item) { try { if (!string.IsNullOrWhiteSpace(item.FilePath) && File.Exists(item.FilePath)) File.Delete(item.FilePath); + _imageStore.DeleteSession(item.SessionId); + return true; } catch (Exception e) diff --git a/src/OneWare.Chat/Views/ChatMessages/ChatMessageImageView.axaml b/src/OneWare.Chat/Views/ChatMessages/ChatMessageImageView.axaml new file mode 100644 index 00000000..113e6939 --- /dev/null +++ b/src/OneWare.Chat/Views/ChatMessages/ChatMessageImageView.axaml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OneWare.Chat/Views/ChatMessages/ChatMessageImageView.axaml.cs b/src/OneWare.Chat/Views/ChatMessages/ChatMessageImageView.axaml.cs new file mode 100644 index 00000000..5ff8411d --- /dev/null +++ b/src/OneWare.Chat/Views/ChatMessages/ChatMessageImageView.axaml.cs @@ -0,0 +1,51 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; +using OneWare.Chat.ViewModels.ChatMessages; +using OneWare.Essentials.Helpers; +using OneWare.Essentials.Services; + +namespace OneWare.Chat.Views.ChatMessages; + +public partial class ChatMessageImageView : UserControl +{ + public ChatMessageImageView() + { + InitializeComponent(); + } + + private ChatMessageImageViewModel? Message => DataContext as ChatMessageImageViewModel; + + private void OnImageTapped(object? sender, TappedEventArgs e) => Message?.Open(); + + private void OnOpenClick(object? sender, RoutedEventArgs e) => Message?.Open(); + + private void OnShowInFolderClick(object? sender, RoutedEventArgs e) + { + if (Message is { } message && File.Exists(message.FilePath)) + PlatformHelper.OpenExplorerPath(message.FilePath); + } + + private async void OnSaveAsClick(object? sender, RoutedEventArgs e) + { + if (Message is not { } message || !File.Exists(message.FilePath)) return; + if (TopLevel.GetTopLevel(this) is not { } topLevel) return; + + var extension = Path.GetExtension(message.FilePath); + try + { + var target = await StorageProviderHelper.SelectSaveFileAsync(topLevel, "Save Image", null, + extension.TrimStart('.'), $"image{extension}", true, + new FilePickerFileType("Image") { Patterns = [$"*{extension}"], MimeTypes = [message.MimeType] }); + if (target == null) return; + + File.Copy(message.FilePath, target, true); + } + catch (Exception ex) + { + ContainerLocator.Container.Resolve() + ?.Error($"Saving the image failed: {ex.Message}", ex); + } + } +} diff --git a/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs b/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs index 3603ad2d..01b8819b 100644 --- a/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs +++ b/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs @@ -12,7 +12,6 @@ using System.Web; using Avalonia; using Avalonia.Threading; -using GitCredentialManager; using Microsoft.Extensions.Logging; using OneWare.CloudIntegration.Dto; using OneWare.Essentials.Extensions; @@ -31,7 +30,10 @@ public sealed class OneWareCloudLoginService : IOneWareCloudAccess private readonly IPaths _paths; private readonly SemaphoreSlim _semaphoreSlim = new(1, 1); private readonly ISettingsService _settingService; - private readonly string _tokenPath; + private readonly ISecretStore _secretStore; + + // Before the shared secret store, Windows refresh tokens were kept in Cloud/.bin; they are moved on use. + private readonly string _legacyWindowsTokenPath; private int? _port; private string? _codeVerifier; @@ -46,13 +48,14 @@ public sealed class OneWareCloudLoginService : IOneWareCloudAccess private CancellationTokenSource? _pendingLoginCts; public OneWareCloudLoginService(ILogger logger, ISettingsService settingService, IHttpService httpService, - IPaths paths) + IPaths paths, ISecretStore secretStore) { _logger = logger; _settingService = settingService; _httpService = httpService; _paths = paths; - _tokenPath = Path.Combine(paths.AppDataDirectory, "Cloud"); + _secretStore = secretStore; + _legacyWindowsTokenPath = Path.Combine(paths.AppDataDirectory, "Cloud"); settingService.GetSettingObservable(OneWareCloudIntegrationModule.OneWareCloudHostKey) .Skip(1) @@ -217,23 +220,9 @@ public void Logout(string userId) try { - string? refreshToken = null; - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - var tokenPath = Path.Combine(_tokenPath, $"{userId}.bin"); - if (File.Exists(tokenPath)) - { - refreshToken = TryReadWindowsRefreshToken(tokenPath); - File.Delete(tokenPath); - } - } - else - { - var store = CredentialManager.Create("oneware"); - refreshToken = store.Get(OneWareCloudIntegrationModule.CredentialStore, userId)?.Password; - store.Remove(OneWareCloudIntegrationModule.CredentialStore, userId); - } + MigrateLegacyWindowsRefreshToken(userId); + var refreshToken = _secretStore.Get(OneWareCloudIntegrationModule.CredentialStore, userId); + _secretStore.Remove(OneWareCloudIntegrationModule.CredentialStore, userId); _jwtBearerTokenCache.Remove(userId); @@ -246,19 +235,26 @@ public void Logout(string userId) } } - private string? TryReadWindowsRefreshToken(string tokenPath) + private void MigrateLegacyWindowsRefreshToken(string userId) { + if (!OperatingSystem.IsWindows()) return; + + var tokenPath = Path.Combine(_legacyWindowsTokenPath, $"{userId}.bin"); + if (!File.Exists(tokenPath)) return; + try { var plaintext = ProtectedData.Unprotect(File.ReadAllBytes(tokenPath), null, DataProtectionScope.CurrentUser); - return Encoding.UTF8.GetString(plaintext); + _secretStore.Set(OneWareCloudIntegrationModule.CredentialStore, userId, + Encoding.UTF8.GetString(plaintext)); } catch (Exception e) { _logger.Warning($"Could not read the stored refresh token: {e.Message}", null, false); - return null; } + + File.Delete(tokenPath); } /// Ends the offline session at the auth provider, so the signed-out token can no longer be used. @@ -343,20 +339,7 @@ private void StoreRefreshToken(string userId, string refreshToken) { try { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Directory.CreateDirectory(_tokenPath); - var tokenPath = Path.Combine(_tokenPath, $"{userId}.bin"); - - var plaintext = Encoding.UTF8.GetBytes(refreshToken); - var encrypted = ProtectedData.Protect(plaintext, null, DataProtectionScope.CurrentUser); - File.WriteAllBytes(tokenPath, encrypted); - } - else - { - var store = CredentialManager.Create("oneware"); - store.AddOrUpdate(OneWareCloudIntegrationModule.CredentialStore, userId, refreshToken); - } + _secretStore.Set(OneWareCloudIntegrationModule.CredentialStore, userId, refreshToken); } catch (Exception e) { @@ -799,29 +782,13 @@ async Task PersistAsync() } } - private async Task GetRefreshToken(string userId) + private Task GetRefreshToken(string userId) { - string? refreshToken = null; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return Task.Run(() => { - var tokenPath = Path.Combine(_tokenPath, $"{userId}.bin"); - if (File.Exists(tokenPath)) - { - var encrypted = await File.ReadAllBytesAsync(tokenPath); - var plaintext = ProtectedData.Unprotect(encrypted, null, DataProtectionScope.CurrentUser); - refreshToken = Encoding.UTF8.GetString(plaintext); - } - } - else - { - refreshToken = await Task.Run(() => - { - var store = CredentialManager.Create("oneware"); - return store.Get(OneWareCloudIntegrationModule.CredentialStore, userId)?.Password; - }); - } - - return refreshToken; + MigrateLegacyWindowsRefreshToken(userId); + return _secretStore.Get(OneWareCloudIntegrationModule.CredentialStore, userId); + }); } private static bool ShouldLogoutAfterTokenRefreshFailure(HttpStatusCode status) diff --git a/src/OneWare.Copilot/Connectors/CopilotRuntimeEnvironment.cs b/src/OneWare.Copilot/Connectors/CopilotRuntimeEnvironment.cs new file mode 100644 index 00000000..85a28f5f --- /dev/null +++ b/src/OneWare.Copilot/Connectors/CopilotRuntimeEnvironment.cs @@ -0,0 +1,44 @@ +using System.Collections; +using System.IO; + +namespace OneWare.Copilot.Connectors; + +/// Environment of the Copilot CLI process, which MCP servers inherit and ${VAR} references use. +internal static class CopilotRuntimeEnvironment +{ + /// + /// Merges the login shell's environment (variables and tools such as nvm's npx that apps started from a + /// desktop launcher don't see) with OneWare's own, which wins. PATH keeps the login shell's entries + /// first and appends OneWare's missing ones. Every MCP server inherits this, so it must not contain secrets + /// stored in OneWare. + /// + public static Dictionary Merge(IReadOnlyDictionary process, + IReadOnlyDictionary loginShell, bool ignoreCase) + { + var comparer = ignoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + var result = new Dictionary(loginShell, comparer); + foreach (var (key, value) in process) result[key] = value; + + if (loginShell.TryGetValue("PATH", out var loginPath) && !string.IsNullOrEmpty(loginPath)) + { + var entries = loginPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList(); + if (process.TryGetValue("PATH", out var processPath)) + entries.AddRange(processPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Where(x => !entries.Contains(x, comparer))); + result["PATH"] = string.Join(Path.PathSeparator, entries.Distinct(comparer)); + } + + return result; + } + + public static Dictionary GetProcessEnvironment() + { + var result = new Dictionary(OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal); + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) + if (entry is { Key: string key, Value: string value }) + result[key] = value; + return result; + } +} diff --git a/src/OneWare.Copilot/Connectors/McpConnector.cs b/src/OneWare.Copilot/Connectors/McpConnector.cs new file mode 100644 index 00000000..8942e152 --- /dev/null +++ b/src/OneWare.Copilot/Connectors/McpConnector.cs @@ -0,0 +1,296 @@ +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using GitHub.Copilot; + +namespace OneWare.Copilot.Connectors; + +public enum McpConnectorKind +{ + Local, + Remote +} + +/// An environment variable or header. Secret values are kept in the OS credential store. +public sealed record McpConnectorValue(string Key, string Value, bool IsSecret = false); + +/// +/// An IDE-wide MCP server ("connector") from OneWare's mcp.json. The file uses the Copilot CLI's server format +/// plus a few OneWare fields (, and the names of secret values); +/// the secret values themselves are kept in the OS credential store under the connector's . +/// ${VAR} references are expanded by the runtime from its environment. +/// +public sealed partial class McpConnector +{ + internal const string IdField = "id"; + internal const string DisabledField = "disabled"; + internal const string SecretEnvField = "secretEnv"; + internal const string SecretHeadersField = "secretHeaders"; + + // Fields edited by OneWare; everything else (tools, timeout, …) is kept as is. + private static readonly string[] EditedFields = + [ + IdField, DisabledField, SecretEnvField, SecretHeadersField, + "type", "command", "args", "cwd", "env", "url", "headers", "oauthClientId", "oauthPublicClient" + ]; + + private static readonly string[] SecretKeyMarkers = + ["TOKEN", "SECRET", "PASSWORD", "PASSWD", "API_KEY", "APIKEY", "API-KEY", "ACCESS_KEY"]; + + /// Stable identity, independent of the name; secrets are stored under it. + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + + public string Name { get; set; } = string.Empty; + + public McpConnectorKind Kind { get; set; } + + public bool Enabled { get; set; } = true; + + public string Command { get; set; } = string.Empty; + + public List Args { get; set; } = []; + + public string? WorkingDirectory { get; set; } + + public List Env { get; set; } = []; + + public string Url { get; set; } = string.Empty; + + /// Uses the legacy SSE transport instead of streamable HTTP. + public bool UseSse { get; set; } + + public List Headers { get; set; } = []; + + public string? OAuthClientId { get; set; } + + public bool? OAuthPublicClient { get; set; } + + public JsonObject OtherFields { get; set; } = new(); + + [GeneratedRegex(@"\$\{(?[A-Za-z_][A-Za-z0-9_]*)\}")] + private static partial Regex VariableRegex(); + + [GeneratedRegex("^[A-Za-z0-9_.-]+$")] + private static partial Regex NameRegex(); + + public static bool IsValidName(string name) => NameRegex().IsMatch(name) && name.Any(char.IsAsciiLetterOrDigit); + + /// + /// Reads a server in the Copilot CLI format, with OneWare's fields if present. Values of keys listed as secret + /// are taken from the configuration if it has them (e.g. when importing), otherwise they are empty. + /// + public static McpConnector FromConfig(string name, JsonObject config) + { + var url = GetString(config, "url"); + var command = GetString(config, "command"); + var type = GetString(config, "type")?.Trim().ToLowerInvariant(); + var connector = new McpConnector + { + Name = name, + Enabled = !(config[DisabledField] is JsonValue disabled && disabled.TryGetValue(out var off) && off), + Kind = type is "http" or "sse" || (type == null && command == null && url != null) + ? McpConnectorKind.Remote + : McpConnectorKind.Local, + UseSse = type == "sse", + Command = command ?? string.Empty, + Args = GetStrings(config, "args"), + WorkingDirectory = GetString(config, "cwd"), + Env = GetValues(config, "env", GetStrings(config, SecretEnvField)), + Url = url ?? string.Empty, + Headers = GetValues(config, "headers", GetStrings(config, SecretHeadersField)), + OAuthClientId = GetString(config, "oauthClientId"), + OAuthPublicClient = config["oauthPublicClient"] is JsonValue publicClient && + publicClient.TryGetValue(out var isPublic) + ? isPublic + : null + }; + + if (GetString(config, IdField) is { Length: > 0 } id) connector.Id = id; + + foreach (var (key, value) in config) + if (!EditedFields.Contains(key)) + connector.OtherFields[key] = value?.DeepClone(); + + return connector; + } + + /// The entry written to OneWare's mcp.json: without secret values, with OneWare's fields. + public JsonObject ToStoredConfig() + { + var config = new JsonObject { [IdField] = Id }; + foreach (var (key, value) in ToCliConfig(includeSecrets: false)) config[key] = value?.DeepClone(); + + var values = Kind == McpConnectorKind.Local ? Env : Headers; + var secretKeys = values.Where(x => x.IsSecret && !string.IsNullOrWhiteSpace(x.Key)) + .Select(x => (JsonNode?)x.Key.Trim()).ToArray(); + if (secretKeys.Length > 0) + config[Kind == McpConnectorKind.Local ? SecretEnvField : SecretHeadersField] = new JsonArray(secretKeys); + if (!Enabled) config[DisabledField] = true; + return config; + } + + /// + /// The configuration in the Copilot CLI format, as the runtime starts it. Values keep their ${VAR} + /// references. Without , secret values are left out. + /// + public JsonObject ToCliConfig(bool includeSecrets = true) + { + var config = new JsonObject(); + + if (Kind == McpConnectorKind.Local) + { + config["type"] = "stdio"; + config["command"] = Command.Trim(); + if (Args.Count > 0) config["args"] = new JsonArray(Args.Select(x => (JsonNode?)x).ToArray()); + if (!string.IsNullOrWhiteSpace(WorkingDirectory)) config["cwd"] = WorkingDirectory.Trim(); + if (ToObject(Env, includeSecrets) is { Count: > 0 } env) config["env"] = env; + } + else + { + config["type"] = UseSse ? "sse" : "http"; + config["url"] = Url.Trim(); + if (ToObject(Headers, includeSecrets) is { Count: > 0 } headers) config["headers"] = headers; + if (!string.IsNullOrWhiteSpace(OAuthClientId)) config["oauthClientId"] = OAuthClientId.Trim(); + if (OAuthPublicClient is { } isPublic) config["oauthPublicClient"] = isPublic; + } + + foreach (var (key, value) in OtherFields) config[key] = value?.DeepClone(); + if (includeSecrets) config["tools"] ??= new JsonArray("*"); + + return config; + } + + /// + /// The connector as a session MCP server, with secret values included. for SSE servers, + /// which the SDK's session configuration can't express; they are started with . + /// + public McpServerConfig? ToServerConfig() + { + if (Kind == McpConnectorKind.Remote && UseSse) return null; + + var config = ToCliConfig(); + McpServerConfig result = Kind == McpConnectorKind.Local + ? new McpStdioServerConfig + { + Command = Command.Trim(), + Args = Args.ToList(), + Env = Env.Where(x => !string.IsNullOrWhiteSpace(x.Key)) + .ToDictionary(x => x.Key.Trim(), x => x.Value), + WorkingDirectory = string.IsNullOrWhiteSpace(WorkingDirectory) ? null : WorkingDirectory.Trim() + } + : new McpHttpServerConfig + { + Url = Url.Trim(), + Headers = Headers.Where(x => !string.IsNullOrWhiteSpace(x.Key)) + .ToDictionary(x => x.Key.Trim(), x => x.Value), + OauthClientId = string.IsNullOrWhiteSpace(OAuthClientId) ? null : OAuthClientId.Trim(), + OauthPublicClient = OAuthPublicClient + }; + + result.Tools = GetStrings(config, "tools"); + if (config["timeout"] is JsonValue timeout && timeout.TryGetValue(out var ms)) result.Timeout = ms; + return result; + } + + /// The secret values currently in the connector. + public McpConnectorSecrets GetSecrets(string? oauthClientSecret) => new() + { + Env = Env.Where(x => x.IsSecret && !string.IsNullOrWhiteSpace(x.Key) && x.Value.Length > 0) + .ToDictionary(x => x.Key.Trim(), x => x.Value, StringComparer.Ordinal), + Headers = Headers.Where(x => x.IsSecret && !string.IsNullOrWhiteSpace(x.Key) && x.Value.Length > 0) + .ToDictionary(x => x.Key.Trim(), x => x.Value, StringComparer.Ordinal), + OAuthClientSecret = string.IsNullOrEmpty(oauthClientSecret) ? null : oauthClientSecret + }; + + /// A copy with the stored secret values filled in. + public McpConnector WithSecrets(McpConnectorSecrets secrets) + { + var result = Clone(); + result.Env = Fill(result.Env, secrets.Env); + result.Headers = Fill(result.Headers, secrets.Headers); + return result; + + static List Fill(List values, IReadOnlyDictionary stored) => + values.Select(x => x.IsSecret && stored.TryGetValue(x.Key.Trim(), out var value) ? x with { Value = value } : x) + .ToList(); + } + + /// Environment variables the connector references with ${VAR}, sorted. + public IReadOnlyList GetVariables() + { + IEnumerable values = Kind == McpConnectorKind.Local + ? [Command, WorkingDirectory ?? string.Empty, ..Args, ..Env.Where(x => !x.IsSecret).Select(x => x.Value)] + : [..Headers.Where(x => !x.IsSecret).Select(x => x.Value)]; + + return values.SelectMany(x => VariableRegex().Matches(x).Select(m => m.Groups["name"].Value)) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToList(); + } + + public static string ToVariableReference(string variable) => "${" + variable + "}"; + + public string Describe() => Kind == McpConnectorKind.Remote + ? Url + : string.Join(' ', new[] { Command }.Concat(Args).Where(x => !string.IsNullOrEmpty(x))); + + public McpConnector Clone() + { + var clone = FromConfig(Name, ToStoredConfig()); + // Stored configurations leave secret values out; a clone keeps them. + clone.Env = Env.ToList(); + clone.Headers = Headers.ToList(); + return clone; + } + + /// Whether a value with this key is most likely a credential. + public static bool LooksSecret(string key) + { + var upper = key.Trim().ToUpperInvariant(); + return upper is "AUTHORIZATION" or "PROXY-AUTHORIZATION" or "COOKIE" or "PAT" || + SecretKeyMarkers.Any(x => upper.Contains(x, StringComparison.Ordinal)); + } + + private static JsonObject ToObject(IEnumerable values, bool includeSecrets) + { + var result = new JsonObject(); + foreach (var value in values.Where(x => !string.IsNullOrWhiteSpace(x.Key) && (includeSecrets || !x.IsSecret))) + result[value.Key.Trim()] = value.Value; + return result; + } + + private static string? GetString(JsonObject obj, string key) => + obj[key] is JsonValue value && value.TryGetValue(out var text) ? text : null; + + private static List GetStrings(JsonObject obj, string key) => + obj[key] is JsonArray array + ? array.OfType().Select(x => x.TryGetValue(out var s) ? s : null).OfType() + .ToList() + : []; + + private static List GetValues(JsonObject obj, string key, IReadOnlyCollection secretKeys) + { + var result = obj[key] is JsonObject map + ? map.Where(x => x.Value is JsonValue v && v.TryGetValue(out _)) + .Select(x => new McpConnectorValue(x.Key, x.Value!.GetValue(), secretKeys.Contains(x.Key))) + .ToList() + : []; + + foreach (var secret in secretKeys.Where(x => result.All(r => r.Key != x))) + result.Add(new McpConnectorValue(secret, string.Empty, true)); + return result; + } +} + +/// Secret values of a connector, kept in the OS credential store as one entry per connector. +public sealed class McpConnectorSecrets +{ + public Dictionary Env { get; init; } = new(StringComparer.Ordinal); + + public Dictionary Headers { get; init; } = new(StringComparer.Ordinal); + + /// Secret of a confidential OAuth app, only sent when signing in. + public string? OAuthClientSecret { get; init; } + + [System.Text.Json.Serialization.JsonIgnore] + public bool IsEmpty => Env.Count == 0 && Headers.Count == 0 && string.IsNullOrEmpty(OAuthClientSecret); +} diff --git a/src/OneWare.Copilot/Connectors/McpConnectorHub.cs b/src/OneWare.Copilot/Connectors/McpConnectorHub.cs new file mode 100644 index 00000000..36af8884 --- /dev/null +++ b/src/OneWare.Copilot/Connectors/McpConnectorHub.cs @@ -0,0 +1,910 @@ +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using GitHub.Copilot; +using GitHub.Copilot.Rpc; +using Microsoft.Extensions.Logging; +using OneWare.Copilot.Services; +using OneWare.Essentials.Helpers; +using OneWare.Essentials.Services; + +namespace OneWare.Copilot.Connectors; + +public enum McpConnectorState +{ + Unknown, + Pending, + Connected, + NeedsSignIn, + Failed +} + +public sealed record McpConnectorStatus(McpConnectorState State, string? Message = null); + +/// An enabled connector ready to be started in a session, with its secret values filled in. +/// Configuration in the CLI format, for session.Rpc.Mcp.StartServerAsync. +/// The same as session configuration; for SSE servers. +/// Changes whenever the configuration does, to restart the server. +public sealed record ResolvedMcpConnector(string Name, JsonObject Config, McpServerConfig? ServerConfig, + string Fingerprint); + +/// +/// Owns the IDE-wide MCP servers ("connectors"). They are kept in OneWare's mcp.json, which may also be +/// edited by hand, and their secret values in the OS credential store (one entry per connector). Chats start the +/// enabled connectors in their sessions and report the status back here, so every view shows the same state. +/// OAuth tokens are kept by the runtime in its keychain storage. +/// +public sealed class McpConnectorHub : IDisposable +{ + public const string SecretService = "oneware-mcp"; + + /// Redirect URL of the runtime's OAuth sign-in, needed when registering an OAuth app. + public const string OAuthRedirectUrl = "http://127.0.0.1:41347/"; + + private static readonly TimeSpan HelperIdleTimeout = TimeSpan.FromMinutes(1); + private static readonly TimeSpan SignInTimeout = TimeSpan.FromMinutes(5); + private static readonly TimeSpan StartTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan ChangeDelay = TimeSpan.FromMilliseconds(250); + + private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true }; + + private static readonly JsonDocumentOptions ReadOptions = new() + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + + private readonly ISecretStore _secretStore; + private readonly IPaths _paths; + private readonly ILogger _logger; + + private readonly SemaphoreSlim _fileLock = new(1, 1); + private readonly SemaphoreSlim _helperLock = new(1, 1); + // The runtime serves every OAuth redirect on the same port, so only one sign-in can run at a time. + private readonly SemaphoreSlim _signInLock = new(1, 1); + private readonly Lock _stateLock = new(); + private readonly Dictionary _secrets = new(StringComparer.Ordinal); + private readonly Dictionary _statuses = new(StringComparer.OrdinalIgnoreCase); + + private IReadOnlyList? _connectors; + private string? _lastContent; + private FileSystemWatcher? _watcher; + private CancellationTokenSource? _reloadCts; + private CancellationTokenSource? _changedCts; + private Helper? _helper; + private CancellationTokenSource? _helperIdleCts; + + public McpConnectorHub(ISecretStore secretStore, IPaths paths, ILogger logger) + { + _secretStore = secretStore; + _paths = paths; + _logger = logger; + } + + /// Connectors were added, edited, removed, enabled or disabled, also by editing the file. + public event EventHandler? Changed; + + /// The status of the named server changed. + public event EventHandler? StatusChanged; + + /// The user signed in to the named server; sessions reconnect it. + public event EventHandler? SignedIn; + + public string ConfigFile => Path.Combine(_paths.AppDataDirectory, "Copilot", "mcp.json"); + + /// Why could not be read, if it can't; the last valid state stays in use. + public string? LoadError { get; private set; } + + public bool IsCliInstalled => File.Exists(CopilotModule.GetCliPath(_paths)); + + private static bool IgnoreCase => OperatingSystem.IsWindows(); + + /// + /// Environment for a Copilot CLI process, see . ${VAR} + /// references in connectors are expanded from it. It never contains secret values. + /// + public static async Task> GetRuntimeEnvironmentAsync() => + CopilotRuntimeEnvironment.Merge(CopilotRuntimeEnvironment.GetProcessEnvironment(), + await LoginShellEnvironment.GetAsync(), IgnoreCase); + + public void Dispose() + { + _watcher?.Dispose(); + _reloadCts?.Cancel(); + _changedCts?.Cancel(); + _helperIdleCts?.Cancel(); + _ = ResetHelperAsync(); + } + + #region Configuration + + /// The connectors without secret values, sorted by name. + public async Task> GetConnectorsAsync() + { + if (_connectors is { } cached) return cached; + + await _fileLock.WaitAsync(); + try + { + if (_connectors == null) await LoadAsync(); + StartWatching(); + return _connectors!; + } + finally + { + _fileLock.Release(); + } + } + + public async Task FindAsync(string name) => + (await GetConnectorsAsync()).FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)); + + /// Secret values of the connector, from the OS credential store. + public async Task GetSecretsAsync(string id) + { + lock (_stateLock) + { + if (_secrets.TryGetValue(id, out var cached)) return cached; + } + + var secrets = await Task.Run(() => ReadSecrets(id)); + lock (_stateLock) + { + _secrets[id] = secrets; + } + + return secrets; + } + + /// + /// Adds or updates a connector (matched by ) and stores its secret values, which + /// the connector must contain. + /// + public async Task SaveAsync(McpConnector connector, string? oauthClientSecret) + { + if (!McpConnector.IsValidName(connector.Name)) + throw new ArgumentException($"'{connector.Name}' is not a valid name.", nameof(connector)); + + await EditAsync(servers => + { + var existing = servers.FirstOrDefault(x => GetId(x.Value) == connector.Id).Key; + if (servers.Any(x => x.Key != existing && + string.Equals(x.Key, connector.Name, StringComparison.OrdinalIgnoreCase))) + throw new InvalidOperationException($"A connector named '{connector.Name}' already exists."); + + // Replacing the entry in place keeps the order of the file. + if (existing != null && existing != connector.Name) Rename(servers, existing, connector.Name); + servers[connector.Name] = connector.ToStoredConfig(); + }); + + await WriteSecretsAsync(connector.Id, connector.GetSecrets(oauthClientSecret)); + } + + /// Adds connectors, skipping names that exist. Returns the names that were added. + public async Task> AddAsync(IEnumerable connectors, + Action warn) + { + var added = new List(); + await EditAsync(servers => + { + foreach (var imported in connectors) + { + var name = imported.Connector.Name; + if (servers.Any(x => string.Equals(x.Key, name, StringComparison.OrdinalIgnoreCase))) + { + warn($"'{name}' already exists and was skipped."); + continue; + } + + servers[name] = imported.Connector.ToStoredConfig(); + added.Add(imported); + } + }); + + foreach (var imported in added) + await WriteSecretsAsync(imported.Connector.Id, imported.Connector.GetSecrets(imported.OAuthClientSecret)); + + return added.Select(x => x.Connector.Name).ToList(); + } + + public Task SetEnabledAsync(string id, bool enabled) => EditAsync(servers => + { + if (servers.FirstOrDefault(x => GetId(x.Value) == id).Value is not JsonObject config) return; + if (enabled) config.Remove(McpConnector.DisabledField); + else config[McpConnector.DisabledField] = true; + }); + + /// Removes the connector and its secret values. + public async Task RemoveAsync(string id) + { + string? name = null; + await EditAsync(servers => + { + name = servers.FirstOrDefault(x => GetId(x.Value) == id).Key; + if (name != null) servers.Remove(name); + }); + + await WriteSecretsAsync(id, new McpConnectorSecrets()); + if (name != null) ReportStatus(name, null); + } + + /// + /// The enabled connectors with their secret values filled in. ${VAR} references stay for the runtime to + /// expand from its environment. + /// + public async Task> ResolveEnabledAsync() + { + var result = new List(); + foreach (var connector in (await GetConnectorsAsync()).Where(x => x.Enabled)) + { + var resolved = await ResolveAsync(connector); + var config = resolved.ToCliConfig(); + result.Add(new ResolvedMcpConnector(connector.Name, config, resolved.ToServerConfig(), + Fingerprint(config))); + } + + return result; + } + + /// The connector with its secret values filled in. + public async Task ResolveAsync(McpConnector connector) => + connector.WithSecrets(await GetSecretsAsync(connector.Id)); + + /// + /// Servers in the Copilot CLI's user configuration (~/.copilot/mcp-config.json), offered for import. + /// Chats don't start them; OneWare's own list is the only source of IDE-wide servers. + /// + public async Task> ReadCopilotCliServersAsync(Action? warn = null) + { + var file = Path.Combine(CopilotSessionConfiguration.GetConfigDirectory(), "mcp-config.json"); + try + { + if (!File.Exists(file)) return []; + var text = await File.ReadAllTextAsync(file); + return McpConnectorImport.ParseText(text, warn ?? (_ => { })); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + _logger.LogDebug(e, "Failed to read {File}.", file); + return []; + } + } + + /// Names of the servers in the Copilot CLI's user configuration, which chats leave out. + public async Task> GetCopilotCliServerNamesAsync() + { + var file = Path.Combine(CopilotSessionConfiguration.GetConfigDirectory(), "mcp-config.json"); + try + { + if (!File.Exists(file)) return []; + return JsonNode.Parse(await File.ReadAllTextAsync(file), documentOptions: ReadOptions)?["mcpServers"] + is JsonObject servers + ? servers.Select(x => x.Key).ToList() + : []; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or JsonException) + { + _logger.LogDebug(e, "Failed to read {File}.", file); + return []; + } + } + + private async Task EditAsync(Action edit) + { + await _fileLock.WaitAsync(); + try + { + var root = await ReadRootAsync() ?? throw new InvalidOperationException( + $"{Path.GetFileName(ConfigFile)} can't be read: {LoadError} Fix or remove the file first."); + if (root["mcpServers"] is not JsonObject servers) root["mcpServers"] = servers = new JsonObject(); + + edit(servers); + await WriteRootAsync(root); + ApplyContent(root); + StartWatching(); + } + finally + { + _fileLock.Release(); + } + + RaiseChanged(); + } + + /// Reads the file; if it has errors. + private async Task ReadRootAsync() + { + string text; + try + { + text = File.Exists(ConfigFile) ? await File.ReadAllTextAsync(ConfigFile) : string.Empty; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + LoadError = e.Message; + return null; + } + + _lastContent = text; + if (string.IsNullOrWhiteSpace(text)) + { + LoadError = null; + return new JsonObject { ["mcpServers"] = new JsonObject() }; + } + + try + { + if (JsonNode.Parse(text, documentOptions: ReadOptions) is JsonObject root && + root["mcpServers"] is null or JsonObject) + { + LoadError = null; + return root; + } + + LoadError = "It must contain an object with an \"mcpServers\" object."; + } + catch (Exception e) when (e is JsonException or ArgumentException) + { + LoadError = e.Message; + } + + return null; + } + + private async Task WriteRootAsync(JsonObject root) + { + var text = root.ToJsonString(WriteOptions) + Environment.NewLine; + Directory.CreateDirectory(Path.GetDirectoryName(ConfigFile)!); + // Written next to the file and moved over it, so a crash never leaves a half-written file. + var temp = ConfigFile + ".tmp"; + await File.WriteAllTextAsync(temp, text); + File.Move(temp, ConfigFile, true); + _lastContent = text; + } + + private async Task LoadAsync() + { + var root = await ReadRootAsync(); + if (root == null) + { + _logger.LogWarning("Failed to read {File}: {Error}", ConfigFile, LoadError); + _connectors ??= []; + return; + } + + // Entries added by hand get an id, which their secret values are stored under. + var servers = root["mcpServers"] as JsonObject; + var missingIds = servers?.Where(x => x.Value is JsonObject config && string.IsNullOrEmpty(GetId(config))) + .Select(x => (JsonObject)x.Value!).ToList() ?? []; + if (missingIds.Count > 0) + { + foreach (var config in missingIds) config[McpConnector.IdField] = Guid.NewGuid().ToString("N"); + try + { + await WriteRootAsync(root); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + _logger.LogWarning(e, "Failed to write {File}.", ConfigFile); + } + } + + ApplyContent(root); + } + + private void ApplyContent(JsonObject root) + { + var result = new List(); + if (root["mcpServers"] is JsonObject servers) + foreach (var (name, node) in servers) + { + if (node is not JsonObject config) continue; + if (!McpConnector.IsValidName(name)) + { + _logger.LogWarning("Ignoring MCP server '{Name}' in {File}: invalid name.", name, ConfigFile); + continue; + } + + if (result.Any(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase))) + { + _logger.LogWarning("Ignoring MCP server '{Name}' in {File}: duplicate name.", name, ConfigFile); + continue; + } + + result.Add(McpConnector.FromConfig(name, config)); + } + + _connectors = result.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase).ToList(); + } + + private void StartWatching() + { + if (_watcher != null) return; + try + { + var directory = Path.GetDirectoryName(ConfigFile)!; + Directory.CreateDirectory(directory); + _watcher = new FileSystemWatcher(directory, Path.GetFileName(ConfigFile)) + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size + }; + _watcher.Changed += OnFileChanged; + _watcher.Created += OnFileChanged; + _watcher.Deleted += OnFileChanged; + _watcher.Renamed += OnFileChanged; + _watcher.EnableRaisingEvents = true; + } + catch (Exception e) + { + _logger.LogDebug(e, "Failed to watch {File}.", ConfigFile); + } + } + + private void OnFileChanged(object sender, FileSystemEventArgs e) + { + var cts = new CancellationTokenSource(); + Interlocked.Exchange(ref _reloadCts, cts)?.Cancel(); + _ = Task.Delay(ChangeDelay, cts.Token).ContinueWith(_ => ReloadFromDiskAsync(), + TaskContinuationOptions.OnlyOnRanToCompletion); + } + + private async Task ReloadFromDiskAsync() + { + await _fileLock.WaitAsync(); + try + { + string text; + try + { + text = File.Exists(ConfigFile) ? await File.ReadAllTextAsync(ConfigFile) : string.Empty; + } + catch (IOException) + { + // Still being written; the watcher reports the next write. + return; + } + + // Our own writes. + if (text == _lastContent) return; + await LoadAsync(); + } + catch (Exception e) + { + _logger.LogWarning(e, "Failed to reload {File}.", ConfigFile); + return; + } + finally + { + _fileLock.Release(); + } + + lock (_stateLock) + { + _secrets.Clear(); + } + + RaiseChanged(); + } + + private void RaiseChanged() + { + var cts = new CancellationTokenSource(); + Interlocked.Exchange(ref _changedCts, cts)?.Cancel(); + _ = Task.Delay(ChangeDelay, cts.Token).ContinueWith(_ => Changed?.Invoke(this, EventArgs.Empty), + TaskContinuationOptions.OnlyOnRanToCompletion); + } + + private static string? GetId(JsonNode? config) => + config?[McpConnector.IdField] is JsonValue value && value.TryGetValue(out var id) ? id : null; + + private static void Rename(JsonObject servers, string from, string to) + { + var entries = servers.ToList(); + servers.Clear(); + foreach (var (key, value) in entries) servers[key == from ? to : key] = value; + } + + private static string Fingerprint(JsonObject config) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(config.ToJsonString()))); + + #endregion + + #region Secrets + + private McpConnectorSecrets ReadSecrets(string id) + { + try + { + if (_secretStore.Get(SecretService, id) is { Length: > 0 } json && + JsonSerializer.Deserialize(json) is { } secrets) + return secrets; + } + catch (Exception e) + { + _logger.LogWarning(e, "Failed to read the secrets of MCP server {Id}.", id); + } + + return new McpConnectorSecrets(); + } + + private async Task WriteSecretsAsync(string id, McpConnectorSecrets secrets) + { + await Task.Run(() => + { + if (secrets.IsEmpty) _secretStore.Remove(SecretService, id); + else _secretStore.Set(SecretService, id, JsonSerializer.Serialize(secrets)); + }); + + lock (_stateLock) + { + _secrets[id] = secrets; + } + } + + #endregion + + #region Status and sign-in + + public McpConnectorStatus? GetStatus(string name) + { + lock (_stateLock) + { + return _statuses.GetValueOrDefault(name); + } + } + + /// Records the status of a server, as seen by a chat or a check. + public void ReportStatus(string name, McpConnectorStatus? status) + { + lock (_stateLock) + { + if (Equals(_statuses.GetValueOrDefault(name), status)) return; + if (status == null) _statuses.Remove(name); + else _statuses[name] = status; + } + + StatusChanged?.Invoke(this, name); + } + + public static McpConnectorStatus ToStatus(McpServerStatus status, string? error) + { + if (status == McpServerStatus.Connected) return new McpConnectorStatus(McpConnectorState.Connected); + if (status == McpServerStatus.NeedsAuth) return new McpConnectorStatus(McpConnectorState.NeedsSignIn); + if (status == McpServerStatus.Failed) return new McpConnectorStatus(McpConnectorState.Failed, error); + if (status == McpServerStatus.Pending) return new McpConnectorStatus(McpConnectorState.Pending); + return new McpConnectorStatus(McpConnectorState.Unknown); + } + + /// + /// Starts the connector in a separate session to see whether it connects. Local ones run their command. The + /// connector must contain its secret values. + /// + public async Task CheckAsync(McpConnector connector, bool report, + CancellationToken cancellationToken = default) + { + var status = await UseHelperAsync(async helper => + { + var session = await helper.GetSessionAsync(); + try + { + return await StartAsync(session, connector, cancellationToken); + } + finally + { + try + { + await session.Rpc.Mcp.StopServerAsync(connector.Name, CancellationToken.None); + } + catch (Exception e) + { + _logger.LogDebug(e, "Failed to stop MCP server {Name} after checking it.", connector.Name); + } + } + }); + + if (report) ReportStatus(connector.Name, status); + return status; + } + + /// + /// Runs the OAuth sign-in of a saved connector in the browser and waits until it connects. Tokens are kept by + /// the runtime, so every chat uses them afterwards. + /// + public async Task SignInAsync(string name, CancellationToken cancellationToken = default) + { + if (await FindAsync(name) is not { } connector) + return new McpConnectorStatus(McpConnectorState.Failed, $"'{name}' is not configured."); + + var secrets = await GetSecretsAsync(connector.Id); + var resolved = connector.WithSecrets(secrets); + + var status = await RunSignInAsync(async () => + { + // A sign-in waits for the browser, so it gets its own runtime instead of blocking the shared helper; the + // runtime serves the OAuth redirect and must stay alive until then. + await using var helper = await Helper.CreateAsync(this); + var session = await helper.GetSessionAsync(); + + var started = await StartAsync(session, resolved, cancellationToken); + if (started.State != McpConnectorState.NeedsSignIn) return started; + + return await LoginAsync(session, resolved.Name, resolved.OAuthClientId, secrets.OAuthClientSecret, + resolved.OAuthPublicClient, cancellationToken); + }, cancellationToken); + + ReportStatus(name, status); + if (status.State == McpConnectorState.Connected) SignedIn?.Invoke(this, name); + return status; + } + + /// + /// Runs the OAuth sign-in of a server in a chat's session, e.g. one from a project's .mcp.json. + /// + public async Task SignInAsync(CopilotSession session, string name, + CancellationToken cancellationToken = default) + { + var connector = await FindAsync(name); + var secrets = connector == null ? null : await GetSecretsAsync(connector.Id); + var status = await RunSignInAsync(() => LoginAsync(session, name, connector?.OAuthClientId, + secrets?.OAuthClientSecret, connector?.OAuthPublicClient, cancellationToken), cancellationToken); + + ReportStatus(name, status); + if (status.State == McpConnectorState.Connected) SignedIn?.Invoke(this, name); + return status; + } + + private async Task RunSignInAsync(Func> signIn, + CancellationToken cancellationToken) + { + if (!await _signInLock.WaitAsync(TimeSpan.Zero, cancellationToken)) + return new McpConnectorStatus(McpConnectorState.NeedsSignIn, + "Another sign-in is in progress. Finish it in the browser first."); + try + { + return await signIn(); + } + catch (Exception e) when (e is not OperationCanceledException) + { + _logger.LogWarning(e, "MCP sign-in failed."); + return new McpConnectorStatus(McpConnectorState.Failed, DescribeSignInError(StripRpcPrefix(e.Message))); + } + finally + { + _signInLock.Release(); + } + } + + private async Task LoginAsync(CopilotSession session, string name, string? clientId, + string? clientSecret, bool? publicClient, CancellationToken cancellationToken) + { + var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = session.On(e => + { + if (e.Data.ServerName != name) return; + if (e.Data.Status == McpServerStatus.Connected) + connected.TrySetResult(new McpConnectorStatus(McpConnectorState.Connected)); + else if (e.Data.Status == McpServerStatus.Failed) + connected.TrySetResult(new McpConnectorStatus(McpConnectorState.Failed, e.Data.Error)); + }); + + var login = await session.Rpc.Mcp.Oauth.LoginAsync(new McpOauthLoginRequest + { + ServerName = name, + ClientName = "OneWare Studio", + CallbackSuccessMessage = "Signed in. You can close this tab and return to OneWare Studio.", + ClientId = string.IsNullOrWhiteSpace(clientId) ? null : clientId, + ClientSecret = string.IsNullOrEmpty(clientSecret) ? null : clientSecret, + PublicClient = publicClient ?? (string.IsNullOrEmpty(clientSecret) ? null : false) + }, cancellationToken); + + // Without a URL, stored tokens were still valid and the server reconnects by itself. + if (!string.IsNullOrEmpty(login.AuthorizationUrl)) PlatformHelper.OpenHyperLink(login.AuthorizationUrl); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(SignInTimeout); + try + { + return await connected.Task.WaitAsync(timeout.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + var status = await GetServerStatusAsync(session, name, CancellationToken.None); + return status.State == McpConnectorState.Connected + ? status + : new McpConnectorStatus(McpConnectorState.NeedsSignIn, "The sign-in was not completed."); + } + } + + public static string DescribeSignInError(string message) => + message.Contains("dynamic client registration", StringComparison.OrdinalIgnoreCase) + ? "The server doesn't let apps register themselves. Register an OAuth app with the provider, using " + + $"{OAuthRedirectUrl} as redirect URL, and enter its client ID (and secret) under Advanced." + : message; + + internal static bool IsDynamicRegistrationError(string? message) => + message?.Contains("register themselves", StringComparison.Ordinal) == true || + message?.Contains("dynamic client registration", StringComparison.OrdinalIgnoreCase) == true; + + internal static bool IsAuthenticationRequired(string message) => + message.Contains("requires authentication", StringComparison.OrdinalIgnoreCase) || + message.Contains("needs-auth", StringComparison.OrdinalIgnoreCase); + + private async Task StartAsync(CopilotSession session, McpConnector connector, + CancellationToken cancellationToken) + { + try + { + await session.Rpc.Mcp.StartServerAsync(new McpStartServerRequest + { + ServerName = connector.Name, + Config = JsonSerializer.SerializeToElement(connector.ToCliConfig()) + }, cancellationToken); + } + catch (Exception e) when (e is not OperationCanceledException) + { + if (IsAuthenticationRequired(e.Message)) return new McpConnectorStatus(McpConnectorState.NeedsSignIn); + return new McpConnectorStatus(McpConnectorState.Failed, StripRpcPrefix(e.Message)); + } + + return await GetServerStatusAsync(session, connector.Name, cancellationToken); + } + + private static async Task GetServerStatusAsync(CopilotSession session, string name, + CancellationToken cancellationToken) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(StartTimeout); + try + { + while (true) + { + var server = (await session.Rpc.Mcp.ListAsync(timeout.Token)).Servers.FirstOrDefault(x => x.Name == name); + if (server == null) return new McpConnectorStatus(McpConnectorState.Unknown); + if (server.Status != McpServerStatus.Pending) return ToStatus(server.Status, server.Error); + await Task.Delay(250, timeout.Token); + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return new McpConnectorStatus(McpConnectorState.Failed, "The server did not start in time."); + } + } + + internal static string StripRpcPrefix(string message) + { + const string marker = "failed with message: "; + var index = message.IndexOf(marker, StringComparison.Ordinal); + return index < 0 ? message : message[(index + marker.Length)..]; + } + + #endregion + + #region Helper runtime + + /// + /// Runs on a shared, short-lived CLI process. It is shut down when idle, so it never + /// holds the CLI executable while it gets updated. + /// + private async Task UseHelperAsync(Func> action) + { + if (!IsCliInstalled) + throw new InvalidOperationException("The Copilot CLI is not installed. Open the AI chat to install it."); + + await _helperLock.WaitAsync(); + try + { + _helperIdleCts?.Cancel(); + _helper ??= await Helper.CreateAsync(this); + try + { + return await action(_helper); + } + catch (Exception e) when (e is IOException or ObjectDisposedException) + { + // The process may have exited; the next operation starts a new one. + await _helper.DisposeAsync(); + _helper = null; + throw; + } + } + finally + { + var idle = new CancellationTokenSource(); + _helperIdleCts = idle; + _helperLock.Release(); + _ = Task.Delay(HelperIdleTimeout, idle.Token).ContinueWith(_ => ResetHelperAsync(idle.Token), + TaskContinuationOptions.OnlyOnRanToCompletion); + } + } + + private async Task ResetHelperAsync(CancellationToken idleToken = default) + { + await _helperLock.WaitAsync(CancellationToken.None); + try + { + if (idleToken.IsCancellationRequested || _helper == null) return; + await _helper.DisposeAsync(); + _helper = null; + } + catch (Exception e) + { + _logger.LogDebug(e, "Failed to stop the connector helper runtime."); + } + finally + { + _helperLock.Release(); + } + } + + private sealed class Helper : IAsyncDisposable + { + private CopilotSession? _session; + + private Helper(CopilotClient client) + { + Client = client; + } + + public CopilotClient Client { get; } + + public static async Task CreateAsync(McpConnectorHub hub) + { + if (!hub.IsCliInstalled) + throw new InvalidOperationException("The Copilot CLI is not installed. Open the AI chat to install it."); + + var connection = RuntimeConnection.ForStdio(CopilotModule.GetCliPath(hub._paths), []); + connection.Environment = await GetRuntimeEnvironmentAsync(); + + // No Copilot account is needed to start MCP servers. + var client = new CopilotClient(new CopilotClientOptions + { + WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + Connection = connection, + UseLoggedInUser = false + }); + + try + { + await client.StartAsync(); + } + catch + { + await client.DisposeAsync(); + throw; + } + + return new Helper(client); + } + + /// A session without config discovery, used to start individual connectors. + public async Task GetSessionAsync() => _session ??= await Client.CreateSessionAsync( + new SessionConfig + { + WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ClientName = "OneWare Studio", + EnableConfigDiscovery = false, + AvailableTools = new ToolSet().AddMcp("*"), + McpOAuthTokenStorage = McpOAuthTokenStorageMode.Persistent, + OnPermissionRequest = (_, _) => + Task.FromResult(PermissionDecision.Reject("Connector checks don't run tools.")) + }); + + public async ValueTask DisposeAsync() + { + try + { + if (_session != null) await _session.DisposeAsync(); + } + catch + { + // The process is stopped below anyway. + } + + await Client.DisposeAsync(); + } + } + + #endregion +} diff --git a/src/OneWare.Copilot/Connectors/McpConnectorImport.cs b/src/OneWare.Copilot/Connectors/McpConnectorImport.cs new file mode 100644 index 00000000..6034c55c --- /dev/null +++ b/src/OneWare.Copilot/Connectors/McpConnectorImport.cs @@ -0,0 +1,229 @@ +using System.IO; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace OneWare.Copilot.Connectors; + +/// Secret of a pre-registered OAuth client (oauthClientSecret), if declared. +public sealed record ImportedMcpConnector(McpConnector Connector, string? OAuthClientSecret); + +/// +/// Reads connectors from MCP configuration files in the formats of the Copilot CLI (mcpServers), VS Code +/// (servers) and Claude Code (top-level map). +/// +public static partial class McpConnectorImport +{ + [GeneratedRegex(@"\$\{(?[A-Za-z_][A-Za-z0-9_:]*)(?::-(?[^}]*))?\}")] + private static partial Regex VariableRegex(); + + public static IReadOnlyList Read(string file, Action warn) + { + string text; + try + { + text = File.ReadAllText(file); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + warn($"Failed to read {file}: {e.Message}"); + return []; + } + + return Parse(text, Path.GetDirectoryName(Path.GetFullPath(file))!, warn, file); + } + + /// + /// Reads pasted configuration: a file in one of the supported formats, a single "name": { … } entry, or + /// just a server's configuration, which gets . + /// + public static IReadOnlyList ParseText(string text, Action warn, + string fallbackName = "server") + { + var trimmed = text.Trim(); + // A copied "name": { … } entry without the surrounding braces. + if (trimmed.StartsWith('"')) trimmed = "{" + trimmed.TrimEnd(',') + "}"; + return Parse(trimmed, null, warn, "The configuration", fallbackName); + } + + private static IReadOnlyList Parse(string text, string? directory, Action warn, + string source, string? fallbackName = null) + { + JsonNode? document; + try + { + document = JsonNode.Parse(text, documentOptions: new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }); + } + catch (JsonException e) + { + warn($"{source} is not valid JSON: {e.Message}"); + return []; + } + + if (document is not JsonObject root) + { + warn($"{source} must contain a JSON object."); + return []; + } + + // A single server's configuration. + if (fallbackName != null && (root["command"] is JsonValue || root["url"] is JsonValue)) + return Convert(fallbackName, root, directory, warn) is { } single ? [single] : []; + + var map = root["mcpServers"] as JsonObject ?? root["servers"] as JsonObject ?? root; + var result = new List(); + + foreach (var (name, node) in map) + { + if (node is not JsonObject declaration) continue; + if (!McpConnector.IsValidName(name)) + { + warn($"'{name}' is not a valid connector name (letters, digits, '.', '-' and '_')."); + continue; + } + + if (Convert(name, declaration, directory, message => warn($"{name}: {message}")) is { } imported) + result.Add(imported); + } + + return result; + } + + private static ImportedMcpConnector? Convert(string name, JsonObject declaration, string? directory, + Action warn) + { + string Normalize(string value) => NormalizeVariables(value, directory, warn); + + var command = GetString(declaration, "command"); + var url = GetString(declaration, "url"); + var type = GetString(declaration, "type")?.Trim().ToLowerInvariant() switch + { + null or "" => command != null ? "stdio" : url != null ? "http" : null, + "stdio" or "local" => "stdio", + "http" or "streamable-http" or "streamablehttp" => "http", + "sse" => "sse", + var other => Unsupported(other) + }; + + string? Unsupported(string value) + { + warn($"the type '{value}' is not supported."); + return null; + } + + var config = new JsonObject(); + string? clientSecret = null; + + if (type == "stdio" && !string.IsNullOrWhiteSpace(command)) + { + config["command"] = Normalize(command); + if (declaration["args"] is JsonArray args) + config["args"] = new JsonArray(args.OfType() + .Select(x => x.TryGetValue(out var s) ? (JsonNode?)Normalize(s) : null) + .OfType().ToArray()); + if (declaration["env"] is JsonObject env) config["env"] = MapValues(env, Normalize); + // Project files expect to run in their folder, a connector runs wherever the chat is. + var cwd = GetString(declaration, "cwd"); + if (!string.IsNullOrWhiteSpace(cwd)) + config["cwd"] = directory == null ? Normalize(cwd) : Path.GetFullPath(Normalize(cwd), directory); + else if (directory != null) + config["cwd"] = directory; + } + else if (type is "http" or "sse" && !string.IsNullOrWhiteSpace(url)) + { + config["type"] = type; + // The CLI only accepts a literal url. + config["url"] = ExpandVariables(url, directory, warn); + if (declaration["headers"] is JsonObject headers) config["headers"] = MapValues(headers, Normalize); + if (GetString(declaration, "oauthClientId") is { } clientId) + config["oauthClientId"] = ExpandVariables(clientId, directory, warn); + if (declaration["oauthPublicClient"]?.DeepClone() is { } publicClient) + config["oauthPublicClient"] = publicClient; + if (GetString(declaration, "oauthClientSecret") is { } secret) + clientSecret = ExpandVariables(secret, directory, warn); + } + else + { + if (type != null || command != null || url != null) + warn(type == "stdio" ? "a command is required." : "a url is required."); + else warn("a command or a url is required."); + return null; + } + + foreach (var key in new[] { "tools", "timeout" }) + if (declaration[key]?.DeepClone() is { } value) + config[key] = value; + + if (declaration["disabled"] is JsonValue disabled && disabled.TryGetValue(out var off) && off) + config[McpConnector.DisabledField] = true; + + var connector = McpConnector.FromConfig(name, config); + // Literal credentials go to the credential store instead of OneWare's configuration file. + connector.Env = connector.Env.Select(MarkSecret).ToList(); + connector.Headers = connector.Headers.Select(MarkSecret).ToList(); + return new ImportedMcpConnector(connector, clientSecret); + + static McpConnectorValue MarkSecret(McpConnectorValue value) => + value with { IsSecret = McpConnector.LooksSecret(value.Key) && value.Value.Length > 0 && + !value.Value.Contains("${", StringComparison.Ordinal) }; + } + + /// + /// Rewrites references to the CLI's ${VAR} syntax: ${env:VAR} becomes ${VAR} and + /// ${workspaceFolder} the folder of the imported file. + /// + internal static string NormalizeVariables(string value, string? workspaceFolder, Action warn) => + VariableRegex().Replace(value, match => + { + var name = match.Groups["name"].Value; + if (name is "workspaceFolder" or "workspaceRoot") + { + if (workspaceFolder != null) return workspaceFolder; + warn($"${{{name}}} has no meaning outside a project; replace it with a path."); + return match.Value; + } + + if (name.StartsWith("env:", StringComparison.Ordinal)) name = name[4..]; + if (name.Contains(':')) return match.Value; + if (match.Groups["default"].Success) + warn($"the default value of ${{{name}}} is not supported and was dropped."); + return McpConnector.ToVariableReference(name); + }); + + private static string ExpandVariables(string value, string? workspaceFolder, Action warn) => + VariableRegex().Replace(value, match => + { + var name = match.Groups["name"].Value; + if (name is "workspaceFolder" or "workspaceRoot") + { + if (workspaceFolder != null) return workspaceFolder; + warn($"${{{name}}} has no meaning outside a project; replace it with a path."); + return match.Value; + } + + if (name.StartsWith("env:", StringComparison.Ordinal)) name = name[4..]; + + var resolved = Environment.GetEnvironmentVariable(name); + if (!string.IsNullOrEmpty(resolved)) return resolved; + if (match.Groups["default"].Success) return match.Groups["default"].Value; + + warn($"${{{name}}} is not defined."); + return string.Empty; + }); + + private static JsonObject MapValues(JsonObject values, Func map) + { + var result = new JsonObject(); + foreach (var (key, value) in values) + if (value is JsonValue v && v.TryGetValue(out var text)) + result[key] = map(text); + return result; + } + + private static string? GetString(JsonObject obj, string key) => + obj[key] is JsonValue value && value.TryGetValue(out var text) ? text : null; +} diff --git a/src/OneWare.Copilot/CopilotModule.cs b/src/OneWare.Copilot/CopilotModule.cs index d234278d..a7ce8743 100644 --- a/src/OneWare.Copilot/CopilotModule.cs +++ b/src/OneWare.Copilot/CopilotModule.cs @@ -1,7 +1,9 @@ using System.Reactive.Linq; using Microsoft.Extensions.DependencyInjection; using OneWare.CloudIntegration; +using OneWare.Copilot.Connectors; using OneWare.Copilot.Services; +using OneWare.Copilot.Settings; using OneWare.Copilot.Views; using OneWare.Essentials.Helpers; using OneWare.Essentials.Models; @@ -17,6 +19,8 @@ public class CopilotModule : OneWareModuleBase public const string CopilotApprovalModeSettingKey = "AI_Chat_Copilot_ApprovalMode"; public const string CopilotContextTierSettingKey = "AI_Chat_Copilot_ContextTier"; public const string CopilotAutoTierSettingKey = "AI_Chat_Copilot_AutoTier"; + public const string CopilotSkillDirectoriesSettingKey = "AI_Chat_Copilot_SkillDirectories"; + public const string CopilotConnectorsSettingKey = "AI_Chat_Copilot_Connectors"; public const string CopilotProviderSettingKey = "AI_Chat_Copilot_Provider"; public const string CopilotByokEndpointSettingKey = "AI_Chat_Copilot_BYOK_Endpoint"; public const string CopilotByokApiKeyEnvironmentVariableSettingKey = @@ -93,38 +97,38 @@ public class CopilotModule : OneWareModuleBase [ new PackageVersion() { - Version = "1.0.97", + Version = "1.0.105", Targets = [ new PackageTarget() { Target = "win-x64", - Url = "https://github.com/github/copilot-cli/releases/download/v1.0.97/copilot-win32-x64.zip" + Url = "https://github.com/github/copilot-cli/releases/download/v1.0.105/copilot-win32-x64.zip" }, new PackageTarget() { Target = "win-arm64", - Url = "https://github.com/github/copilot-cli/releases/download/v1.0.97/copilot-win32-arm64.zip" + Url = "https://github.com/github/copilot-cli/releases/download/v1.0.105/copilot-win32-arm64.zip" }, new PackageTarget() { Target = "linux-x64", - Url = "https://github.com/github/copilot-cli/releases/download/v1.0.97/copilot-linux-x64.tar.gz" + Url = "https://github.com/github/copilot-cli/releases/download/v1.0.105/copilot-linux-x64.tar.gz" }, new PackageTarget() { Target = "linux-arm64", - Url = "https://github.com/github/copilot-cli/releases/download/v1.0.97/copilot-linux-arm64.tar.gz" + Url = "https://github.com/github/copilot-cli/releases/download/v1.0.105/copilot-linux-arm64.tar.gz" }, new PackageTarget() { Target = "osx-x64", - Url = "https://github.com/github/copilot-cli/releases/download/v1.0.97/copilot-darwin-x64.tar.gz" + Url = "https://github.com/github/copilot-cli/releases/download/v1.0.105/copilot-darwin-x64.tar.gz" }, new PackageTarget() { Target = "osx-arm64", - Url = "https://github.com/github/copilot-cli/releases/download/v1.0.97/copilot-darwin-arm64.tar.gz" + Url = "https://github.com/github/copilot-cli/releases/download/v1.0.105/copilot-darwin-arm64.tar.gz" }, ] } @@ -144,6 +148,7 @@ public static string GetCliPath(IPaths paths) public override void RegisterServices(IServiceCollection services) { + services.AddSingleton(); services.AddTransient(); services.AddTransient(); } @@ -159,6 +164,21 @@ public override void Initialize(IServiceProvider serviceProvider) var settingsService = serviceProvider.Resolve(); + settingsService.RegisterSetting("AI Chat", "Copilot CLI", CopilotSkillDirectoriesSettingKey, + new ListBoxSetting("Additional Skill Directories") + { + HoverDescription = + "Additional folders containing SKILL.md bundles, alongside Copilot CLI user/project skills " + + "and plugin skills. Supports absolute paths, ~/ paths, and paths relative to the active " + + "project. Applied when starting or reopening a chat." + }); + + settingsService.RegisterCustom("AI Chat", "Connectors", CopilotConnectorsSettingKey, + new CopilotConnectorsSetting(serviceProvider.Resolve())); + + // Reading the login shell's environment takes a moment; the first chat needs it. + _ = LoginShellEnvironment.GetAsync(); + settingsService.RegisterSetting("AI Chat", "Copilot CLI", CopilotProviderSettingKey, new ComboBoxSetting("Model Provider", ProviderGitHubCopilot, [ProviderGitHubCopilot, ProviderOpenAiCompatible, ProviderAnthropic]) @@ -268,7 +288,8 @@ public override void Initialize(IServiceProvider serviceProvider) "Routing preference for the \"auto\" model, which lets Copilot pick a backend " + "model per turn. Default: leave the choice to Copilot. Fast: prefer the " + "lowest-latency models. Efficiency: prefer cheaper models. Balance: trade off speed and capability. " + - "Intelligence: prefer the most capable models." + "Intelligence: prefer the most capable models. Copilot can offer further tiers for your " + + "account, e.g. HydraFusion, which combines several models per turn." }); settingsService.Register(CopilotSelectedModelSettingKey, DefaultModelId); diff --git a/src/OneWare.Copilot/OneWare.Copilot.csproj b/src/OneWare.Copilot/OneWare.Copilot.csproj index 412f7d9e..39366358 100644 --- a/src/OneWare.Copilot/OneWare.Copilot.csproj +++ b/src/OneWare.Copilot/OneWare.Copilot.csproj @@ -17,7 +17,7 @@ - + \ No newline at end of file diff --git a/src/OneWare.Copilot/Services/CopilotAutoTiers.cs b/src/OneWare.Copilot/Services/CopilotAutoTiers.cs new file mode 100644 index 00000000..33a8cd66 --- /dev/null +++ b/src/OneWare.Copilot/Services/CopilotAutoTiers.cs @@ -0,0 +1,105 @@ +using GitHub.Copilot.Rpc; + +namespace OneWare.Copilot.Services; + +/// A routing preference of the auto model as offered in the picker. +public sealed class CopilotAutoTierOption(string label, string? id, string? description = null, bool isFusion = false) +{ + public string Label { get; } = label; + + /// Runtime tier id, null to leave the routing to the provider. + public string? Id { get; } = id; + + public string? Description { get; } = description; + + /// True for HydraFusion tiers, which combine several models per turn. + public bool IsFusion { get; } = isFusion; + + public bool HasDescription => !string.IsNullOrWhiteSpace(Description); + + public override string ToString() => Label; +} + +/// +/// Builds the Auto routing preferences. With DYNAMIC_AUTO_TIERS the runtime reports the tiers the account can +/// use (including HydraFusion), otherwise the built-in tiers apply. +/// +internal static class CopilotAutoTiers +{ + internal const string AutoType = "auto"; + internal const string FusionType = "fusion"; + private const string FastId = "fast"; + + public static IReadOnlyList Fallback { get; } = + [ + new(CopilotChatServiceBase.AutoTierDefault, null, "Leave the choice to Copilot."), + new(CopilotChatServiceBase.AutoTierFast, FastId, "Prefer the lowest-latency models."), + new(CopilotChatServiceBase.AutoTierEfficiency, "efficiency", "Prefer cheaper models."), + new(CopilotChatServiceBase.AutoTierBalance, "balance", "Trade off speed and capability."), + new(CopilotChatServiceBase.AutoTierIntelligence, "intelligence", "Prefer the most capable models.") + ]; + + /// + /// The tiers to offer for : enabled, picker-visible auto and fusion + /// tiers in the provider's order. fast is hidden like in the Copilot CLI. Falls back to + /// when the provider reported nothing usable. + /// + public static IReadOnlyList Build(AutoTierMetadata? metadata) + { + if (metadata?.Tiers is not { Count: > 0 } tiers) return Fallback; + + var options = new List(); + var labels = new HashSet(StringComparer.OrdinalIgnoreCase) { CopilotChatServiceBase.AutoTierDefault }; + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var tier in tiers) + { + if (string.IsNullOrWhiteSpace(tier.Id) || !ids.Add(tier.Id)) continue; + if (string.Equals(tier.Id, FastId, StringComparison.OrdinalIgnoreCase)) continue; + + var isFusion = string.Equals(tier.Type, FusionType, StringComparison.OrdinalIgnoreCase); + if (!isFusion && !string.Equals(tier.Type, AutoType, StringComparison.OrdinalIgnoreCase)) continue; + if (tier.Status is { Enabled: false } || tier.Status?.PickerVisible == false) continue; + + var label = GetKnownLabel(tier.Id) ?? + (string.IsNullOrWhiteSpace(tier.DisplayName) ? tier.Id : tier.DisplayName.Trim()); + if (!labels.Add(label)) + { + label = tier.Id; + if (!labels.Add(label)) continue; + } + + options.Add(new CopilotAutoTierOption(label, tier.Id, tier.Description, isFusion)); + } + + if (options.Count == 0) return Fallback; + + var defaultLabel = Describe(options, metadata.DefaultTier); + var defaultDescription = string.IsNullOrWhiteSpace(metadata.DefaultTier) + ? "Leave the choice to Copilot." + : $"Leave the choice to Copilot (currently {defaultLabel})."; + + options.Insert(0, new CopilotAutoTierOption(CopilotChatServiceBase.AutoTierDefault, null, defaultDescription)); + return options; + } + + /// The runtime id of the tier labelled , null for Default or unknown labels. + public static string? ResolveId(IEnumerable options, string? label) + { + if (string.IsNullOrWhiteSpace(label)) return null; + return options.FirstOrDefault(x => string.Equals(x.Label, label, StringComparison.Ordinal))?.Id; + } + + /// The label for the runtime tier ; Default when it is null. + public static string Describe(IEnumerable options, string? id) + { + if (string.IsNullOrWhiteSpace(id)) return CopilotChatServiceBase.AutoTierDefault; + + return options.FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase))?.Label + ?? GetKnownLabel(id) + ?? id; + } + + private static string? GetKnownLabel(string id) => + Fallback.FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase))?.Label; +} diff --git a/src/OneWare.Copilot/Services/CopilotChatService.Fusion.cs b/src/OneWare.Copilot/Services/CopilotChatService.Fusion.cs new file mode 100644 index 00000000..7a922be8 --- /dev/null +++ b/src/OneWare.Copilot/Services/CopilotChatService.Fusion.cs @@ -0,0 +1,222 @@ +using GitHub.Copilot; +using Microsoft.Extensions.Logging; +using OneWare.Essentials.Models; +using OneWare.Essentials.Services; + +namespace OneWare.Copilot.Services; + +/// +/// HydraFusion turns (Auto tiers of type fusion) run several models per turn. Phases that work in the root +/// conversation (primary, revision, …) produce the answer and stay in the main flow. Review phases (critic, judge) +/// run in an isolated history and are shown in their own collapsible block, like a sub-agent. +/// +public abstract partial class CopilotChatServiceBase +{ + private const string FusionBlockPrefix = "fusion:"; + + // Running review phases by phase id. + private readonly Dictionary _fusionPhases = new(StringComparer.Ordinal); + + // The router's short summary of a turn's plan, by fusion id. + private readonly Dictionary _fusionHints = new(StringComparer.Ordinal); + + private sealed class FusionReviewPhase(string blockId, string fusionId) + { + public string BlockId { get; } = blockId; + + public string FusionId { get; } = fusionId; + + /// True once the phase's own messages were shown in its block. + public bool HasContent { get; set; } + } + + private static bool IsFusionReviewScope(FusionConversationScope scope) => + string.Equals(scope.Value, FusionConversationScope.Review.Value, StringComparison.OrdinalIgnoreCase); + + /// Handles the HydraFusion lifecycle events; returns false for every other event. + private bool HandleFusionEvent(SessionEvent evt) + { + switch (evt) + { + case SessionFusionResolvedEvent x: + lock (_fusionPhases) + { + if (!string.IsNullOrWhiteSpace(x.Data.FusionId) && !string.IsNullOrWhiteSpace(x.Data.Hint)) + _fusionHints[x.Data.FusionId] = x.Data.Hint; + } + + ContainerLocator.Container.Resolve().LogInformation( + "Copilot Fusion: {Pattern} with {Primary} ({Hint})", x.Data.Pattern.Value, + x.Data.PrimaryModel, x.Data.Hint); + return true; + case SessionFusionRouteFailedEvent x: + ContainerLocator.Container.Resolve().LogWarning( + "Copilot Fusion routing failed ({Reason}): {Error}. Falling back to {Model}.", + x.Data.Reason, x.Data.ErrorMessage, x.Data.FallbackModel); + return true; + case AssistantFusionPhaseStartedEvent x: + if (IsFusionReviewScope(x.Data.ConversationScope)) StartFusionPhase(x.Data); + return true; + case AssistantFusionPhaseCompletedEvent x: + CompleteFusionPhase(x.Data); + return true; + case AssistantFusionPhaseFailedEvent x: + FailFusionPhase(x.Data); + return true; + case SessionFusionCompletedEvent x: + ContainerLocator.Container.Resolve().LogInformation( + "Copilot Fusion finished: {Outcome}, {Phases} phases, {Requests} requests in {Duration:0} ms{Degraded}", + x.Data.Outcome, x.Data.PhaseCount, x.Data.RequestCount, x.Data.DurationMs, + string.IsNullOrWhiteSpace(x.Data.DegradedReason) ? "" : $" (degraded: {x.Data.DegradedReason})"); + CloseFusionPhases(x.Data.FusionId); + return true; + case AssistantFusionPhaseActivityEvent or SessionFusionRouteStartedEvent: + return true; + default: + return false; + } + } + + private void StartFusionPhase(AssistantFusionPhaseStartedData data) + { + if (string.IsNullOrWhiteSpace(data.PhaseId)) return; + + var blockId = FusionBlockPrefix + data.PhaseId; + string? hint; + lock (_fusionPhases) + { + if (_fusionPhases.ContainsKey(data.PhaseId)) return; + _fusionPhases[data.PhaseId] = new FusionReviewPhase(blockId, data.FusionId); + _fusionHints.TryGetValue(data.FusionId ?? "", out hint); + } + + EventReceived?.Invoke(this, new ChatSubAgentStartedEvent(blockId, $"Fusion {DescribeFusionPhase(data.PhaseKind)}") + { + Description = FirstNonEmpty(data.Role, hint), + Model = DescribeModel(data.Model) + }); + } + + private void CompleteFusionPhase(AssistantFusionPhaseCompletedData data) + { + var phase = RemoveFusionPhase(data.PhaseId); + if (phase == null) return; + + // Review output is often only reported here, not as messages of its own. + var text = phase.HasContent ? null : data.Content?.Trim(); + if (!string.IsNullOrWhiteSpace(data.Verdict)) + text = string.IsNullOrWhiteSpace(text) + ? $"**Verdict:** {data.Verdict}" + : $"**Verdict:** {data.Verdict}\n\n{text}"; + + if (!string.IsNullOrWhiteSpace(text)) + EventReceived?.Invoke(this, new ChatMessageEvent(text) { AgentId = phase.BlockId, Model = DescribeModel(data.Model) }); + + var status = data.Status.Value; + EventReceived?.Invoke(this, new ChatSubAgentCompletedEvent(phase.BlockId, + !string.Equals(status, FusionPhaseStatus.Failed.Value, StringComparison.OrdinalIgnoreCase)) + { + Cancelled = string.Equals(status, FusionPhaseStatus.Cancelled.Value, StringComparison.OrdinalIgnoreCase), + Duration = TimeSpan.FromMilliseconds(data.DurationMs), + TotalTokens = data.Usage is { } usage ? usage.InputTokens + usage.OutputTokens : null + }); + } + + private void FailFusionPhase(AssistantFusionPhaseFailedData data) + { + ContainerLocator.Container.Resolve().LogWarning( + "Copilot Fusion phase {Phase} ({Model}) failed ({Reason}): {Error}", data.PhaseKind.Value, data.Model, + data.Reason, data.ErrorMessage); + + var phase = RemoveFusionPhase(data.PhaseId); + if (phase == null) return; + + EventReceived?.Invoke(this, new ChatSubAgentCompletedEvent(phase.BlockId, false) + { + Error = FirstNonEmpty(data.ErrorMessage, data.Reason), + Cancelled = string.Equals(data.Status.Value, FusionPhaseStatus.Cancelled.Value, + StringComparison.OrdinalIgnoreCase), + Duration = TimeSpan.FromMilliseconds(data.DurationMs) + }); + } + + private FusionReviewPhase? RemoveFusionPhase(string? phaseId) + { + if (string.IsNullOrWhiteSpace(phaseId)) return null; + + lock (_fusionPhases) + return _fusionPhases.Remove(phaseId, out var phase) ? phase : null; + } + + /// + /// Closes review blocks that never reported an end, for the turn or, when + /// null, for every turn (session idle). + /// + private void CloseFusionPhases(string? fusionId) + { + List open; + lock (_fusionPhases) + { + open = _fusionPhases.Values + .Where(x => fusionId == null || string.Equals(x.FusionId, fusionId, StringComparison.Ordinal)) + .ToList(); + foreach (var phase in _fusionPhases.Where(x => open.Contains(x.Value)).Select(x => x.Key).ToArray()) + _fusionPhases.Remove(phase); + + if (fusionId == null) _fusionHints.Clear(); + else _fusionHints.Remove(fusionId); + } + + foreach (var phase in open) + EventReceived?.Invoke(this, new ChatSubAgentCompletedEvent(phase.BlockId, true)); + } + + /// + /// The block of the review phase belongs to, or null when it belongs to the main + /// flow. Messages and tools name their phase; streaming deltas don't, so they go to the review phase when exactly + /// one is running. + /// + private string? ResolveFusionAgentId(SessionEvent evt) + { + lock (_fusionPhases) + { + if (_fusionPhases.Count == 0) return null; + + var attribution = evt switch + { + AssistantMessageEvent x => x.Data.Fusion, + ToolExecutionStartEvent x => x.Data.Fusion, + ToolExecutionCompleteEvent x => x.Data.Fusion, + _ => null + }; + + FusionReviewPhase? phase; + if (attribution != null) + { + if (attribution.PhaseId == null || !_fusionPhases.TryGetValue(attribution.PhaseId, out phase)) + return null; + } + else if (evt is AssistantMessageDeltaEvent or AssistantReasoningDeltaEvent or AssistantReasoningEvent + && _fusionPhases.Count == 1) + { + phase = _fusionPhases.Values.First(); + } + else + { + return null; + } + + if (evt is AssistantMessageEvent or AssistantMessageDeltaEvent) phase.HasContent = true; + return phase.BlockId; + } + } + + private static string DescribeFusionPhase(FusionPhaseKind kind) + { + var value = kind.Value; + if (string.IsNullOrWhiteSpace(value)) return "Review"; + + var words = value.Replace('_', ' ').Replace('-', ' ').Trim(); + return char.ToUpperInvariant(words[0]) + words[1..]; + } +} diff --git a/src/OneWare.Copilot/Services/CopilotChatService.Images.cs b/src/OneWare.Copilot/Services/CopilotChatService.Images.cs new file mode 100644 index 00000000..bbadec40 --- /dev/null +++ b/src/OneWare.Copilot/Services/CopilotChatService.Images.cs @@ -0,0 +1,172 @@ +using GitHub.Copilot; +using OneWare.Essentials.Models; + +namespace OneWare.Copilot.Services; + +// Images returned by tools (MCP screenshots, image files the agent read, …) are shown in the chat. +// Large or repeated images are not inlined in the tool result but persisted once as a session.binary_asset +// event and referenced by id, so those assets are cached until the result that references them arrives. +public abstract partial class CopilotChatServiceBase +{ + /// Images larger than this are not shown, which keeps the chat history folder small. + internal const int MaxChatImageBytes = 20 * 1024 * 1024; + + private const int MaxCachedImageAssets = 16; + + private readonly Lock _imageLock = new(); + private readonly Dictionary _imageAssets = new(StringComparer.Ordinal); + private readonly Queue _imageAssetOrder = new(); + private readonly Dictionary _imageToolNames = new(StringComparer.Ordinal); + private readonly List _pendingToolImages = []; + + private sealed record PendingToolImage(string AssetId, string? Description, string ToolCallId, string? AgentId, + string? ToolName); + + internal readonly record struct ToolImage(string Base64, string MimeType, string? Description, string? AssetId); + + private void RememberToolName(string? toolCallId, string? toolName) + { + if (string.IsNullOrEmpty(toolCallId) || string.IsNullOrEmpty(toolName)) return; + lock (_imageLock) _imageToolNames[toolCallId] = toolName; + } + + private void HandleBinaryAsset(SessionBinaryAssetData asset) + { + if (string.IsNullOrEmpty(asset.AssetId) || !IsImage(asset.MimeType) || string.IsNullOrEmpty(asset.Data)) + return; + + List ready; + lock (_imageLock) + { + if (_imageAssets.TryAdd(asset.AssetId, asset)) + { + _imageAssetOrder.Enqueue(asset.AssetId); + while (_imageAssetOrder.Count > MaxCachedImageAssets) + _imageAssets.Remove(_imageAssetOrder.Dequeue()); + } + + ready = _pendingToolImages.Where(x => x.AssetId == asset.AssetId).ToList(); + _pendingToolImages.RemoveAll(x => x.AssetId == asset.AssetId); + } + + foreach (var pending in ready) + EmitToolImage(new ToolImage(asset.Data, asset.MimeType, pending.Description ?? asset.Description, + asset.AssetId), pending.ToolCallId, pending.AgentId, pending.ToolName); + } + + private void HandleToolImages(ToolExecutionCompleteData data, string? agentId) + { + string? toolName; + lock (_imageLock) + _imageToolNames.Remove(data.ToolCallId ?? string.Empty, out toolName); + + if (data.Result == null) return; + + var images = ExtractToolImages(data.Result, ResolveImageAsset, out var missingAssets); + foreach (var image in images) + EmitToolImage(image, data.ToolCallId, agentId, toolName); + + if (missingAssets.Count == 0) return; + + // The asset event may follow the result; show the image when it arrives. + lock (_imageLock) + { + foreach (var reference in missingAssets) + _pendingToolImages.Add(new PendingToolImage(reference.AssetId, reference.Description, + data.ToolCallId ?? string.Empty, agentId, toolName)); + while (_pendingToolImages.Count > MaxCachedImageAssets) + _pendingToolImages.RemoveAt(0); + } + } + + private SessionBinaryAssetData? ResolveImageAsset(string assetId) + { + lock (_imageLock) + return _imageAssets.GetValueOrDefault(assetId); + } + + private void EmitToolImage(ToolImage image, string? toolCallId, string? agentId, string? toolName = null) + { + byte[] bytes; + try + { + bytes = Convert.FromBase64String(image.Base64); + } + catch (FormatException) + { + return; + } + + if (bytes.Length == 0 || bytes.Length > MaxChatImageBytes) return; + + EventReceived?.Invoke(this, new ChatImageEvent(bytes, image.MimeType) + { + AgentId = agentId, + Description = string.IsNullOrWhiteSpace(image.Description) ? null : image.Description, + ToolCallId = toolCallId, + ToolName = toolName + }); + } + + private void ResetImageState() + { + lock (_imageLock) + { + _imageAssets.Clear(); + _imageAssetOrder.Clear(); + _imageToolNames.Clear(); + _pendingToolImages.Clear(); + } + } + + /// + /// The images of a tool result. The structured contents and the model-facing binary results usually carry the + /// same image, so each image is returned once. + /// + internal static List ExtractToolImages(ToolExecutionCompleteResult result, + Func resolveAsset, out List missingAssets) + { + var images = new List(); + var seen = new HashSet(StringComparer.Ordinal); + missingAssets = []; + + void Add(string? data, string? mimeType, string? description, string? assetId = null) + { + if (string.IsNullOrEmpty(data) || !IsImage(mimeType) || !seen.Add(data)) return; + images.Add(new ToolImage(data, mimeType!, description, assetId)); + } + + foreach (var content in result.Contents ?? []) + if (content is ToolExecutionCompleteContentImage image) + Add(image.Data, image.MimeType, null); + + var imageBinaries = 0; + foreach (var binary in result.BinaryResultsForLlm ?? []) + { + if (IsImage(binary.PersistedBinaryImage?.MimeType ?? binary.BinaryAssetReference?.MimeType)) + imageBinaries++; + + if (binary.PersistedBinaryImage is { } inline) + { + Add(inline.Data, inline.MimeType, inline.Description); + } + else if (binary.BinaryAssetReference is { } reference && IsImage(reference.MimeType) && + !string.IsNullOrEmpty(reference.AssetId)) + { + if (resolveAsset(reference.AssetId) is { } asset) + Add(asset.Data, asset.MimeType ?? reference.MimeType, + reference.Description ?? asset.Description, reference.AssetId); + else if (missingAssets.All(x => x.AssetId != reference.AssetId)) + missingAssets.Add(reference); + } + } + + // The contents already carried every image, so the referenced assets are duplicates. + if (images.Count >= imageBinaries) missingAssets.Clear(); + + return images; + } + + internal static bool IsImage(string? mimeType) => + mimeType != null && mimeType.StartsWith("image/", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/OneWare.Copilot/Services/CopilotChatService.Mcp.cs b/src/OneWare.Copilot/Services/CopilotChatService.Mcp.cs new file mode 100644 index 00000000..bab9715d --- /dev/null +++ b/src/OneWare.Copilot/Services/CopilotChatService.Mcp.cs @@ -0,0 +1,641 @@ +using System.Collections.Concurrent; +using System.Collections.ObjectModel; +using System.IO; +using Avalonia.Controls; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GitHub.Copilot; +using GitHub.Copilot.Rpc; +using Microsoft.Extensions.Logging; +using OneWare.Copilot.Connectors; +using OneWare.Copilot.ViewModels; +using OneWare.Essentials.Models; +using OneWare.Essentials.Services; + +namespace OneWare.Copilot.Services; + +/// +/// MCP servers of a chat: the connectors (IDE-wide servers from OneWare's mcp.json) are given to the session +/// as its own servers and kept in sync by diffing, servers of the project's configuration are started by the runtime +/// once the user trusts the folder, and the Copilot CLI's user servers are left out (they can be imported instead). +/// Problems are shown as a badge in the chat footer instead of chat messages. +/// +public abstract partial class CopilotChatServiceBase +{ + // Project files that define MCP servers the runtime loads for trusted folders. + private static readonly string[] ProjectMcpFiles = [".mcp.json", Path.Combine(".vscode", "mcp.json")]; + + // Servers turned off per chat, by session id, for the app run. + private static readonly ConcurrentDictionary> ChatDisabledMcpServers = new(); + + // Folders whose MCP servers the user chose not to start, for the app run. + private static readonly ConcurrentDictionary DeclinedTrustFolders = + new(CopilotSessionConfiguration.PathComparer); + + // Connectors running in the session, with the fingerprint of the configuration they were started with. + private readonly Dictionary _appliedConnectors = new(StringComparer.Ordinal); + private readonly SemaphoreSlim _mcpGate = new(1, 1); + private McpConnectorHub? _observedHub; + private CancellationTokenSource? _mcpRefreshCts; + private int _mcpProblemCount; + private IRelayCommand? _manageMcpServersCommand; + private IAsyncRelayCommand? _refreshMcpServersCommand; + + /// MCP servers of this chat, for the footer. + public ObservableCollection McpServers { get; } = []; + + public bool HasMcpServers => McpServers.Count > 0; + + public int McpProblemCount + { + get => _mcpProblemCount; + private set + { + if (!SetProperty(ref _mcpProblemCount, value)) return; + OnPropertyChanged(nameof(HasMcpProblems)); + OnPropertyChanged(nameof(McpSummary)); + } + } + + public bool HasMcpProblems => McpProblemCount > 0; + + public string McpSummary + { + get + { + var connected = McpServers.Count(x => x.State == McpConnectorState.Connected); + var text = $"MCP servers: {connected} of {McpServers.Count} connected"; + return HasMcpProblems ? $"{text}, {McpProblemCount} need attention" : text; + } + } + + public IRelayCommand ManageMcpServersCommand => + _manageMcpServersCommand ??= new RelayCommand(owner => + CopilotConnectorsSettingViewModel.ShowWindow(Hub, owner)); + + /// Refreshes the list including tool counts, e.g. when the footer flyout opens. + public IAsyncRelayCommand RefreshMcpServersCommand => + _refreshMcpServersCommand ??= new AsyncRelayCommand(() => RefreshMcpServersAsync(includeTools: true)); + + private static McpConnectorHub Hub => ContainerLocator.Container.Resolve(); + + private HashSet GetChatDisabledMcpServers() => + CurrentSessionId is { } id ? ChatDisabledMcpServers.GetOrAdd(id, _ => new HashSet(StringComparer.Ordinal)) : []; + + private static HashSet GetChatDisabledMcpServers(string? sessionId) => + sessionId is { } id && ChatDisabledMcpServers.TryGetValue(id, out var names) ? names : []; + + private void SubscribeToConnectors() + { + if (_observedHub != null) return; + _observedHub = Hub; + _observedHub.Changed += OnConnectorsChanged; + _observedHub.SignedIn += OnConnectorSignedIn; + } + + private void UnsubscribeFromConnectors() + { + if (_observedHub == null) return; + _observedHub.Changed -= OnConnectorsChanged; + _observedHub.SignedIn -= OnConnectorSignedIn; + _observedHub = null; + } + + private void ResetMcpState() + { + Interlocked.Exchange(ref _mcpRefreshCts, null)?.Cancel(); + lock (_appliedConnectors) _appliedConnectors.Clear(); + Dispatcher.UIThread.Post(() => + { + McpServers.Clear(); + UpdateMcpSummary(); + }); + } + + /// + /// Configures the MCP servers of a new or resumed session: connectors that the session configuration can express + /// are passed directly, the Copilot CLI's user servers and servers turned off in this chat are disabled. + /// + private async Task ConfigureMcpAsync(SessionConfigBase config, string? sessionId) + { + IReadOnlyList connectors = []; + IReadOnlyList cliServers = []; + try + { + connectors = await Hub.ResolveEnabledAsync(); + cliServers = await Hub.GetCopilotCliServerNamesAsync(); + } + catch (Exception e) + { + Logger.LogWarning(e, "Failed to read the MCP servers for the Copilot session."); + } + + var chatDisabled = GetChatDisabledMcpServers(sessionId); + var connectorNames = connectors.Select(x => x.Name).ToHashSet(StringComparer.Ordinal); + + var disabled = cliServers.Where(x => !connectorNames.Contains(x)) + .Concat(chatDisabled.Where(x => !connectorNames.Contains(x))) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (disabled.Count > 0) config.DisabledMcpServers = disabled; + + var servers = new Dictionary(StringComparer.Ordinal); + lock (_appliedConnectors) + { + _appliedConnectors.Clear(); + foreach (var connector in connectors.Where(x => !chatDisabled.Contains(x.Name))) + { + if (connector.ServerConfig is not { } serverConfig) continue; + servers[connector.Name] = serverConfig; + _appliedConnectors[connector.Name] = connector.Fingerprint; + } + } + + if (servers.Count > 0) config.McpServers = servers; + } + + /// Runs once the session exists: starts what the configuration couldn't and checks project servers. + private void OnMcpSessionStarted(CopilotSession session, string workingDirectory) + { + _ = Task.Run(async () => + { + await ApplyConnectorsAsync(session); + await RefreshMcpServersAsync(includeTools: false, disableCliServers: true); + await OfferProjectTrustAsync(session, workingDirectory); + }); + } + + private void OnConnectorsChanged(object? sender, EventArgs e) + { + if (_session is not { } session) return; + _ = Task.Run(async () => + { + await ApplyConnectorsAsync(session); + await RefreshMcpServersAsync(includeTools: false); + }); + } + + private void OnConnectorSignedIn(object? sender, string serverName) + { + if (_session is not { } session) return; + _ = Task.Run(async () => + { + try + { + // The tokens may have been stored by another runtime process; this one picks them up on reconnecting. + await session.Rpc.Mcp.Oauth.AuthenticationStateChangedAsync(serverName); + bool isConnector; + lock (_appliedConnectors) isConnector = _appliedConnectors.ContainsKey(serverName); + if (isConnector) await ApplyConnectorsAsync(session, restart: serverName); + else await session.Rpc.Mcp.RestartServerAsync(serverName, null); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Failed to reconnect MCP server {Name} after signing in.", serverName); + } + + await RefreshMcpServersAsync(includeTools: false); + }); + } + + /// + /// Brings the session's connectors in line with the enabled ones: removed connectors are stopped, new ones + /// started and changed ones restarted. restarts one (or "*" all) regardless. + /// + private async Task ApplyConnectorsAsync(CopilotSession session, string? restart = null) + { + try + { + await _mcpGate.WaitAsync(); + try + { + if (!ReferenceEquals(_session, session)) return; + + var chatDisabled = GetChatDisabledMcpServers(); + var desired = (await Hub.ResolveEnabledAsync()) + .Where(x => !chatDisabled.Contains(x.Name)) + .ToDictionary(x => x.Name, StringComparer.Ordinal); + + Dictionary applied; + lock (_appliedConnectors) applied = new Dictionary(_appliedConnectors); + + foreach (var name in applied.Keys.Where(x => !desired.ContainsKey(x))) + { + await TryMcpAsync(() => session.Rpc.Mcp.StopServerAsync(name), "stop", name); + applied.Remove(name); + } + + foreach (var (name, connector) in desired) + { + var running = applied.TryGetValue(name, out var fingerprint); + if (running && fingerprint == connector.Fingerprint && restart != "*" && restart != name) continue; + + if (running) await RestartConnectorAsync(session, connector); + else await StartConnectorAsync(session, connector); + applied[name] = connector.Fingerprint; + } + + lock (_appliedConnectors) + { + _appliedConnectors.Clear(); + foreach (var (name, fingerprint) in applied) _appliedConnectors[name] = fingerprint; + } + } + finally + { + _mcpGate.Release(); + } + } + catch (Exception e) when (e is OperationCanceledException or ObjectDisposedException) + { + // The session was closed meanwhile. + } + catch (Exception e) + { + Logger.LogWarning(e, "Failed to apply the MCP servers to the Copilot session."); + } + } + + private static async Task StartConnectorAsync(CopilotSession session, ResolvedMcpConnector connector) + { + try + { + await session.Rpc.Mcp.StartServerAsync(connector.Name, connector.Config); + } + catch (Exception e) when (e is not (OperationCanceledException or ObjectDisposedException)) + { + // E.g. the session configuration already started it. + Logger.LogDebug(e, "Starting MCP server {Name} failed, restarting it.", connector.Name); + await RestartConnectorAsync(session, connector); + } + } + + private static Task RestartConnectorAsync(CopilotSession session, ResolvedMcpConnector connector) => + TryMcpAsync(() => session.Rpc.Mcp.RestartServerAsync(connector.Name, connector.Config), "restart", + connector.Name); + + private static async Task TryMcpAsync(Func action, string operation, string name) + { + try + { + await action(); + } + catch (Exception e) when (e is not (OperationCanceledException or ObjectDisposedException)) + { + // The status shows the problem. + Logger.LogDebug(e, "Failed to {Operation} MCP server {Name}.", operation, name); + } + } + + private void HandleMcpServerStatusChanged(SessionMcpServerStatusChangedData data) + { + var status = McpConnectorHub.ToStatus(data.Status, data.Error); + if (data.Status == McpServerStatus.Failed) + Logger.LogWarning("MCP server {Name} failed: {Error}", data.ServerName, data.Error); + + bool isConnector; + lock (_appliedConnectors) isConnector = _appliedConnectors.ContainsKey(data.ServerName); + if (isConnector) Hub.ReportStatus(data.ServerName, status); + + Dispatcher.UIThread.Post(() => + { + if (McpServers.FirstOrDefault(x => x.Name == data.ServerName) is { } item) + { + item.Update(data.Status, data.Error); + UpdateMcpSummary(); + } + else + { + ScheduleMcpRefresh(); + } + }); + } + + private void ScheduleMcpRefresh() + { + var cts = new CancellationTokenSource(); + Interlocked.Exchange(ref _mcpRefreshCts, cts)?.Cancel(); + _ = Task.Delay(TimeSpan.FromMilliseconds(300), cts.Token).ContinueWith( + _ => RefreshMcpServersAsync(includeTools: false), TaskContinuationOptions.OnlyOnRanToCompletion); + } + + /// Rebuilds from the session. + private async Task RefreshMcpServersAsync(bool includeTools, bool disableCliServers = false) + { + if (_session is not { } session) return; + try + { + var servers = (await session.Rpc.Mcp.ListAsync()).Servers; + HashSet connectors; + lock (_appliedConnectors) connectors = _appliedConnectors.Keys.ToHashSet(StringComparer.Ordinal); + var configured = (await Hub.GetConnectorsAsync()).Where(x => x.Enabled) + .Select(x => x.Name).ToHashSet(StringComparer.Ordinal); + + // In case the runtime started user servers despite the session configuration. + if (disableCliServers) + foreach (var server in servers.Where(x => x.Source == McpServerSource.User && + !configured.Contains(x.Name) && + x.Status != McpServerStatus.Disabled)) + await TryMcpAsync(() => session.Rpc.Mcp.DisableAsync(server.Name), "disable", server.Name); + + var chatDisabled = GetChatDisabledMcpServers(); + var items = new List(); + foreach (var server in servers) + { + var isConnector = connectors.Contains(server.Name) || configured.Contains(server.Name); + // User servers OneWare doesn't own are not part of the chat. + if (!isConnector && server.Source == McpServerSource.User) continue; + + var item = new CopilotMcpServerViewModel(this, server.Name, server.DisplayName, + isConnector ? "Connector" : DescribeSource(server.Source), isConnector, + !chatDisabled.Contains(server.Name)); + item.Update(server.Status, server.Error); + if (includeTools && server.Status == McpServerStatus.Connected) + { + try + { + item.ToolCount = (await session.Rpc.Mcp.ListToolsAsync(server.Name)).Tools.Count; + } + catch (Exception e) + { + Logger.LogDebug(e, "Failed to list the tools of MCP server {Name}.", server.Name); + } + } + + items.Add(item); + } + + // Connectors turned off in this chat aren't running, so the session doesn't list them. + foreach (var name in configured.Where(x => chatDisabled.Contains(x) && items.All(i => i.Name != x))) + items.Add(new CopilotMcpServerViewModel(this, name, null, "Connector", true, false)); + + if (!ReferenceEquals(_session, session)) return; + await Dispatcher.UIThread.InvokeAsync(() => + { + McpServers.Clear(); + foreach (var item in items.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)) McpServers.Add(item); + UpdateMcpSummary(); + }); + } + catch (Exception e) when (e is OperationCanceledException or ObjectDisposedException) + { + // The session was closed meanwhile. + } + catch (Exception e) + { + Logger.LogDebug(e, "Failed to list the MCP servers of the Copilot session."); + } + } + + private static string DescribeSource(McpServerSource? source) => + source == McpServerSource.Workspace ? "Project" + : source == McpServerSource.Plugin ? "Plugin" + : source == McpServerSource.Builtin ? "Built-in" + : source == McpServerSource.Managed ? "Managed" + : source == McpServerSource.Account ? "Account" + : "Session"; + + private void UpdateMcpSummary() + { + McpProblemCount = McpServers.Count(x => x.HasProblem); + OnPropertyChanged(nameof(HasMcpServers)); + OnPropertyChanged(nameof(McpSummary)); + } + + /// Turns a server on or off in this chat only. + internal async Task SetMcpServerEnabledInChatAsync(CopilotMcpServerViewModel item, bool enabled) + { + if (_session is not { } session) return; + + var chatDisabled = GetChatDisabledMcpServers(); + lock (chatDisabled) + { + if (enabled) chatDisabled.Remove(item.Name); + else chatDisabled.Add(item.Name); + } + + if (item.IsConnector) + await ApplyConnectorsAsync(session); + else if (enabled) + await TryMcpAsync(() => session.Rpc.Mcp.EnableAsync(item.Name), "enable", item.Name); + else + await TryMcpAsync(() => session.Rpc.Mcp.DisableAsync(item.Name), "disable", item.Name); + + await RefreshMcpServersAsync(includeTools: true); + } + + internal async Task SignInToMcpServerAsync(CopilotMcpServerViewModel item) + { + if (_session is not { } session) return; + + // Connectors sign in on a separate runtime with their OAuth app; every chat reconnects afterwards. + var status = item.IsConnector + ? await Hub.SignInAsync(item.Name) + : await Hub.SignInAsync(session, item.Name); + + await Dispatcher.UIThread.InvokeAsync(() => + { + item.Message = status.State == McpConnectorState.Connected ? null : status.Message; + if (status.State == McpConnectorState.Failed) item.SetFailed(status.Message); + UpdateMcpSummary(); + }); + } + + /// + /// The runtime only starts the MCP servers of a project's configuration once the folder is trusted. Asks once + /// per folder and app run whether to trust it. + /// + private async Task OfferProjectTrustAsync(CopilotSession session, string folder) + { + try + { + var file = ProjectMcpFiles.FirstOrDefault(x => File.Exists(Path.Combine(folder, x))); + if (file == null || DeclinedTrustFolders.ContainsKey(folder)) return; + if ((await session.Rpc.Permissions.FolderTrust.IsTrustedAsync(folder)).Trusted) return; + if (!ReferenceEquals(_session, session)) return; + + var message = $"**This project defines MCP servers** (`{file.Replace('\\', '/')}`).\n\n" + + "They run commands or connect to services on your behalf. Start them only if you trust " + + $"`{folder}`."; + EventReceived?.Invoke(this, new ChatPermissionRequestEvent(message, "Trust and Start", "Not Now", + new AsyncRelayCommand(_ => TrustProjectAsync(session, folder)), + new RelayCommand(_ => DeclinedTrustFolders.TryAdd(folder, 0)))); + } + catch (Exception e) when (e is OperationCanceledException or ObjectDisposedException) + { + // The session was closed meanwhile. + } + catch (Exception e) + { + Logger.LogDebug(e, "Failed to check whether {Folder} is trusted.", folder); + } + } + + private async Task TrustProjectAsync(CopilotSession session, string folder) + { + try + { + await session.Rpc.Permissions.FolderTrust.AddTrustedAsync(folder); + await session.Rpc.Mcp.ReloadAsync(); + + // Reloading reads the configuration again, so servers this chat leaves out are disabled again and the + // connectors restarted in case the reload replaced them. + var configured = (await Hub.GetConnectorsAsync()).Select(x => x.Name).ToHashSet(StringComparer.Ordinal); + var leaveOut = (await Hub.GetCopilotCliServerNamesAsync()).Where(x => !configured.Contains(x)) + .Concat(GetChatDisabledMcpServers().Where(x => !configured.Contains(x))); + foreach (var name in leaveOut.Distinct(StringComparer.Ordinal)) + await TryMcpAsync(() => session.Rpc.Mcp.DisableAsync(name), "disable", name); + + await ApplyConnectorsAsync(session, restart: "*"); + await RefreshMcpServersAsync(includeTools: false); + } + catch (Exception e) when (e is not (OperationCanceledException or ObjectDisposedException)) + { + Logger.LogWarning(e, "Failed to start the MCP servers of {Folder}.", folder); + EventReceived?.Invoke(this, new ChatErrorEvent( + $"Failed to start the project's MCP servers: {McpConnectorHub.StripRpcPrefix(e.Message)}")); + } + } +} + +/// An MCP server of a chat, shown in the footer flyout. +public sealed class CopilotMcpServerViewModel : ObservableObject +{ + private readonly CopilotChatServiceBase _chat; + private bool _enabledInChat; + private McpConnectorState _state; + private string? _message; + private int? _toolCount; + private bool _isBusy; + + internal CopilotMcpServerViewModel(CopilotChatServiceBase chat, string name, string? displayName, string source, + bool isConnector, bool enabledInChat) + { + _chat = chat; + Name = name; + DisplayName = string.IsNullOrWhiteSpace(displayName) ? name : displayName; + Source = source; + IsConnector = isConnector; + _enabledInChat = enabledInChat; + SignInCommand = new AsyncRelayCommand(SignInAsync); + } + + public string Name { get; } + + public string DisplayName { get; } + + public string Source { get; } + + public bool IsConnector { get; } + + public bool EnabledInChat + { + get => _enabledInChat; + set + { + if (!SetProperty(ref _enabledInChat, value)) return; + _ = RunAsync(() => _chat.SetMcpServerEnabledInChatAsync(this, value)); + } + } + + public McpConnectorState State + { + get => _state; + private set + { + if (!SetProperty(ref _state, value)) return; + OnPropertyChanged(nameof(StatusText)); + OnPropertyChanged(nameof(NeedsSignIn)); + OnPropertyChanged(nameof(HasProblem)); + OnPropertyChanged(nameof(IsConnected)); + OnPropertyChanged(nameof(IsFailed)); + } + } + + public string? Message + { + get => _message; + set + { + if (SetProperty(ref _message, value)) OnPropertyChanged(nameof(HasMessage)); + } + } + + public bool HasMessage => !string.IsNullOrWhiteSpace(Message); + + public int? ToolCount + { + get => _toolCount; + set + { + if (SetProperty(ref _toolCount, value)) OnPropertyChanged(nameof(StatusText)); + } + } + + public bool IsBusy + { + get => _isBusy; + private set => SetProperty(ref _isBusy, value); + } + + public bool IsConnected => State == McpConnectorState.Connected; + + public bool IsFailed => State == McpConnectorState.Failed; + + public bool NeedsSignIn => State == McpConnectorState.NeedsSignIn && EnabledInChat; + + public bool HasProblem => EnabledInChat && State is McpConnectorState.Failed or McpConnectorState.NeedsSignIn; + + public string StatusText => !EnabledInChat + ? $"{Source} · Off in this chat" + : State switch + { + McpConnectorState.Connected => ToolCount is { } count + ? $"{Source} · {count} tool{(count == 1 ? "" : "s")}" + : $"{Source} · Connected", + McpConnectorState.Pending => $"{Source} · Starting…", + McpConnectorState.NeedsSignIn => $"{Source} · Sign-in required", + McpConnectorState.Failed => $"{Source} · Failed", + _ => $"{Source} · Stopped" + }; + + public IAsyncRelayCommand SignInCommand { get; } + + internal void Update(McpServerStatus status, string? error) + { + var converted = McpConnectorHub.ToStatus(status, error); + // Disabled servers have the toggle off; they are not a problem. + if (status == McpServerStatus.Disabled) _enabledInChat = false; + State = converted.State; + Message = converted.State == McpConnectorState.Failed ? converted.Message : null; + OnPropertyChanged(nameof(EnabledInChat)); + OnPropertyChanged(nameof(StatusText)); + OnPropertyChanged(nameof(NeedsSignIn)); + OnPropertyChanged(nameof(HasProblem)); + } + + internal void SetFailed(string? message) + { + State = McpConnectorState.Failed; + Message = McpConnectorHub.DescribeSignInError(message ?? "Unknown error"); + } + + private Task SignInAsync() => RunAsync(() => _chat.SignInToMcpServerAsync(this)); + + private async Task RunAsync(Func action) + { + IsBusy = true; + try + { + await action(); + } + finally + { + IsBusy = false; + OnPropertyChanged(nameof(StatusText)); + OnPropertyChanged(nameof(HasProblem)); + OnPropertyChanged(nameof(NeedsSignIn)); + } + } +} diff --git a/src/OneWare.Copilot/Services/CopilotChatService.Workspace.cs b/src/OneWare.Copilot/Services/CopilotChatService.Workspace.cs new file mode 100644 index 00000000..4c2df763 --- /dev/null +++ b/src/OneWare.Copilot/Services/CopilotChatService.Workspace.cs @@ -0,0 +1,163 @@ +using System.Collections.Specialized; +using System.IO; +using Avalonia.Threading; +using GitHub.Copilot; +using Microsoft.Extensions.Logging; +using OneWare.Essentials.Services; + +namespace OneWare.Copilot.Services; + +/// +/// Keeps a chat in sync with the IDE: the skills and instruction files of every open project (the runtime only +/// discovers its working directory). MCP servers are handled in CopilotChatService.Mcp.cs. +/// +public abstract partial class CopilotChatServiceBase +{ + private string? _sessionWorkingDirectory; + private string? _announcedWorkspaceKey; + private CancellationTokenSource? _workspaceCts; + private CancellationTokenSource? _projectsRefreshCts; + private readonly SemaphoreSlim _workspaceGate = new(1, 1); + private INotifyCollectionChanged? _observedProjects; + + private static ILogger Logger => ContainerLocator.Container.Resolve(); + + private static async Task> GetOpenProjectRootsAsync() + { + var explorer = ContainerLocator.Container.Resolve(); + + List Snapshot() + { + var roots = explorer.Projects.Select(x => x.RootFolderPath) + .Where(x => !string.IsNullOrWhiteSpace(x) && Directory.Exists(x)) + .Select(Path.GetFullPath) + .Distinct(CopilotSessionConfiguration.PathComparer) + .ToList(); + + // The active project goes first, matching the session's working directory. + if (explorer.ActiveProject?.RootFolderPath is { } active && + roots.FindIndex(x => CopilotSessionConfiguration.PathComparer.Equals(x, Path.GetFullPath(active))) + is > 0 and var index) + { + var root = roots[index]; + roots.RemoveAt(index); + roots.Insert(0, root); + } + + return roots; + } + + return Dispatcher.UIThread.CheckAccess() ? Snapshot() : await Dispatcher.UIThread.InvokeAsync(Snapshot); + } + + private void SubscribeToProjectChanges() + { + if (_observedProjects != null) return; + + _observedProjects = ContainerLocator.Container.Resolve().Projects; + _observedProjects.CollectionChanged += OnProjectsChanged; + SubscribeToConnectors(); + } + + private void UnsubscribeFromProjectChanges() + { + if (_observedProjects != null) _observedProjects.CollectionChanged -= OnProjectsChanged; + _observedProjects = null; + UnsubscribeFromConnectors(); + Interlocked.Exchange(ref _projectsRefreshCts, null)?.Cancel(); + } + + private void OnProjectsChanged(object? sender, NotifyCollectionChangedEventArgs e) => + ScheduleWorkspaceSync(updateSkills: true); + + private void ScheduleWorkspaceSync(bool updateSkills) + { + var session = _session; + if (session == null) return; + + // Opening a folder with several projects or editing a list raises a burst of changes; they are applied + // together. + var cts = new CancellationTokenSource(); + Interlocked.Exchange(ref _projectsRefreshCts, cts)?.Cancel(); + _ = Task.Delay(TimeSpan.FromMilliseconds(500), cts.Token).ContinueWith(t => + { + if (!t.IsCanceled) _ = StartWorkspaceSync(session, updateSkills); + }, TaskScheduler.Default); + } + + private Task StartWorkspaceSync(CopilotSession session, bool updateSkills) + { + var token = (_workspaceCts ??= new CancellationTokenSource()).Token; + return SyncWorkspaceAsync(session, updateSkills, token); + } + + private void ResetWorkspaceState() + { + Interlocked.Exchange(ref _workspaceCts, null)?.Cancel(); + Interlocked.Exchange(ref _projectsRefreshCts, null)?.Cancel(); + ResetMcpState(); + _announcedWorkspaceKey = null; + _sessionWorkingDirectory = null; + } + + private async Task SyncWorkspaceAsync(CopilotSession session, bool updateSkills, CancellationToken token) + { + try + { + await _workspaceGate.WaitAsync(token); + try + { + if (!ReferenceEquals(_session, session) || _sessionWorkingDirectory is not { } workingDirectory) + return; + + if (updateSkills) + { + var roots = await GetOpenProjectRootsAsync(); + await session.Rpc.Options.UpdateAsync( + skillDirectories: BuildSessionSkillDirectories(workingDirectory, roots), + cancellationToken: token); + await session.Rpc.Skills.ReloadAsync(token); + } + } + finally + { + _workspaceGate.Release(); + } + } + catch (Exception e) when (e is OperationCanceledException or ObjectDisposedException) + { + // The session was closed or replaced meanwhile. + } + catch (Exception e) + { + Logger.LogWarning(e, "Failed to apply the open projects to the Copilot session."); + } + } + + /// + /// Prepends the open projects and their instruction files whenever they differ from what this session was + /// last told, so instructions of projects besides the working directory apply without bloating every turn. + /// + private async Task ApplyWorkspaceContextAsync(string prompt) + { + if (_sessionWorkingDirectory is not { } workingDirectory) return prompt; + + try + { + var roots = await GetOpenProjectRootsAsync(); + string? key = null; + var text = await Task.Run(() => + CopilotSessionConfiguration.BuildWorkspaceContext(roots, workingDirectory, out key)); + + if (string.Equals(key, _announcedWorkspaceKey, StringComparison.Ordinal)) return prompt; + _announcedWorkspaceKey = key; + + return text == null ? prompt : $"{text}\n\n{prompt}"; + } + catch (Exception e) + { + Logger.LogWarning(e, "Failed to describe the open projects to the Copilot session."); + return prompt; + } + } +} diff --git a/src/OneWare.Copilot/Services/CopilotChatService.cs b/src/OneWare.Copilot/Services/CopilotChatService.cs index 47709fef..8dd4a1ec 100644 --- a/src/OneWare.Copilot/Services/CopilotChatService.cs +++ b/src/OneWare.Copilot/Services/CopilotChatService.cs @@ -20,6 +20,7 @@ using GitHub.Copilot.Rpc; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; +using OneWare.Copilot.Connectors; using OneWare.Copilot.ViewModels; using OneWare.Copilot.Views; using OneWare.Essentials.Enums; @@ -39,7 +40,7 @@ public sealed record ContextSizeOption(string Label, string Tier, long Tokens) public override string ToString() => Label; } -public abstract class CopilotChatServiceBase( +public abstract partial class CopilotChatServiceBase( ISettingsService settingsService, IAiFunctionProvider toolProvider, IPackageService packageService, @@ -285,6 +286,7 @@ private void MirrorConnectionStateFrom(CopilotChatServiceBase owner) Models.Clear(); Models.AddRange(owner.Models.ToArray()); RefreshFilteredModels(); + SetAutoTiers(owner.AutoTiers.ToArray()); if (SelectedModel == null) ApplyModelSelection(ResolveDefaultSelectedModel(), null, null, null, writeSettings: false, @@ -454,7 +456,7 @@ private void ApplyModelSelection( SelectedContextSize = ContextSizes.FirstOrDefault(x => x.Tier == contextTier) ?? SelectedContextSize; - if (!string.IsNullOrWhiteSpace(autoTier) && AutoTiers.Contains(autoTier)) + if (IsAutoTierOffered(autoTier)) SelectedAutoTier = autoTier; } finally @@ -681,10 +683,70 @@ private void ApplyContextTierToSession() /// /// Routing preferences offered for the auto model. hands the - /// choice back to the provider. + /// choice back to the provider. Replaced by the tiers the runtime reports for the account, which + /// can include HydraFusion. /// - public ObservableCollection AutoTiers { get; } = - [AutoTierDefault, AutoTierFast, AutoTierEfficiency, AutoTierBalance, AutoTierIntelligence]; + public ObservableCollection AutoTiers { get; } = new(CopilotAutoTiers.Fallback); + + private bool IsAutoTierOffered(string? label) => + !string.IsNullOrWhiteSpace(label) && AutoTiers.Any(x => string.Equals(x.Label, label, StringComparison.Ordinal)); + + /// + /// Replaces the offered tiers and keeps the selection if it is still offered, otherwise falls + /// back to the persisted preference or . + /// + private void SetAutoTiers(IReadOnlyList tiers) + { + if (AutoTiers.Select(x => (x.Label, x.Id)).SequenceEqual(tiers.Select(x => (x.Label, x.Id)))) return; + + var selected = SelectedAutoTier; + _suppressAutoTierApply = true; + try + { + AutoTiers.Clear(); + foreach (var tier in tiers) AutoTiers.Add(tier); + + var persisted = settingsService.GetSettingValue(CopilotModule.CopilotAutoTierSettingKey); + SelectedAutoTier = IsAutoTierOffered(selected) ? selected + : IsAutoTierOffered(persisted) ? persisted! + : AutoTierDefault; + } + finally + { + _suppressAutoTierApply = false; + } + } + + /// + /// Asks the runtime which Auto tiers the account can use. They are only reported when the runtime + /// runs with DYNAMIC_AUTO_TIERS; without them the built-in tiers stay. + /// + private async Task> LoadAutoTiersAsync(CopilotClient client) + { + if (_byokConfiguration != null) return CopilotAutoTiers.Fallback; + + try + { + var list = await client.Rpc.Models.ListAsync(null, null); + return CopilotAutoTiers.Build(list.Auto); + } + catch (Exception ex) + { + ContainerLocator.Container.Resolve() + .LogWarning(ex, "Failed to load the Copilot auto routing tiers."); + return CopilotAutoTiers.Fallback; + } + } + + /// Offers the discovered tiers in the global Auto Routing setting as well. + private void PublishAutoTierSetting() + { + if (settingsService.GetSetting(CopilotModule.CopilotAutoTierSettingKey) is not ComboBoxSetting setting) return; + + var labels = AutoTiers.Select(x => (object)x.Label).ToArray(); + if (setting.Options.SequenceEqual(labels)) return; + setting.Options = labels; + } /// /// True while the auto model is selected — only then does a routing preference apply. @@ -729,9 +791,7 @@ private void RefreshAutoTier(ModelInfo model) _suppressAutoTierApply = true; try { - SelectedAutoTier = !string.IsNullOrWhiteSpace(persisted) && AutoTiers.Contains(persisted) - ? persisted - : AutoTierDefault; + SelectedAutoTier = IsAutoTierOffered(persisted) ? persisted : AutoTierDefault; } finally { @@ -739,24 +799,10 @@ private void RefreshAutoTier(ModelInfo model) } } - private AutoTier? ResolveAutoTier() => SelectedAutoTier switch - { - AutoTierFast => AutoTier.Fast, - AutoTierEfficiency => AutoTier.Efficiency, - AutoTierBalance => AutoTier.Balance, - AutoTierIntelligence => AutoTier.Intelligence, - _ => null - }; + private AutoTier? ResolveAutoTier() => + CopilotAutoTiers.ResolveId(AutoTiers, SelectedAutoTier) is { } id ? new AutoTier(id) : null; - private static string DescribeAutoTier(AutoTier? tier) => tier?.Value switch - { - null => AutoTierDefault, - var value when value == AutoTier.Fast.Value => AutoTierFast, - var value when value == AutoTier.Efficiency.Value => AutoTierEfficiency, - var value when value == AutoTier.Balance.Value => AutoTierBalance, - var value when value == AutoTier.Intelligence.Value => AutoTierIntelligence, - var value => value - }; + private string DescribeAutoTier(AutoTier? tier) => CopilotAutoTiers.Describe(AutoTiers, tier?.Value); private void ApplyAutoTierToSession() { @@ -1210,6 +1256,7 @@ private async Task RunSignOutAsync() private bool _attachmentTrackingInitialized; private bool _activeFileDismissed; + private IExtendedDocument? _activeDocument; private IEditor? _trackedEditor; // The active file is only sent when text is selected in it, unless the user switched the chip. @@ -1249,42 +1296,53 @@ private void OnEditorSelectionChanged(object? sender, EventArgs e) => private void RefreshActiveFileAttachment(bool focusChanged) { - var currentDocument = mainDockService.CurrentDocument; - - if (currentDocument is IEditor editor && !ReferenceEquals(currentDocument, _trackedEditor)) + try { - if (_trackedEditor != null) - _trackedEditor.Editor.TextArea.SelectionChanged -= OnEditorSelectionChanged; + var currentDocument = mainDockService.CurrentDocument; - _trackedEditor = editor; + if (!ReferenceEquals(currentDocument, _activeDocument)) + { + _activeDocument = currentDocument; + TrackSelection(currentDocument as IEditor); - if (_trackedEditor != null) - _trackedEditor.Editor.TextArea.SelectionChanged += OnEditorSelectionChanged; + // Moving focus to a different file revives a previously dismissed active-file chip. + if (focusChanged) + { + _activeFileDismissed = false; + _activeFileIncludedOverride = null; + } + } - // Moving focus to a different file revives a previously dismissed active-file chip. - if (focusChanged) + var attachment = _activeFileDismissed ? null : BuildActiveFileAttachment(currentDocument); + var hasSelection = attachment?.Detail != null; + if (hasSelection != _activeFileHadSelection) { - _activeFileDismissed = false; + _activeFileHadSelection = hasSelection; _activeFileIncludedOverride = null; } - } - var attachment = _activeFileDismissed ? null : BuildActiveFileAttachment(currentDocument); - var hasSelection = attachment?.Detail != null; - if (hasSelection != _activeFileHadSelection) - { - _activeFileHadSelection = hasSelection; - _activeFileIncludedOverride = null; - } + if (attachment != null) + { + attachment.IsIncluded = _activeFileIncludedOverride ?? hasSelection; + attachment.OnToggled(a => _activeFileIncludedOverride = a.IsIncluded); + } - if (attachment != null) + var previous = ActiveFileAttachment; + ActiveFileAttachment = attachment; + previous?.Dispose(); + } + catch (Exception e) { - attachment.IsIncluded = _activeFileIncludedOverride ?? hasSelection; - attachment.OnToggled(a => _activeFileIncludedOverride = a.IsIncluded); + ContainerLocator.Container.Resolve().LogWarning(e, "Failed to update the active file attachment."); } + } - ActiveFileAttachment?.Dispose(); - ActiveFileAttachment = attachment; + private void TrackSelection(IEditor? editor) + { + if (ReferenceEquals(editor, _trackedEditor)) return; + if (_trackedEditor != null) _trackedEditor.Editor.TextArea.SelectionChanged -= OnEditorSelectionChanged; + _trackedEditor = editor; + if (_trackedEditor != null) _trackedEditor.Editor.TextArea.SelectionChanged += OnEditorSelectionChanged; } private CopilotAttachmentViewModel? BuildActiveFileAttachment(IExtendedDocument? document) @@ -1600,11 +1658,20 @@ public async Task InitializeAsync() } } + // MCP servers inherit the runtime's environment; the login shell's PATH lets them find npx and co. + var connection = RuntimeConnection.ForStdio(cliPath, []); + var environment = await McpConnectorHub.GetRuntimeEnvironmentAsync(); + // Copilot's service reports the Auto tiers the account can use, including HydraFusion. + connection.Environment = _byokConfiguration == null + ? CopilotSessionConfiguration.WithRuntimeFeatureFlag(environment, + CopilotSessionConfiguration.DynamicAutoTiersFeatureFlag, OperatingSystem.IsWindows()) + : environment; + var clientOptions = new CopilotClientOptions { WorkingDirectory = paths.ProjectsDirectory, ClientInfo = BuildClientInfo(), - Connection = RuntimeConnection.ForStdio(cliPath, []), + Connection = connection, UseLoggedInUser = _byokConfiguration == null }; @@ -1657,6 +1724,7 @@ public async Task InitializeAsync() await _client.StartAsync(); var models = await _client.ListModelsAsync(); + var autoTiers = await LoadAutoTiersAsync(_client); StatusChanged?.Invoke(this, new StatusEvent(true, $"Copilot started")); Blocker = null; @@ -1664,6 +1732,8 @@ public async Task InitializeAsync() Models.Clear(); Models.AddRange(models.ToArray()); RefreshFilteredModels(); + SetAutoTiers(autoTiers); + Dispatcher.UIThread.Post(PublishAutoTierSetting); ApplyModelSelection(ResolveDefaultSelectedModel(), null, null, null, writeSettings: false, applyToSession: false); @@ -2017,10 +2087,14 @@ private async Task InitializeSessionCoreAsync(string? sessionId) // Plugins can register tools at any time, so the cache is rebuilt per session. _clientToolNames = null; + var workingDirectory = ContainerLocator.Container.Resolve() + .ActiveProject?.RootFolderPath ?? paths.ProjectsDirectory; + _sessionWorkingDirectory = workingDirectory; + var skillDirectories = BuildSessionSkillDirectories(workingDirectory, await GetOpenProjectRootsAsync()); if (string.IsNullOrWhiteSpace(sessionId)) { - var tools = toolProvider.GetTools().Cast().ToList(); + var tools = CopilotSessionConfiguration.ApplyToolDeferral(toolProvider.GetTools(), toolProvider.IsFunctionAlwaysLoaded); var sessionConfig = new SessionConfig { Model = SelectedModel.Id, @@ -2033,14 +2107,9 @@ private async Task InitializeSessionCoreAsync(string? sessionId) IncludeSubAgentStreamingEvents = true, SystemMessage = BuildSystemMessageConfig(), Tools = tools, - // Restrict the session to OneWare's own tools plus the session-isolated built-ins - // (task/skill delegation, ask_user, …) that plugin-contributed agents and skills - // need. Host-capable built-ins stay unavailable. - AvailableTools = BuildAvailableTools(), + AvailableTools = CopilotSessionConfiguration.BuildAvailableTools(), ExcludedTools = ExcludedBuiltInTools.ToList(), CustomAgents = BuildCustomAgents(), - SkillDirectories = BuildSkillDirectories(), - EnableSkills = true, ClientName = "OneWare Studio", OnPermissionRequest = OnPermissionRequestAsync, OnUserInputRequest = OnUserInputRequestAsync, @@ -2051,6 +2120,9 @@ private async Task InitializeSessionCoreAsync(string? sessionId) } }; + CopilotSessionConfiguration.ApplyDiscovery(sessionConfig, workingDirectory, skillDirectories); + if (_byokConfiguration == null) CopilotSessionConfiguration.ApplyFusion(sessionConfig); + await ConfigureMcpAsync(sessionConfig, null); _session = await client.CreateSessionAsync(sessionConfig); } else @@ -2062,19 +2134,17 @@ private async Task InitializeSessionCoreAsync(string? sessionId) // A session can only be tracked once per client: an unloaded chat may still hold it. await ReleaseSessionHeldByOthersAsync(sessionId); - _session = await client.ResumeSessionAsync(sessionId, new ResumeSessionConfig() + var resumeConfig = new ResumeSessionConfig() { Streaming = true, Provider = BuildProviderConfig(), ContextTier = ResolveContextTier(), IncludeSubAgentStreamingEvents = true, SystemMessage = BuildSystemMessageConfig(), - Tools = toolProvider.GetTools().Cast().ToList(), - AvailableTools = BuildAvailableTools(), + Tools = CopilotSessionConfiguration.ApplyToolDeferral(toolProvider.GetTools(), toolProvider.IsFunctionAlwaysLoaded), + AvailableTools = CopilotSessionConfiguration.BuildAvailableTools(), ExcludedTools = ExcludedBuiltInTools.ToList(), CustomAgents = BuildCustomAgents(), - SkillDirectories = BuildSkillDirectories(), - EnableSkills = true, OnPermissionRequest = OnPermissionRequestAsync, OnUserInputRequest = OnUserInputRequestAsync, OnExitPlanModeRequest = OnExitPlanModeRequestAsync, @@ -2082,7 +2152,11 @@ private async Task InitializeSessionCoreAsync(string? sessionId) { OnPreToolUse = OnPreToolUseAsync } - }); + }; + CopilotSessionConfiguration.ApplyDiscovery(resumeConfig, workingDirectory, skillDirectories); + if (_byokConfiguration == null) CopilotSessionConfiguration.ApplyFusion(resumeConfig); + await ConfigureMcpAsync(resumeConfig, sessionId); + _session = await client.ResumeSessionAsync(sessionId, resumeConfig); } finally { @@ -2116,6 +2190,10 @@ private async Task InitializeSessionCoreAsync(string? sessionId) _toolsChangedSubscribed = true; } + SubscribeToProjectChanges(); + OnMcpSessionStarted(_session, workingDirectory); + _ = StartWorkspaceSync(_session, updateSkills: false); + if (string.IsNullOrWhiteSpace(sessionId)) { // The routing preference is not part of the session config, so it has to be applied to @@ -2131,22 +2209,19 @@ private async Task InitializeSessionCoreAsync(string? sessionId) } /// - /// Allow-list for the session: every tool OneWare registers, plus the built-ins that operate - /// only inside the session (agent delegation via task, skill loading, …). Built-ins - /// with host filesystem, shell or network access stay out, because - /// auto-approves any tool that has no OneWare confirmation check. + /// Converts the agents contributed by modules/plugins (e.g. a dataset agent from an FPGA AI + /// extension) into Copilot custom agents the main agent can delegate to. /// - private ToolSet BuildAvailableTools() + private IList BuildSessionSkillDirectories(string workingDirectory, IReadOnlyList projectRoots) { - return new ToolSet() - .AddCustom("*") - .AddBuiltIn(BuiltInTools.Isolated); + return CopilotSessionConfiguration.BuildSkillDirectories( + toolProvider.GetSkillDirectories() + .Concat(CopilotSessionConfiguration.GetProjectSkillDirectories(projectRoots, workingDirectory)), + settingsService.GetSettingValue>(CopilotModule.CopilotSkillDirectoriesSettingKey), + workingDirectory, + message => ContainerLocator.Container.Resolve().LogWarning("{Message}", message)); } - /// - /// Converts the agents contributed by modules/plugins (e.g. a dataset agent from an FPGA AI - /// extension) into Copilot custom agents the main agent can delegate to. - /// private IList BuildCustomAgents() { return toolProvider.GetAgents() @@ -2165,11 +2240,6 @@ private IList BuildCustomAgents() .ToList(); } - private IList BuildSkillDirectories() - { - return toolProvider.GetSkillDirectories().ToList(); - } - private SystemMessageConfig BuildSystemMessageConfig() { var overrides = new Dictionary { @@ -2189,12 +2259,16 @@ private SystemMessageConfig BuildSystemMessageConfig() { Content = """ OneWare Studio IDE context tools — use these instead of assumptions: - - Active file path → `getFocusedFile` - Open file paths → `getOpenFiles` - Active project → `getActiveProject` - LSP diagnostics → `getErrorsForFile` or `getAllErrors` + The file the user is working on (and any selection) arrives as a message attachment + when the user includes it. PATH RULES: always pass ABSOLUTE paths to file tools; take paths verbatim from tool outputs. + Follow the repository's AGENTS.md instructions. Before working in a subdirectory, + use `readFile` to check for more specific AGENTS.md files along that directory's + path and follow their scope. OneWare file tools do not use the CLI's built-in viewer. """ }, @@ -2300,7 +2374,7 @@ public async Task SendAsync(string prompt, ChatSendMode mode) var options = new MessageOptions { - Prompt = ApplyAgentInstructions(prompt, agent), + Prompt = await ApplyWorkspaceContextAsync(ApplyAgentInstructions(prompt, agent)), AgentMode = agent?.TurnMode switch { ChatAgentTurnMode.Plan => AgentMode.Plan, @@ -2431,6 +2505,7 @@ public async ValueTask DisposeAsync() _toolsChangedSubscribed = false; } Interlocked.Exchange(ref _toolsRefreshCts, null)?.Cancel(); + UnsubscribeFromProjectChanges(); ReleasePendingInputRequests(); ReleasePendingPlanRequests(); @@ -2489,6 +2564,7 @@ private async Task DisposeSessionAsync() { _subscription?.Dispose(); _subscription = null; + ResetWorkspaceState(); if (_session != null) { @@ -2505,6 +2581,7 @@ private async Task DisposeSessionAsync() IsRemoteSession = false; RemoteSessionUrl = null; ResetUsageStats(); + ResetImageState(); } private void ResetUsageStats() @@ -2545,8 +2622,21 @@ private void HandleSessionEvent(SessionEvent evt) ? null : ResolveSubAgentId(evt); + if (HandleFusionEvent(evt)) return; + agentId ??= ResolveFusionAgentId(evt); + switch (evt) { + case SessionMcpServerStatusChangedEvent x: + { + HandleMcpServerStatusChanged(x.Data); + break; + } + case SessionMcpServersLoadedEvent: + { + ScheduleMcpRefresh(); + break; + } case SubagentStartedEvent x: { HandleSubAgentStarted(x); @@ -2593,9 +2683,15 @@ private void HandleSessionEvent(SessionEvent evt) new ChatReasoningEvent(x.Data.Content, x.Data.ReasoningId) { AgentId = agentId }); break; } + case SessionBinaryAssetEvent x: + { + HandleBinaryAsset(x.Data); + break; + } case ToolExecutionCompleteEvent x: { var toolAgentId = ResolveToolAgentId(x.Data.ParentToolCallId, agentId); + HandleToolImages(x.Data, toolAgentId); if (toolAgentId == null) break; EventReceived?.Invoke(this, new ChatToolExecutionCompleteEvent( @@ -2621,6 +2717,7 @@ private void HandleSessionEvent(SessionEvent evt) case ToolExecutionStartEvent x: { RememberSubAgentInstructions(x.Data); + RememberToolName(x.Data.ToolCallId, x.Data.ToolName); var toolAgentId = ResolveToolAgentId(x.Data.ParentToolCallId, agentId); @@ -2632,6 +2729,13 @@ private void HandleSessionEvent(SessionEvent evt) }); break; } + case SessionWarningEvent { Data.WarningType: "compaction_static_context_blocked" } warning + when agentId == null: + // Followed by a generic "No response was returned" error that doesn't say why. + EventReceived?.Invoke(this, new ChatErrorEvent( + $"{warning.Data.Message} Disable connectors with many tools in Settings › AI Chat › Connectors " + + "or choose a model with a larger context.")); + break; case SessionErrorEvent error: if (HandleSessionError(error, agentId)) break; @@ -2640,6 +2744,7 @@ private void HandleSessionEvent(SessionEvent evt) break; case SessionIdleEvent when agentId == null: DropForegroundSubAgents(); + CloseFusionPhases(null); EventReceived?.Invoke(this, new ChatIdleEvent { Model = _lastTurnModel }); break; case AssistantUsageEvent usage: @@ -2895,7 +3000,7 @@ private async Task RefreshSessionToolsAsync(CancellationToken cancellationToken) if (session == null) return; _clientToolNames = null; - var tools = toolProvider.GetTools().Cast().ToList(); + var tools = CopilotSessionConfiguration.ApplyToolDeferral(toolProvider.GetTools(), toolProvider.IsFunctionAlwaysLoaded); await session.SetToolsAsync(tools, cancellationToken); } finally @@ -2974,7 +3079,7 @@ private bool IsClientTool(string? toolName) private void SyncAutoTierFromSession(AutoTier? tier) { var label = DescribeAutoTier(tier); - if (!AutoTiers.Contains(label) || label == SelectedAutoTier) return; + if (!IsAutoTierOffered(label) || label == SelectedAutoTier) return; Dispatcher.UIThread.Post(() => { @@ -3148,19 +3253,17 @@ private static string ApplyAgentInstructions(string prompt, ChatAgentDefinition? /// /// Returns why the selected agent must not call the given tool, or if it - /// may. Only OneWare tools can reach the workspace or the IDE — backend built-ins that touch the - /// host are already kept out of the session by — so a read-only - /// agent is enforced by blocking every OneWare tool that is not marked read-only. + /// may. Read-only agents cannot run OneWare functions that change anything; MCP tools are decided + /// in , where the runtime reports whether they are read-only. /// private string? GetAgentToolDenyReason(string toolName) { var agent = agentService.SelectedAgent; if (agent == null) return null; - // Only tools OneWare provides are restricted: the session built-ins (task/skill delegation, - // planning bookkeeping, …) are what the agent works with and are never named in a tool list. var isOneWareTool = toolProvider.IsFunctionReadOnly(toolName); - if (isOneWareTool == null) return null; + if (isOneWareTool == null && + !CopilotSessionConfiguration.IsExternalTool(toolName, IsClientTool(toolName))) return null; if (agent.Tools != null && !agent.Tools.Contains(toolName, StringComparer.OrdinalIgnoreCase)) return $"The {agent.DisplayName} agent is not allowed to use '{toolName}'."; @@ -3269,10 +3372,22 @@ private sealed class PendingPlanRequest PermissionDecisionReason = denyReason }); + var isExternal = CopilotSessionConfiguration.IsExternalTool(input.ToolName, IsClientTool(input.ToolName)); + + // Read-only agents only run MCP tools the server declares read-only, which the permission + // request reports, so they must reach it even when approvals are bypassed. + if (isExternal && agentService.SelectedAgent is { IsReadOnly: true }) + return Task.FromResult(null); + // Bypass Approval / Autopilot: auto-approve all permission requests without prompting if (IsApprovalBypassed) return Task.FromResult(new PreToolUseHookOutput { PermissionDecision = "allow" }); + // MCP tools must retain the runtime's permission checks rather than inheriting the + // automatic approval of OneWare tools without a confirmation check. + if (isExternal) + return Task.FromResult(null); + var check = toolProvider.GetConfirmationCheck(input.ToolName); if (check == null) return Task.FromResult(new PreToolUseHookOutput { PermissionDecision = "allow" }); @@ -3308,6 +3423,13 @@ private Task OnPermissionRequestAsync( PermissionRequest request, PermissionInvocation invocation) { + if (request is PermissionRequestMcp { ReadOnly: not true } mcpRequest && + agentService.SelectedAgent is { IsReadOnly: true } agent) + return Task.FromResult(PermissionDecision.Reject( + $"'{mcpRequest.ToolName}' of MCP server '{mcpRequest.ServerName}' may change data, which is not " + + $"allowed in {agent.DisplayName} mode. Only tools the server declares read-only can be used; " + + "ask the user to switch to Agent mode.")); + // Bypass Approval / Autopilot: auto-approve all permission requests if (IsApprovalBypassed) return Task.FromResult(PermissionDecision.ApproveOnce()); diff --git a/src/OneWare.Copilot/Services/CopilotSessionConfiguration.cs b/src/OneWare.Copilot/Services/CopilotSessionConfiguration.cs new file mode 100644 index 00000000..09e7a7b6 --- /dev/null +++ b/src/OneWare.Copilot/Services/CopilotSessionConfiguration.cs @@ -0,0 +1,229 @@ +using System.IO; +using System.Text; +using GitHub.Copilot; +using Microsoft.Extensions.AI; + +namespace OneWare.Copilot.Services; + +internal static class CopilotSessionConfiguration +{ + internal static readonly StringComparer PathComparer = + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + + private static readonly string[] ProjectSkillFolders = + [Path.Combine(".github", "skills"), Path.Combine(".agents", "skills"), Path.Combine(".claude", "skills")]; + + private static readonly string[] ProjectInstructionFiles = + ["AGENTS.md", Path.Combine(".github", "copilot-instructions.md")]; + + internal const int MaxInstructionFileChars = 32_000; + + /// + /// Project skill roots of the open projects. The runtime only discovers those of the session's working + /// directory itself, so that project is skipped to avoid loading its skills twice. + /// + internal static IEnumerable GetProjectSkillDirectories(IEnumerable projectRoots, + string workingDirectory) + { + return projectRoots + .Where(root => !PathComparer.Equals(Path.GetFullPath(root), Path.GetFullPath(workingDirectory))) + .SelectMany(root => ProjectSkillFolders.Select(folder => Path.Combine(root, folder))) + .Where(Directory.Exists); + } + + /// + /// Describes the open projects and inlines the instruction files of those the runtime does not load itself + /// (every project except the working directory). Returns when there is nothing beyond + /// what the runtime already knows. changes whenever the context would change. + /// + internal static string? BuildWorkspaceContext(IReadOnlyList projectRoots, string workingDirectory, + out string key) + { + var workingRoot = Path.GetFullPath(workingDirectory); + var keyBuilder = new StringBuilder(workingRoot); + var others = projectRoots.Where(x => !PathComparer.Equals(Path.GetFullPath(x), workingRoot)).ToList(); + + if (others.Count == 0) + { + key = keyBuilder.ToString(); + return null; + } + + var text = new StringBuilder(); + text.AppendLine(""); + text.AppendLine($"Your working directory is {workingRoot}. The IDE also has these projects open:"); + foreach (var root in projectRoots) + { + var marker = PathComparer.Equals(Path.GetFullPath(root), workingRoot) ? " (working directory)" : ""; + text.AppendLine($"- {Path.GetFileName(root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))}: {root}{marker}"); + } + + text.AppendLine("Follow each project's instructions below when working in it, and read more specific " + + "AGENTS.md files in its subdirectories before working there."); + + foreach (var root in others) + { + keyBuilder.Append('|').Append(root); + foreach (var file in ProjectInstructionFiles.Select(x => Path.Combine(root, x))) + { + if (!File.Exists(file)) continue; + + string content; + try + { + var info = new FileInfo(file); + keyBuilder.Append('|').Append(file).Append(':').Append(info.Length).Append(':') + .Append(info.LastWriteTimeUtc.Ticks); + content = File.ReadAllText(file).Trim(); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + continue; + } + + if (content.Length == 0) continue; + if (content.Length > MaxInstructionFileChars) + content = content[..MaxInstructionFileChars] + + $"\n[truncated, read {file} for the rest]"; + + text.AppendLine($""); + text.AppendLine(content); + text.AppendLine(""); + } + } + + text.Append(""); + key = keyBuilder.ToString(); + return text.ToString(); + } + internal static string GetConfigDirectory() + { + var copilotHome = Environment.GetEnvironmentVariable("COPILOT_HOME"); + return Path.GetFullPath(string.IsNullOrWhiteSpace(copilotHome) + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".copilot") + : copilotHome); + } + + internal const string EnabledFeatureFlagsVariable = "COPILOT_CLI_ENABLED_FEATURE_FLAGS"; + + /// + /// Runtime startup flag that makes the runtime report Auto's tiers (including HydraFusion) from the + /// Copilot service. It also moves the runtime's model catalog to the service's /meta endpoint. + /// + internal const string DynamicAutoTiersFeatureFlag = "DYNAMIC_AUTO_TIERS"; + + /// Session flags HydraFusion tiers need to run, the same set VS Code enables. + internal static readonly string[] FusionFeatureFlags = + ["HYDRAFUSION", "HYDRAFUSION_ROLLOUT", "HYDRAFUSION_INTERRUPTED_COMMIT", "HYDRAFUSION_PLAN_V2"]; + + /// + /// Adds to the runtime's comma separated , + /// keeping flags that are already enabled there. + /// + internal static IReadOnlyDictionary WithRuntimeFeatureFlag( + IReadOnlyDictionary environment, string flag, bool ignoreCase) + { + var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var key = environment.Keys.FirstOrDefault(x => string.Equals(x, EnabledFeatureFlagsVariable, comparison)) + ?? EnabledFeatureFlagsVariable; + + var flags = (environment.GetValueOrDefault(key) ?? "") + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + if (!flags.Contains(flag, StringComparer.OrdinalIgnoreCase)) flags.Add(flag); + + var result = new Dictionary(environment, + ignoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal) + { + [key] = string.Join(',', flags) + }; + return result; + } + + /// Lets the session run HydraFusion when an Auto tier selects it. + internal static void ApplyFusion(SessionConfigBase config) + { + config.FeatureFlags ??= new Dictionary(); + foreach (var flag in FusionFeatureFlags) + config.FeatureFlags[flag] = true; + } + + internal static void ApplyDiscovery(SessionConfigBase config, string workingDirectory, + IList skillDirectories) + { + config.WorkingDirectory = workingDirectory; + // BYOK runtimes have separate state directories, but use the same user customizations. + config.ConfigDirectory = GetConfigDirectory(); + config.EnableConfigDiscovery = true; + config.EnableOnDemandInstructionDiscovery = true; + config.EnableSkills = true; + config.SkillDirectories = skillDirectories; + // Discovering user customizations must not opt into executing repository hooks. + config.EnableFileHooks = false; + // Keep MCP sign-ins (connectors) in the OS keychain instead of asking again in every chat. + config.McpOAuthTokenStorage = McpOAuthTokenStorageMode.Persistent; + // Connectors can add hundreds of tools (Microsoft 365 alone ~400), more than fits into the model's context. + // Tool search keeps them out of the prompt until the model looks them up. + config.ToolSearch = new ToolSearchConfig { Enabled = true }; + } + + internal static IList BuildSkillDirectories(IEnumerable pluginDirectories, + IEnumerable userDirectories, string workingDirectory, Action warn) + { + var comparer = PathComparer; + var directories = new HashSet(comparer); + foreach (var entry in pluginDirectories.Concat(userDirectories)) + { + if (string.IsNullOrWhiteSpace(entry)) continue; + + var directory = ResolveUserPath(entry, workingDirectory); + if (Directory.Exists(directory)) + directories.Add(directory); + else + warn($"Skill directory does not exist: {directory}"); + } + + return directories.ToList(); + } + + /// Resolves a path from the settings: absolute, ~/ or relative to the working directory. + internal static string ResolveUserPath(string entry, string workingDirectory) + { + var path = entry.Trim(); + if (path == "~" || path.StartsWith("~/", StringComparison.Ordinal) || + path.StartsWith(@"~\", StringComparison.Ordinal)) + path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + path.Length == 1 ? "" : path[2..]); + + return Path.GetFullPath(path, workingDirectory); + } + + /// + /// Marks the functions flagged so tool + /// search never defers them. All other tools (plugins, MCP servers) may be deferred behind tool search. + /// + internal static List ApplyToolDeferral(IEnumerable tools, + Func isAlwaysLoaded) => + tools.Select(AIFunctionDeclaration (x) => isAlwaysLoaded(x.Name) ? new AlwaysLoadedTool(x) : x).ToList(); + + // Same as CopilotTool.DefineTool with CopilotToolOptions.Defer = Never, for an existing function. + private sealed class AlwaysLoadedTool(AIFunction inner) : DelegatingAIFunction(inner) + { + private const string DeferKey = "defer"; + + public override IReadOnlyDictionary AdditionalProperties { get; } = + new Dictionary(inner.AdditionalProperties) { [DeferKey] = CopilotToolDefer.Never }; + } + + internal static ToolSet BuildAvailableTools() => new ToolSet() + .AddCustom("*") + .AddMcp("*") + .AddBuiltIn(BuiltInTools.Isolated); + + /// The runtime's tool search, which only looks up other tools. + internal const string ToolSearchToolName = "tool_search_tool"; + + internal static bool IsExternalTool(string toolName, bool isClientTool) => + !isClientTool && toolName != ToolSearchToolName && + !BuiltInTools.Isolated.Contains(toolName, StringComparer.Ordinal); +} diff --git a/src/OneWare.Copilot/Services/LoginShellEnvironment.cs b/src/OneWare.Copilot/Services/LoginShellEnvironment.cs new file mode 100644 index 00000000..3b1c414b --- /dev/null +++ b/src/OneWare.Copilot/Services/LoginShellEnvironment.cs @@ -0,0 +1,123 @@ +using System.Diagnostics; +using System.IO; + +namespace OneWare.Copilot.Services; + +/// +/// The environment of the user's login shell. Apps started from a desktop launcher don't see variables exported +/// in shell profiles (tokens referenced by MCP configurations, for example), so they are read from there like +/// VS Code does. Read once per process, never on Windows. +/// +internal static class LoginShellEnvironment +{ + private const string Marker = "__ONEWARE_SHELL_ENV__"; + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); + private static Task>? _environment; + + public static Task> GetAsync() => + LazyInitializer.EnsureInitialized(ref _environment, () => Task.Run(ReadAsync)); + + private static async Task> ReadAsync() + { + var empty = new Dictionary(); + if (OperatingSystem.IsWindows() || OperatingSystem.IsBrowser()) return empty; + + var shell = ResolveShell(Environment.GetEnvironmentVariable("SHELL"), + Environment.GetEnvironmentVariable("PATH"), ReadAccountShell); + + try + { + var start = new ProcessStartInfo(shell) + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + // Interactive and login, so both ~/.profile and ~/.bashrc / ~/.zshrc style files are applied. + foreach (var arg in new[] { "-i", "-l", "-c", $"printf '{Marker}'; env -0" }) + start.ArgumentList.Add(arg); + + using var process = Process.Start(start); + if (process == null) return empty; + process.StandardInput.Close(); + + using var cts = new CancellationTokenSource(Timeout); + var output = process.StandardOutput.ReadToEndAsync(cts.Token); + _ = process.StandardError.ReadToEndAsync(cts.Token); + try + { + await process.WaitForExitAsync(cts.Token); + return Parse(await output); + } + catch (OperationCanceledException) + { + process.Kill(true); + return empty; + } + } + catch (Exception) + { + return empty; + } + } + + /// + /// The user's shell. SHELL may be a bare name (some launchers and IDE run configurations set + /// SHELL=bash) or missing; falling back to /bin/sh right away would skip ~/.bashrc / + /// ~/.zshrc, where tools such as nvm are set up. + /// + internal static string ResolveShell(string? shell, string? path, Func accountShell) + { + if (FindExecutable(shell, path) is { } fromVariable) return fromVariable; + if (FindExecutable(accountShell(), path) is { } fromAccount) return fromAccount; + return "/bin/sh"; + } + + private static string? FindExecutable(string? name, string? path) + { + if (string.IsNullOrWhiteSpace(name)) return null; + name = name.Trim(); + if (Path.IsPathRooted(name)) return File.Exists(name) ? name : null; + if (name.Contains('/')) return null; + + return (path ?? string.Empty).Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Concat(["/usr/bin", "/bin"]) + .Select(x => Path.Combine(x, name)) + .FirstOrDefault(File.Exists); + } + + // The login shell of the account, from /etc/passwd. + private static string? ReadAccountShell() + { + try + { + var user = Environment.UserName; + return File.ReadLines("/etc/passwd") + .Select(x => x.Split(':')) + .FirstOrDefault(x => x.Length >= 7 && x[0] == user)?[6]; + } + catch (Exception) + { + return null; + } + } + + internal static IReadOnlyDictionary Parse(string output) + { + var result = new Dictionary(StringComparer.Ordinal); + + // Profiles may print banners before the marker. + var start = output.LastIndexOf(Marker, StringComparison.Ordinal); + if (start < 0) return result; + + foreach (var entry in output[(start + Marker.Length)..].Split('\0')) + { + var separator = entry.IndexOf('='); + if (separator <= 0) continue; + result[entry[..separator]] = entry[(separator + 1)..]; + } + + return result; + } +} diff --git a/src/OneWare.Copilot/Settings/CopilotConnectorsSetting.cs b/src/OneWare.Copilot/Settings/CopilotConnectorsSetting.cs new file mode 100644 index 00000000..de1904d4 --- /dev/null +++ b/src/OneWare.Copilot/Settings/CopilotConnectorsSetting.cs @@ -0,0 +1,14 @@ +using OneWare.Copilot.Connectors; +using OneWare.Copilot.ViewModels; +using OneWare.Essentials.Models; + +namespace OneWare.Copilot.Settings; + +/// Settings entry for the connectors; they are stored in their own mcp.json, not in the settings. +public sealed class CopilotConnectorsSetting : CustomSetting +{ + public CopilotConnectorsSetting(McpConnectorHub hub) : base(string.Empty) + { + Control = new CopilotConnectorsSettingViewModel(hub); + } +} diff --git a/src/OneWare.Copilot/ViewModels/CopilotConnectorEditorViewModel.cs b/src/OneWare.Copilot/ViewModels/CopilotConnectorEditorViewModel.cs new file mode 100644 index 00000000..7f375e53 --- /dev/null +++ b/src/OneWare.Copilot/ViewModels/CopilotConnectorEditorViewModel.cs @@ -0,0 +1,507 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using OneWare.Copilot.Connectors; +using OneWare.Essentials.Controls; +using OneWare.Essentials.ViewModels; + +namespace OneWare.Copilot.ViewModels; + +/// Adds or edits a connector. Values marked as secret are kept in the OS credential store. +public sealed class CopilotConnectorEditorViewModel : FlexibleWindowViewModelBase +{ + private readonly McpConnectorHub _hub; + private readonly McpConnector? _original; + private readonly IReadOnlyDictionary _environment; + private string _name = string.Empty; + private bool _isRemote; + private string _command = string.Empty; + private string _arguments = string.Empty; + private string _workingDirectory = string.Empty; + private string _url = string.Empty; + private bool _useSse; + private string _oauthClientId = string.Empty; + private string _oauthClientSecret = string.Empty; + private bool _isPublicClient; + private bool _isAdvancedExpanded; + private bool _isPasteExpanded; + private string _pasteText = string.Empty; + private string? _pasteMessage; + private string? _error; + private bool _isBusy; + private McpConnectorStatus? _testStatus; + private string _missingVariables = string.Empty; + + private CopilotConnectorEditorViewModel(McpConnectorHub hub, McpConnector? original, + IReadOnlyDictionary environment) + { + _hub = hub; + _original = original; + _environment = environment; + Title = original == null ? "Add MCP Server" : $"Edit MCP Server - {original.Name}"; + + AddValueCommand = new RelayCommand(() => + AddValue(IsRemote ? Headers : Env, new McpConnectorValue(string.Empty, string.Empty, false))); + ApplyPasteCommand = new RelayCommand(ApplyPaste); + TestCommand = new AsyncRelayCommand(TestAsync); + SaveCommand = new AsyncRelayCommand(SaveAsync); + CancelCommand = new RelayCommand(x => x?.Close()); + + // New servers start with the paste box open, which is the quickest way to add one from a README. + _isPasteExpanded = original == null; + Load(original ?? new McpConnector()); + } + + /// The connector to edit, or to add one. + public static async Task CreateAsync(McpConnectorHub hub, + McpConnector? connector) + { + var environment = await McpConnectorHub.GetRuntimeEnvironmentAsync(); + if (connector == null) return new CopilotConnectorEditorViewModel(hub, null, environment); + + var resolved = await hub.ResolveAsync(connector); + var editor = new CopilotConnectorEditorViewModel(hub, resolved, environment); + editor.OAuthClientSecret = (await hub.GetSecretsAsync(connector.Id)).OAuthClientSecret ?? string.Empty; + editor.IsAdvancedExpanded = editor.HasAdvancedValues; + return editor; + } + + public bool IsNew => _original == null; + + public bool Saved { get; private set; } + + public string Name + { + get => _name; + set => SetProperty(ref _name, value); + } + + public bool IsRemote + { + get => _isRemote; + set + { + if (!SetProperty(ref _isRemote, value)) return; + OnPropertyChanged(nameof(IsLocal)); + OnPropertyChanged(nameof(Values)); + OnPropertyChanged(nameof(ValuesTitle)); + OnPropertyChanged(nameof(AddValueText)); + UpdateVariables(); + } + } + + public bool IsLocal + { + get => !IsRemote; + set => IsRemote = !value; + } + + public string Command + { + get => _command; + set + { + if (SetProperty(ref _command, value)) UpdateVariables(); + } + } + + /// Arguments, one per line. + public string Arguments + { + get => _arguments; + set + { + if (SetProperty(ref _arguments, value)) UpdateVariables(); + } + } + + public string WorkingDirectory + { + get => _workingDirectory; + set + { + if (SetProperty(ref _workingDirectory, value)) UpdateVariables(); + } + } + + public string Url + { + get => _url; + set => SetProperty(ref _url, value); + } + + public bool UseSse + { + get => _useSse; + set => SetProperty(ref _useSse, value); + } + + public string OAuthClientId + { + get => _oauthClientId; + set => SetProperty(ref _oauthClientId, value); + } + + public string OAuthClientSecret + { + get => _oauthClientSecret; + set => SetProperty(ref _oauthClientSecret, value); + } + + public bool IsPublicClient + { + get => _isPublicClient; + set => SetProperty(ref _isPublicClient, value); + } + + public bool IsAdvancedExpanded + { + get => _isAdvancedExpanded; + set => SetProperty(ref _isAdvancedExpanded, value); + } + + public string RedirectUrl => McpConnectorHub.OAuthRedirectUrl; + + public bool IsPasteExpanded + { + get => _isPasteExpanded; + set => SetProperty(ref _isPasteExpanded, value); + } + + public string PasteText + { + get => _pasteText; + set => SetProperty(ref _pasteText, value); + } + + public string? PasteMessage + { + get => _pasteMessage; + private set + { + if (SetProperty(ref _pasteMessage, value)) OnPropertyChanged(nameof(HasPasteMessage)); + } + } + + public bool HasPasteMessage => !string.IsNullOrEmpty(PasteMessage); + + public ObservableCollection Env { get; } = []; + + public ObservableCollection Headers { get; } = []; + + /// Environment variables of local servers, headers of remote ones. + public ObservableCollection Values => IsRemote ? Headers : Env; + + public string ValuesTitle => IsRemote ? "Headers" : "Environment variables"; + + public string AddValueText => IsRemote ? "Add header" : "Add variable"; + + /// Referenced ${VAR}s that are not set in the environment OneWare starts servers with. + public string MissingVariables + { + get => _missingVariables; + private set + { + if (SetProperty(ref _missingVariables, value)) OnPropertyChanged(nameof(HasMissingVariables)); + } + } + + public bool HasMissingVariables => MissingVariables.Length > 0; + + public string? Error + { + get => _error; + private set + { + if (SetProperty(ref _error, value)) OnPropertyChanged(nameof(HasError)); + } + } + + public bool HasError => !string.IsNullOrEmpty(Error); + + public bool IsBusy + { + get => _isBusy; + private set => SetProperty(ref _isBusy, value); + } + + public McpConnectorStatus? TestStatus + { + get => _testStatus; + private set + { + if (!SetProperty(ref _testStatus, value)) return; + OnPropertyChanged(nameof(TestText)); + OnPropertyChanged(nameof(HasTestStatus)); + OnPropertyChanged(nameof(TestSucceeded)); + OnPropertyChanged(nameof(TestFailed)); + } + } + + public bool HasTestStatus => TestStatus != null; + + public bool TestSucceeded => TestStatus?.State == McpConnectorState.Connected; + + public bool TestFailed => TestStatus is { State: not McpConnectorState.Connected }; + + public string? TestText => TestStatus?.State switch + { + null => null, + McpConnectorState.Connected => "Connected", + McpConnectorState.NeedsSignIn => + "Reachable, sign-in required. Save and use Sign in to authorize OneWare.", + _ => string.IsNullOrWhiteSpace(TestStatus.Message) ? "Failed to connect." : TestStatus.Message + }; + + public IRelayCommand AddValueCommand { get; } + + public IRelayCommand ApplyPasteCommand { get; } + + public IAsyncRelayCommand TestCommand { get; } + + public IAsyncRelayCommand SaveCommand { get; } + + public IRelayCommand CancelCommand { get; } + + private bool HasAdvancedValues => !string.IsNullOrWhiteSpace(OAuthClientId) || OAuthClientSecret.Length > 0 || + IsPublicClient || UseSse; + + private void Load(McpConnector connector) + { + Name = connector.Name; + IsRemote = connector.Kind == McpConnectorKind.Remote; + Command = connector.Command; + Arguments = string.Join(Environment.NewLine, connector.Args); + WorkingDirectory = connector.WorkingDirectory ?? string.Empty; + Url = connector.Url; + UseSse = connector.UseSse; + OAuthClientId = connector.OAuthClientId ?? string.Empty; + IsPublicClient = connector.OAuthPublicClient == true; + + Env.Clear(); + Headers.Clear(); + foreach (var value in connector.Env) AddValue(Env, value); + foreach (var value in connector.Headers) AddValue(Headers, value); + UpdateVariables(); + } + + private void AddValue(ObservableCollection target, McpConnectorValue value) + { + var row = new CopilotConnectorValueViewModel(value.Key, value.Value, value.IsSecret); + row.RemoveCommand = new RelayCommand(() => + { + target.Remove(row); + UpdateVariables(); + }); + row.PropertyChanged += OnRowChanged; + target.Add(row); + } + + private void OnRowChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(CopilotConnectorValueViewModel.Value) + or nameof(CopilotConnectorValueViewModel.IsSecret)) + UpdateVariables(); + } + + private void ApplyPaste() + { + var warnings = new List(); + var fallbackName = string.IsNullOrWhiteSpace(Name) ? "server" : Name.Trim(); + var servers = McpConnectorImport.ParseText(PasteText, warnings.Add, fallbackName); + if (servers.Count == 0) + { + PasteMessage = warnings.Count > 0 ? string.Join(" ", warnings) : "No MCP server found."; + return; + } + + var imported = servers[0]; + // Editing keeps the identity (and so the stored secrets) of the connector. + if (_original != null) imported.Connector.Id = _original.Id; + Load(imported.Connector); + if (imported.OAuthClientSecret != null) OAuthClientSecret = imported.OAuthClientSecret; + if (HasAdvancedValues) IsAdvancedExpanded = true; + + if (servers.Count > 1) + warnings.Insert(0, $"Found {servers.Count} servers, only '{imported.Connector.Name}' was used. " + + "Use Import to add all of them."); + PasteMessage = warnings.Count > 0 ? string.Join(" ", warnings) : null; + PasteText = string.Empty; + IsPasteExpanded = warnings.Count > 0; + TestStatus = null; + } + + private McpConnector BuildConnector() + { + var connector = _original?.Clone() ?? new McpConnector(); + connector.Name = Name.Trim(); + connector.Kind = IsRemote ? McpConnectorKind.Remote : McpConnectorKind.Local; + connector.Command = Command.Trim(); + connector.Args = Arguments.Split('\n').Select(x => x.TrimEnd('\r')).Where(x => x.Length > 0).ToList(); + connector.WorkingDirectory = string.IsNullOrWhiteSpace(WorkingDirectory) ? null : WorkingDirectory.Trim(); + connector.Url = Url.Trim(); + connector.UseSse = UseSse; + connector.OAuthClientId = string.IsNullOrWhiteSpace(OAuthClientId) ? null : OAuthClientId.Trim(); + connector.OAuthPublicClient = IsPublicClient ? true : _original?.OAuthPublicClient == false ? false : null; + + static List ToValues(IEnumerable rows) => rows + .Where(x => !string.IsNullOrWhiteSpace(x.Key)) + .Select(x => new McpConnectorValue(x.Key.Trim(), x.Value, x.IsSecret)) + .ToList(); + + // Only the values of the selected kind are kept. + connector.Env = IsRemote ? [] : ToValues(Env); + connector.Headers = IsRemote ? ToValues(Headers) : []; + return connector; + } + + private void UpdateVariables() + { + var missing = BuildConnector().GetVariables().Where(x => !_environment.ContainsKey(x)).ToList(); + MissingVariables = missing.Count == 0 + ? string.Empty + : $"{string.Join(", ", missing.Select(McpConnector.ToVariableReference))} " + + (missing.Count == 1 ? "is" : "are") + + " not set in your environment. Set it before starting OneWare, or mark the value as secret and enter it here."; + } + + private string? Validate(McpConnector connector) + { + if (string.IsNullOrWhiteSpace(connector.Name)) return "Enter a name."; + if (!McpConnector.IsValidName(connector.Name)) + return "The name needs a letter or digit and may only contain letters, digits, '.', '-' and '_'."; + + if (connector.Kind == McpConnectorKind.Local) + { + if (string.IsNullOrWhiteSpace(connector.Command)) return "Enter the command that starts the server."; + } + else if (!Uri.TryCreate(connector.Url, UriKind.Absolute, out var uri) || + uri.Scheme is not ("http" or "https")) + { + return "Enter the server's http(s) URL."; + } + + var keys = Values.Where(x => !string.IsNullOrWhiteSpace(x.Key)).Select(x => x.Key.Trim()).ToList(); + if (keys.Distinct(StringComparer.OrdinalIgnoreCase).Count() != keys.Count) + return IsRemote ? "Each header may only be set once." : "Each variable may only be set once."; + + var missingSecret = Values.FirstOrDefault(x => + x.IsSecret && !string.IsNullOrWhiteSpace(x.Key) && x.Value.Length == 0); + return missingSecret != null ? $"Enter the secret value of '{missingSecret.Key.Trim()}'." : null; + } + + private async Task TestAsync() + { + var connector = BuildConnector(); + Error = Validate(connector); + if (Error != null || IsBusy) return; + + IsBusy = true; + TestStatus = new McpConnectorStatus(McpConnectorState.Pending); + try + { + TestStatus = await _hub.CheckAsync(connector, report: false); + if (McpConnectorHub.IsDynamicRegistrationError(TestStatus.Message)) IsAdvancedExpanded = true; + } + catch (Exception e) + { + TestStatus = new McpConnectorStatus(McpConnectorState.Failed, e.Message); + } + finally + { + IsBusy = false; + } + } + + private async Task SaveAsync(FlexibleWindow? window) + { + var connector = BuildConnector(); + Error = Validate(connector); + if (Error != null) return; + + IsBusy = true; + try + { + await _hub.SaveAsync(connector, OAuthClientSecret.Length > 0 ? OAuthClientSecret : null); + Saved = true; + window?.Close(); + } + catch (Exception e) + { + Error = e.Message; + } + finally + { + IsBusy = false; + } + } +} + +/// An environment variable or header of a connector. +public sealed class CopilotConnectorValueViewModel : ObservableObject +{ + private string _key; + private string _value; + private bool _isSecret; + private bool _isRevealed; + + public CopilotConnectorValueViewModel(string key, string value, bool isSecret) + { + _key = key; + _value = value; + _isSecret = isSecret; + ToggleRevealCommand = new RelayCommand(() => IsRevealed = !IsRevealed); + } + + public string Key + { + get => _key; + set + { + if (!SetProperty(ref _key, value)) return; + // Suggests storing credentials securely; never silently unmarks a value the user marked. + if (!IsSecret && Value.Length == 0 && McpConnector.LooksSecret(value)) IsSecret = true; + } + } + + public string Value + { + get => _value; + set => SetProperty(ref _value, value); + } + + /// Stores the value in the OS credential store instead of mcp.json. + public bool IsSecret + { + get => _isSecret; + set + { + if (!SetProperty(ref _isSecret, value)) return; + OnPropertyChanged(nameof(PasswordChar)); + OnPropertyChanged(nameof(Watermark)); + OnPropertyChanged(nameof(SecretToolTip)); + } + } + + public bool IsRevealed + { + get => _isRevealed; + set + { + if (SetProperty(ref _isRevealed, value)) OnPropertyChanged(nameof(PasswordChar)); + } + } + + public char PasswordChar => IsSecret && !IsRevealed ? '•' : '\0'; + + public string Watermark => IsSecret ? "Secret value" : "Value, may use ${VAR}"; + + public string SecretToolTip => IsSecret + ? "Stored in the OS credential store. Click to store it in mcp.json instead." + : "Stored in mcp.json. Click to store it in the OS credential store instead."; + + public IRelayCommand ToggleRevealCommand { get; } + + public IRelayCommand? RemoveCommand { get; set; } +} diff --git a/src/OneWare.Copilot/ViewModels/CopilotConnectorsSettingViewModel.cs b/src/OneWare.Copilot/ViewModels/CopilotConnectorsSettingViewModel.cs new file mode 100644 index 00000000..8db98d76 --- /dev/null +++ b/src/OneWare.Copilot/ViewModels/CopilotConnectorsSettingViewModel.cs @@ -0,0 +1,416 @@ +using System.Collections.ObjectModel; +using System.IO; +using Avalonia.Controls; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; +using OneWare.Copilot.Connectors; +using OneWare.Copilot.Views; +using OneWare.Essentials.Enums; +using OneWare.Essentials.Helpers; +using OneWare.Essentials.Services; + +namespace OneWare.Copilot.ViewModels; + +/// Lists the connectors (IDE-wide MCP servers), in the settings and in a window opened from the chat. +public sealed class CopilotConnectorsSettingViewModel : ObservableObject +{ + private readonly McpConnectorHub _hub; + private bool _isLoading; + private bool _loaded; + private string? _message; + private bool _isError; + private int _cliImportCount; + + public CopilotConnectorsSettingViewModel(McpConnectorHub hub) + { + _hub = hub; + + AddCommand = new AsyncRelayCommand(AddAsync); + ImportCommand = new AsyncRelayCommand(ImportFileAsync); + ImportCliCommand = new AsyncRelayCommand(ImportCliAsync); + OpenFileCommand = new RelayCommand(OpenFile); + DismissMessageCommand = new RelayCommand(() => ShowMessage(null)); + } + + public ObservableCollection Connectors { get; } = []; + + public bool IsLoading + { + get => _isLoading; + private set => SetProperty(ref _isLoading, value); + } + + public string? Message + { + get => _message; + private set + { + if (SetProperty(ref _message, value)) OnPropertyChanged(nameof(HasMessage)); + } + } + + public bool HasMessage => !string.IsNullOrEmpty(Message); + + public bool IsError + { + get => _isError; + private set => SetProperty(ref _isError, value); + } + + public bool IsEmpty => !IsLoading && Connectors.Count == 0; + + /// Servers in the Copilot CLI's configuration that are not connectors yet. + public int CliImportCount + { + get => _cliImportCount; + private set + { + if (!SetProperty(ref _cliImportCount, value)) return; + OnPropertyChanged(nameof(CanImportCli)); + OnPropertyChanged(nameof(CliImportText)); + } + } + + public bool CanImportCli => CliImportCount > 0; + + public string CliImportText => CliImportCount == 1 + ? "1 MCP server from the Copilot CLI configuration is not used in OneWare." + : $"{CliImportCount} MCP servers from the Copilot CLI configuration are not used in OneWare."; + + public string? LoadError => _hub.LoadError == null ? null : $"mcp.json has errors: {_hub.LoadError}"; + + public string ConfigFile => _hub.ConfigFile; + + public IAsyncRelayCommand AddCommand { get; } + + public IAsyncRelayCommand ImportCommand { get; } + + public IAsyncRelayCommand ImportCliCommand { get; } + + public IRelayCommand OpenFileCommand { get; } + + public IRelayCommand DismissMessageCommand { get; } + + internal McpConnectorHub Hub => _hub; + + /// Opens the list in its own window, e.g. from the chat. + public static void ShowWindow(McpConnectorHub hub, Control? owner) + { + var window = new CopilotConnectorsWindow { DataContext = new CopilotConnectorsSettingViewModel(hub) }; + _ = ContainerLocator.Container.Resolve() + .ShowDialogAsync(window, owner == null ? null : TopLevel.GetTopLevel(owner) as Window); + } + + /// Loads the list and follows changes while it is shown. + public void Attach() + { + if (_loaded) return; + _loaded = true; + _hub.Changed += OnHubChanged; + _hub.StatusChanged += OnHubStatusChanged; + _ = LoadAsync(); + } + + public void Detach() + { + if (!_loaded) return; + _loaded = false; + _hub.Changed -= OnHubChanged; + _hub.StatusChanged -= OnHubStatusChanged; + } + + private void OnHubChanged(object? sender, EventArgs e) => Dispatcher.UIThread.Post(() => _ = LoadAsync()); + + private void OnHubStatusChanged(object? sender, string name) => Dispatcher.UIThread.Post(() => + { + if (Connectors.FirstOrDefault(x => x.Name == name) is { } item) item.Status = _hub.GetStatus(name); + }); + + private async Task LoadAsync() + { + if (!_loaded) return; + IsLoading = true; + OnPropertyChanged(nameof(IsEmpty)); + + try + { + var connectors = await _hub.GetConnectorsAsync(); + + // Keeps the items (and their busy state) of connectors that still exist. + var items = connectors.Select(connector => + { + var item = Connectors.FirstOrDefault(x => x.Connector.Id == connector.Id) ?? + new CopilotConnectorItemViewModel(this); + item.SetConnector(connector, _hub.GetStatus(connector.Name)); + return item; + }).ToList(); + + Connectors.Clear(); + foreach (var item in items) Connectors.Add(item); + + var names = connectors.Select(x => x.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + CliImportCount = (await _hub.ReadCopilotCliServersAsync()).Count(x => !names.Contains(x.Connector.Name)); + } + catch (Exception e) + { + ContainerLocator.Container.Resolve().LogWarning(e, "Failed to load the connectors."); + ShowMessage(e.Message, true); + } + finally + { + IsLoading = false; + OnPropertyChanged(nameof(IsEmpty)); + OnPropertyChanged(nameof(LoadError)); + } + } + + internal void ShowMessage(string? message, bool isError = false) + { + Message = message; + IsError = isError; + } + + private async Task AddAsync(Control? owner) + { + var editor = await CopilotConnectorEditorViewModel.CreateAsync(_hub, null); + await ShowEditorAsync(editor, owner); + } + + internal async Task EditAsync(CopilotConnectorItemViewModel item, Control? owner) + { + var editor = await CopilotConnectorEditorViewModel.CreateAsync(_hub, item.Connector); + await ShowEditorAsync(editor, owner); + } + + private static Task ShowEditorAsync(CopilotConnectorEditorViewModel editor, Control? owner) => + ContainerLocator.Container.Resolve().ShowDialogAsync( + new CopilotConnectorEditorView { DataContext = editor }, + owner == null ? null : TopLevel.GetTopLevel(owner) as Window); + + internal async Task RemoveAsync(CopilotConnectorItemViewModel item, Control? owner) + { + var result = await ContainerLocator.Container.Resolve().ShowYesNoAsync("Remove MCP Server", + $"Remove '{item.Name}'? Its stored secrets are deleted as well.", + MessageBoxIcon.Warning, owner == null ? null : TopLevel.GetTopLevel(owner) as Window); + if (result != MessageBoxStatus.Yes) return; + + try + { + await _hub.RemoveAsync(item.Connector.Id); + } + catch (Exception e) + { + ShowMessage($"Failed to remove '{item.Name}': {e.Message}", true); + } + } + + private async Task ImportFileAsync(Control? owner) + { + if (owner == null || TopLevel.GetTopLevel(owner) is not { } topLevel) return; + + var file = await StorageProviderHelper.SelectFileAsync(topLevel, "Import MCP Servers", null, + new FilePickerFileType("MCP configuration") { Patterns = ["*.json"] }); + if (file == null) return; + + var warnings = new List(); + await ImportAsync(McpConnectorImport.Read(file, warnings.Add), warnings); + } + + private async Task ImportCliAsync() + { + var warnings = new List(); + var names = (await _hub.GetConnectorsAsync()).Select(x => x.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + var servers = (await _hub.ReadCopilotCliServersAsync(warnings.Add)) + .Where(x => !names.Contains(x.Connector.Name)).ToList(); + await ImportAsync(servers, warnings); + } + + private async Task ImportAsync(IReadOnlyList servers, List warnings) + { + IReadOnlyList added = []; + try + { + added = await _hub.AddAsync(servers, warnings.Add); + } + catch (Exception e) + { + warnings.Add(e.Message); + } + + var summary = added.Count == 0 ? "Nothing was imported." : $"Imported {string.Join(", ", added)}."; + if (warnings.Count > 0) summary += " " + string.Join(" ", warnings); + ShowMessage(summary, added.Count == 0); + } + + private void OpenFile() + { + try + { + if (!File.Exists(_hub.ConfigFile)) + { + Directory.CreateDirectory(Path.GetDirectoryName(_hub.ConfigFile)!); + File.WriteAllText(_hub.ConfigFile, "{\n \"mcpServers\": {}\n}\n"); + } + + _ = ContainerLocator.Container.Resolve().OpenFileAsync(_hub.ConfigFile); + } + catch (Exception e) + { + ShowMessage($"Failed to open {_hub.ConfigFile}: {e.Message}", true); + } + } +} + +public sealed class CopilotConnectorItemViewModel : ObservableObject +{ + private readonly CopilotConnectorsSettingViewModel _owner; + private McpConnector _connector = new(); + private bool _enabled; + private bool _isBusy; + private McpConnectorStatus? _status; + + public CopilotConnectorItemViewModel(CopilotConnectorsSettingViewModel owner) + { + _owner = owner; + EditCommand = new AsyncRelayCommand(x => _owner.EditAsync(this, x)); + RemoveCommand = new AsyncRelayCommand(x => _owner.RemoveAsync(this, x)); + CheckCommand = new AsyncRelayCommand(CheckAsync); + SignInCommand = new AsyncRelayCommand(SignInAsync); + } + + public McpConnector Connector => _connector; + + public string Name => _connector.Name; + + public string Description => _connector.Describe(); + + public bool IsRemote => _connector.Kind == McpConnectorKind.Remote; + + public bool Enabled + { + get => _enabled; + set + { + if (!SetProperty(ref _enabled, value)) return; + OnPropertyChanged(nameof(CanSignIn)); + OnPropertyChanged(nameof(StatusText)); + OnPropertyChanged(nameof(NeedsSignIn)); + OnPropertyChanged(nameof(HasStatusMessage)); + _ = ApplyEnabledAsync(value); + } + } + + public bool IsBusy + { + get => _isBusy; + private set => SetProperty(ref _isBusy, value); + } + + public McpConnectorStatus? Status + { + get => _status; + set + { + if (!SetProperty(ref _status, value)) return; + OnPropertyChanged(nameof(StatusText)); + OnPropertyChanged(nameof(StatusMessage)); + OnPropertyChanged(nameof(HasStatusMessage)); + OnPropertyChanged(nameof(IsConnected)); + OnPropertyChanged(nameof(NeedsSignIn)); + OnPropertyChanged(nameof(IsFailed)); + OnPropertyChanged(nameof(IsUnknown)); + } + } + + public bool IsConnected => Status?.State == McpConnectorState.Connected; + + public bool NeedsSignIn => Enabled && Status?.State == McpConnectorState.NeedsSignIn; + + public bool IsFailed => Status?.State == McpConnectorState.Failed; + + public bool IsUnknown => !IsConnected && !NeedsSignIn && !IsFailed; + + public bool CanSignIn => IsRemote && Enabled; + + public string StatusText => !Enabled + ? "Disabled" + : Status?.State switch + { + McpConnectorState.Connected => "Connected", + McpConnectorState.NeedsSignIn => "Sign-in required", + McpConnectorState.Failed => "Failed", + McpConnectorState.Pending => "Starting…", + _ => "Not checked" + }; + + public string? StatusMessage => Status?.Message; + + public bool HasStatusMessage => Enabled && !string.IsNullOrWhiteSpace(StatusMessage); + + public IAsyncRelayCommand EditCommand { get; } + + public IAsyncRelayCommand RemoveCommand { get; } + + public IAsyncRelayCommand CheckCommand { get; } + + public IAsyncRelayCommand SignInCommand { get; } + + internal void SetConnector(McpConnector connector, McpConnectorStatus? status) + { + _connector = connector; + _enabled = connector.Enabled; + OnPropertyChanged(string.Empty); + Status = status; + } + + private async Task CheckAsync() + { + if (IsBusy) return; + IsBusy = true; + try + { + Status = await _owner.Hub.CheckAsync(await _owner.Hub.ResolveAsync(Connector), report: true); + } + catch (Exception e) + { + Status = new McpConnectorStatus(McpConnectorState.Failed, e.Message); + } + finally + { + IsBusy = false; + } + } + + private async Task SignInAsync() + { + if (IsBusy) return; + IsBusy = true; + try + { + Status = await _owner.Hub.SignInAsync(Name); + } + catch (Exception e) + { + Status = new McpConnectorStatus(McpConnectorState.Failed, e.Message); + } + finally + { + IsBusy = false; + } + } + + private async Task ApplyEnabledAsync(bool enabled) + { + try + { + await _owner.Hub.SetEnabledAsync(Connector.Id, enabled); + } + catch (Exception e) + { + _owner.ShowMessage($"Failed to {(enabled ? "enable" : "disable")} '{Name}': {e.Message}", true); + } + } +} diff --git a/src/OneWare.Copilot/Views/CopilotChatExtensionView.axaml b/src/OneWare.Copilot/Views/CopilotChatExtensionView.axaml index 983c388e..172662c1 100644 --- a/src/OneWare.Copilot/Views/CopilotChatExtensionView.axaml +++ b/src/OneWare.Copilot/Views/CopilotChatExtensionView.axaml @@ -117,7 +117,8 @@ @@ -130,8 +131,16 @@ - - + + + + + + + diff --git a/src/OneWare.Copilot/Views/CopilotChatHeaderView.axaml b/src/OneWare.Copilot/Views/CopilotChatHeaderView.axaml index 29cefb29..f03fc296 100644 --- a/src/OneWare.Copilot/Views/CopilotChatHeaderView.axaml +++ b/src/OneWare.Copilot/Views/CopilotChatHeaderView.axaml @@ -3,14 +3,13 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:services="clr-namespace:OneWare.Copilot.Services" - xmlns:behaviors="clr-namespace:OneWare.Essentials.Behaviors;assembly=OneWare.Essentials" + xmlns:controls="clr-namespace:OneWare.Essentials.Controls;assembly=OneWare.Essentials" mc:Ignorable="d" d:DesignWidth="30" d:DesignHeight="30" x:Class="OneWare.Copilot.Views.CopilotChatHeaderView" x:DataType="services:CopilotChatServiceBase"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OneWare.Copilot/Views/CopilotChatHeaderView.axaml.cs b/src/OneWare.Copilot/Views/CopilotChatHeaderView.axaml.cs index fbd97ea8..34df21c9 100644 --- a/src/OneWare.Copilot/Views/CopilotChatHeaderView.axaml.cs +++ b/src/OneWare.Copilot/Views/CopilotChatHeaderView.axaml.cs @@ -1,4 +1,5 @@ using Avalonia.Controls; +using OneWare.Copilot.Services; namespace OneWare.Copilot.Views; @@ -8,4 +9,10 @@ public CopilotChatHeaderView() { InitializeComponent(); } + + private void OnFlyoutOpened(object? sender, EventArgs e) + { + if (DataContext is CopilotChatServiceBase { RefreshMcpServersCommand: { IsRunning: false } refresh }) + refresh.Execute(null); + } } diff --git a/src/OneWare.Copilot/Views/CopilotConnectorEditorView.axaml b/src/OneWare.Copilot/Views/CopilotConnectorEditorView.axaml new file mode 100644 index 00000000..91b2e495 --- /dev/null +++ b/src/OneWare.Copilot/Views/CopilotConnectorEditorView.axaml @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OneWare.Copilot/Views/CopilotConnectorEditorView.axaml.cs b/src/OneWare.Copilot/Views/CopilotConnectorEditorView.axaml.cs new file mode 100644 index 00000000..d9108720 --- /dev/null +++ b/src/OneWare.Copilot/Views/CopilotConnectorEditorView.axaml.cs @@ -0,0 +1,11 @@ +using OneWare.Essentials.Controls; + +namespace OneWare.Copilot.Views; + +public partial class CopilotConnectorEditorView : FlexibleWindow +{ + public CopilotConnectorEditorView() + { + InitializeComponent(); + } +} diff --git a/src/OneWare.Copilot/Views/CopilotConnectorsSettingView.axaml b/src/OneWare.Copilot/Views/CopilotConnectorsSettingView.axaml new file mode 100644 index 00000000..9e099514 --- /dev/null +++ b/src/OneWare.Copilot/Views/CopilotConnectorsSettingView.axaml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OneWare.Copilot/Views/CopilotConnectorsSettingView.axaml.cs b/src/OneWare.Copilot/Views/CopilotConnectorsSettingView.axaml.cs new file mode 100644 index 00000000..2eed503f --- /dev/null +++ b/src/OneWare.Copilot/Views/CopilotConnectorsSettingView.axaml.cs @@ -0,0 +1,43 @@ +using Avalonia; +using Avalonia.Controls; +using OneWare.Copilot.ViewModels; + +namespace OneWare.Copilot.Views; + +public partial class CopilotConnectorsSettingView : UserControl +{ + private CopilotConnectorsSettingViewModel? _attached; + + public CopilotConnectorsSettingView() + { + InitializeComponent(); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Attach(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _attached?.Detach(); + _attached = null; + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + if (VisualRoot == null) return; + _attached?.Detach(); + _attached = null; + Attach(); + } + + private void Attach() + { + _attached = DataContext as CopilotConnectorsSettingViewModel; + _attached?.Attach(); + } +} diff --git a/src/OneWare.Copilot/Views/CopilotConnectorsWindow.axaml b/src/OneWare.Copilot/Views/CopilotConnectorsWindow.axaml new file mode 100644 index 00000000..147f6c35 --- /dev/null +++ b/src/OneWare.Copilot/Views/CopilotConnectorsWindow.axaml @@ -0,0 +1,16 @@ + + + + + diff --git a/src/OneWare.Copilot/Views/CopilotConnectorsWindow.axaml.cs b/src/OneWare.Copilot/Views/CopilotConnectorsWindow.axaml.cs new file mode 100644 index 00000000..8a5aca04 --- /dev/null +++ b/src/OneWare.Copilot/Views/CopilotConnectorsWindow.axaml.cs @@ -0,0 +1,11 @@ +using OneWare.Essentials.Controls; + +namespace OneWare.Copilot.Views; + +public partial class CopilotConnectorsWindow : FlexibleWindow +{ + public CopilotConnectorsWindow() + { + InitializeComponent(); + } +} diff --git a/src/OneWare.Core/App.axaml.cs b/src/OneWare.Core/App.axaml.cs index db4fb164..83c0d560 100644 --- a/src/OneWare.Core/App.axaml.cs +++ b/src/OneWare.Core/App.axaml.cs @@ -84,6 +84,7 @@ protected virtual void RegisterServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/OneWare.Core/OneWare.Core.csproj b/src/OneWare.Core/OneWare.Core.csproj index bc2a065e..094e057b 100644 --- a/src/OneWare.Core/OneWare.Core.csproj +++ b/src/OneWare.Core/OneWare.Core.csproj @@ -5,6 +5,7 @@ + diff --git a/src/OneWare.Core/Services/MainDockService.cs b/src/OneWare.Core/Services/MainDockService.cs index 3689e133..1bce7652 100644 --- a/src/OneWare.Core/Services/MainDockService.cs +++ b/src/OneWare.Core/Services/MainDockService.cs @@ -117,9 +117,35 @@ public IExtendedDocument? CurrentDocument { if (value == field) return; field = value; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CurrentDocument))); + RaiseCurrentDocumentChanged(); } } + + // A throwing handler must neither skip the others nor reach the focus subscription in the Layout setter, which + // would end it and leave CurrentDocument stuck. + private void RaiseCurrentDocumentChanged() + { + if (PropertyChanged is not { } handlers) return; + + var args = new PropertyChangedEventArgs(nameof(CurrentDocument)); + foreach (var handler in handlers.GetInvocationList().Cast()) + try + { + handler(this, args); + } + catch (Exception e) + { + ContainerLocator.Container.Resolve() + .LogError(e, "A handler for the current document change failed."); + } + } + + // Covers documents in floating windows too, whose focus is tracked by their window's root dock instead of Layout. + public override void OnFocusedDockableChanged(IDockable? dockable) + { + base.OnFocusedDockableChanged(dockable); + if (dockable is IExtendedDocument document) CurrentDocument = document; + } public override void FloatDockable(IDockable dockable) { @@ -404,7 +430,11 @@ public IEnumerable SearchView(IDockable? layout = null) public override void OnDockableClosed(IDockable? dockable) { base.OnDockableClosed(dockable); - if (dockable == CurrentDocument) CurrentDocument = null; + // Usually the next tab got focus already; otherwise continue with the tab that is shown now. + if (dockable == CurrentDocument) + CurrentDocument = new[] { (dockable?.Owner as IDock)?.ActiveDockable, _mainDocumentDockViewModel.ActiveDockable } + .OfType() + .FirstOrDefault(x => x != dockable); } public void ResetLayout() diff --git a/src/OneWare.Core/Services/SecretStore.cs b/src/OneWare.Core/Services/SecretStore.cs new file mode 100644 index 00000000..f4b27d59 --- /dev/null +++ b/src/OneWare.Core/Services/SecretStore.cs @@ -0,0 +1,58 @@ +using System.Security.Cryptography; +using System.Text; +using GitCredentialManager; +using OneWare.Essentials.Services; + +namespace OneWare.Core.Services; + +/// +/// Windows stores secrets in DPAPI-encrypted files because Credential Manager entries are limited to 2.5 KB, which +/// long tokens exceed. Other platforms use the system credential store through Git Credential Manager. +/// +public sealed class SecretStore(IPaths paths) : ISecretStore +{ + private const string Namespace = "oneware"; + + public string? Get(string service, string account) + { + if (!OperatingSystem.IsWindows()) return CredentialManager.Create(Namespace).Get(service, account)?.Password; + + var path = GetWindowsPath(service, account); + if (!File.Exists(path)) return null; + var plaintext = ProtectedData.Unprotect(File.ReadAllBytes(path), null, DataProtectionScope.CurrentUser); + return Encoding.UTF8.GetString(plaintext); + } + + public void Set(string service, string account, string secret) + { + if (!OperatingSystem.IsWindows()) + { + CredentialManager.Create(Namespace).AddOrUpdate(service, account, secret); + return; + } + + var path = GetWindowsPath(service, account); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, + ProtectedData.Protect(Encoding.UTF8.GetBytes(secret), null, DataProtectionScope.CurrentUser)); + } + + public void Remove(string service, string account) + { + if (!OperatingSystem.IsWindows()) + { + CredentialManager.Create(Namespace).Remove(service, account); + return; + } + + var path = GetWindowsPath(service, account); + if (File.Exists(path)) File.Delete(path); + } + + // Hashed, because services and accounts may be URLs or contain characters that are invalid in file names. + private string GetWindowsPath(string service, string account) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{service}\n{account}")); + return Path.Combine(paths.AppDataDirectory, "Secrets", Convert.ToHexString(hash) + ".bin"); + } +} diff --git a/src/OneWare.Essentials/Models/ChatServiceEvents.cs b/src/OneWare.Essentials/Models/ChatServiceEvents.cs index e14af84d..e3082886 100644 --- a/src/OneWare.Essentials/Models/ChatServiceEvents.cs +++ b/src/OneWare.Essentials/Models/ChatServiceEvents.cs @@ -151,6 +151,28 @@ public sealed class ChatSkillLoadedEvent(string skillName, string content) public string Content { get; } = content; } +/// +/// An image the AI received from a tool (for example a screenshot of an MCP server or an image file it read). +/// The chat UI shows it below the tool call and keeps it in the chat history. +/// +public sealed class ChatImageEvent(byte[] data, string mimeType) + : ChatEvent() +{ + /// Encoded image bytes (PNG, JPEG, GIF, WebP, …). + public byte[] Data { get; } = data; + + public string MimeType { get; } = mimeType; + + /// Short description of the image, when the tool provides one. + public string? Description { get; init; } + + /// Id of the tool call that returned the image, when known. + public string? ToolCallId { get; init; } + + /// Name of the tool that returned the image, when known. + public string? ToolName { get; init; } +} + public sealed class ChatErrorEvent(string message) : ChatEvent() { diff --git a/src/OneWare.Essentials/Models/OneWareAiFunction.cs b/src/OneWare.Essentials/Models/OneWareAiFunction.cs index 065766b5..9a7431ca 100644 --- a/src/OneWare.Essentials/Models/OneWareAiFunction.cs +++ b/src/OneWare.Essentials/Models/OneWareAiFunction.cs @@ -38,6 +38,13 @@ public interface IOneWareAiFunction /// function that changes files, the IDE or the system must leave this . /// bool IsReadOnly => false; + + /// + /// Whether the function is always sent to the model. Chats that defer tools behind a tool search (because MCP + /// servers add hundreds of tools) only load the others when the model searches for them, so mark only the few + /// functions almost every request needs. + /// + bool AlwaysLoaded => false; } public sealed class AiFunctionInvocationContext(string id, Action reportProgress) @@ -70,4 +77,7 @@ public sealed class OneWareAiFunction : IOneWareAiFunction /// public bool IsReadOnly { get; init; } + + /// + public bool AlwaysLoaded { get; init; } } diff --git a/src/OneWare.Essentials/Services/IAiFunctionProvider.cs b/src/OneWare.Essentials/Services/IAiFunctionProvider.cs index 2895b5b9..17796f25 100644 --- a/src/OneWare.Essentials/Services/IAiFunctionProvider.cs +++ b/src/OneWare.Essentials/Services/IAiFunctionProvider.cs @@ -101,6 +101,11 @@ void RegisterSkillDirectory(string directory) /// bool? IsFunctionReadOnly(string functionName) => null; + /// + /// Returns whether the named function is registered and marked . + /// + bool IsFunctionAlwaysLoaded(string functionName) => false; + /// /// Returns the delegate for the named function, /// or if the function has no check or is not registered. diff --git a/src/OneWare.Essentials/Services/ISecretStore.cs b/src/OneWare.Essentials/Services/ISecretStore.cs new file mode 100644 index 00000000..4e48cd26 --- /dev/null +++ b/src/OneWare.Essentials/Services/ISecretStore.cs @@ -0,0 +1,20 @@ +namespace OneWare.Essentials.Services; + +/// +/// Keeps secrets such as refresh tokens and API keys out of the settings file: in the operating system's credential +/// store (Keychain, Secret Service) or, on Windows, in files encrypted for the current user. +/// +/// Methods may block on the platform store and throw if it is unavailable; call them off the UI thread. +public interface ISecretStore +{ + /// Returns the secret, or null if none is stored. + /// Identifies the owner of the secret, e.g. a host URL or oneware-mcp. + /// Identifies the secret within the service, e.g. a user id. + string? Get(string service, string account); + + /// Stores or replaces the secret. + void Set(string service, string account, string secret); + + /// Deletes the secret if it exists. + void Remove(string service, string account); +} diff --git a/tests/OneWare.Chat.UnitTests/ChatImageStoreTests.cs b/tests/OneWare.Chat.UnitTests/ChatImageStoreTests.cs new file mode 100644 index 00000000..5c455973 --- /dev/null +++ b/tests/OneWare.Chat.UnitTests/ChatImageStoreTests.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using OneWare.Chat.Services; +using OneWare.Chat.ViewModels; +using OneWare.Chat.ViewModels.ChatMessages; +using Xunit; + +namespace OneWare.Chat.UnitTests; + +public sealed class ChatImageStoreTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "oneware-chat-images-" + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + if (Directory.Exists(_root)) Directory.Delete(_root, true); + } + + [Fact] + public void Store_WritesEachImageOncePerSession() + { + var store = new ChatImageStore(_root); + byte[] data = [1, 2, 3]; + + var first = store.Store("session-1", data, "image/png"); + var second = store.Store("session-1", data, "image/png"); + var other = store.Store("session-2", data, "image/jpeg"); + + Assert.Equal(first, second); + Assert.Equal(".png", Path.GetExtension(first)); + Assert.Equal(".jpg", Path.GetExtension(other)); + Assert.Equal(data, File.ReadAllBytes(first)); + Assert.Single(Directory.GetFiles(store.GetSessionDirectory("session-1"))); + } + + [Fact] + public void DeleteSession_RemovesOnlyThatSession() + { + var store = new ChatImageStore(_root); + var kept = store.Store("keep", [1], "image/png"); + var removed = store.Store("remove", [1], "image/png"); + + store.DeleteSession("remove"); + + Assert.True(File.Exists(kept)); + Assert.False(File.Exists(removed)); + } + + [Theory] + [InlineData("../escape")] + [InlineData("..")] + [InlineData("a/b\\c")] + public void GetSessionDirectory_StaysInsideTheRoot(string sessionId) + { + var directory = new ChatImageStore(_root).GetSessionDirectory(sessionId); + + Assert.Equal(Path.GetFullPath(_root), Path.GetDirectoryName(Path.GetFullPath(directory))); + } + + [Fact] + public void Transcript_KeepsImageMessages() + { + var image = new ChatMessageImageViewModel("/tmp/image.png", "image/png") + { + Description = "Screenshot", + ToolName = "browser_take_screenshot" + }; + + var state = ChatTranscript.BuildMessageState(image); + Assert.NotNull(state); + Assert.True(ChatTranscript.TryCreateMessage(state, out var restored)); + + var restoredImage = Assert.IsType(restored); + Assert.Equal(image.FilePath, restoredImage.FilePath); + Assert.Equal(image.MimeType, restoredImage.MimeType); + Assert.Equal("Screenshot", restoredImage.Caption); + Assert.Equal(image.ToolName, restoredImage.ToolName); + } +} diff --git a/tests/OneWare.Copilot.UnitTests/ConnectorTests.cs b/tests/OneWare.Copilot.UnitTests/ConnectorTests.cs new file mode 100644 index 00000000..c95d5cc4 --- /dev/null +++ b/tests/OneWare.Copilot.UnitTests/ConnectorTests.cs @@ -0,0 +1,416 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using GitHub.Copilot; +using Microsoft.Extensions.Logging.Abstractions; +using OneWare.Copilot.Connectors; +using OneWare.Essentials.Services; +using Xunit; + +namespace OneWare.Copilot.UnitTests; + +public sealed class ConnectorTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "OneWare-Connectors-" + Guid.NewGuid()); + private readonly List _warnings = []; + private readonly FakeSecretStore _secrets = new(); + private readonly List _hubs = []; + + public ConnectorTests() + { + Directory.CreateDirectory(_root); + } + + private IReadOnlyList Import(string json) + { + var file = Path.Combine(_root, ".mcp.json"); + File.WriteAllText(file, json); + return McpConnectorImport.Read(file, _warnings.Add); + } + + private McpConnectorHub CreateHub() + { + var hub = new McpConnectorHub(_secrets, new FakePaths(Path.Combine(_root, "appdata")), NullLogger.Instance); + _hubs.Add(hub); + return hub; + } + + private static JsonObject Json(string json) => JsonNode.Parse(json)!.AsObject(); + + [Fact] + public void FromConfig_ReadsCliConfigurationsAndKeepsUnknownFields() + { + // The CLI omits "type" for stdio servers. + var local = McpConnector.FromConfig("mail", Json(""" + {"command":"npx","args":["-y","ms365"],"env":{"TOKEN":"${MS_TOKEN}"},"cwd":"/work","tools":["read"], + "timeout":5000,"disabled":true} + """)); + + Assert.Equal(McpConnectorKind.Local, local.Kind); + Assert.False(local.Enabled); + Assert.Equal("npx -y ms365", local.Describe()); + Assert.Equal([new McpConnectorValue("TOKEN", "${MS_TOKEN}", false)], local.Env); + + var config = local.ToCliConfig(); + Assert.Equal("stdio", config["type"]!.GetValue()); + Assert.Equal("/work", config["cwd"]!.GetValue()); + Assert.Equal(5000, config["timeout"]!.GetValue()); + Assert.Equal("read", config["tools"]![0]!.GetValue()); + Assert.Null(config["disabled"]); + + var remote = McpConnector.FromConfig("asana", Json(""" + {"type":"sse","url":"https://mcp.asana.com/sse","headers":{"X":"1"},"oauthClientId":"abc","oauthPublicClient":false} + """)); + + Assert.Equal(McpConnectorKind.Remote, remote.Kind); + Assert.True(remote.UseSse); + Assert.False(remote.OAuthPublicClient); + var remoteConfig = remote.ToCliConfig(); + Assert.Equal("sse", remoteConfig["type"]!.GetValue()); + Assert.Equal("abc", remoteConfig["oauthClientId"]!.GetValue()); + Assert.Null(remoteConfig["command"]); + Assert.Equal("*", remoteConfig["tools"]![0]!.GetValue()); + } + + [Fact] + public void StoredConfig_LeavesSecretValuesOutAndRoundTrips() + { + var connector = new McpConnector + { + Name = "gh", + Kind = McpConnectorKind.Remote, + Url = "https://api.githubcopilot.com/mcp/", + Enabled = false, + Headers = + [ + new McpConnectorValue("Authorization", "Bearer s3cret", true), + new McpConnectorValue("X-Mode", "${MODE}", false) + ] + }; + + var stored = connector.ToStoredConfig(); + Assert.DoesNotContain("s3cret", stored.ToJsonString()); + Assert.Equal(connector.Id, stored[McpConnector.IdField]!.GetValue()); + Assert.True(stored[McpConnector.DisabledField]!.GetValue()); + Assert.Null(stored["tools"]); + + var loaded = McpConnector.FromConfig("gh", stored); + Assert.Equal(connector.Id, loaded.Id); + Assert.False(loaded.Enabled); + Assert.Equal(new McpConnectorValue("Authorization", "", true), loaded.Headers.Single(x => x.IsSecret)); + Assert.False(loaded.ToCliConfig(includeSecrets: false).ContainsKey("tools")); + + var secrets = connector.GetSecrets("client-secret"); + Assert.Equal("Bearer s3cret", secrets.Headers["Authorization"]); + Assert.Equal("client-secret", secrets.OAuthClientSecret); + + var resolved = loaded.WithSecrets(secrets); + var config = Assert.IsType(resolved.ToServerConfig()); + Assert.Equal("Bearer s3cret", config.Headers!["Authorization"]); + Assert.Equal("${MODE}", config.Headers["X-Mode"]); + // Secret values never count as ${VAR} references. + Assert.Equal(["MODE"], resolved.GetVariables()); + + resolved.UseSse = true; + Assert.Null(resolved.ToServerConfig()); + Assert.Equal("sse", resolved.ToCliConfig()["type"]!.GetValue()); + } + + [Fact] + public void GetVariables_ListsReferencesOfTheActiveKind() + { + var connector = new McpConnector + { + Kind = McpConnectorKind.Local, + Command = "${TOOLS}/server", + Args = ["--token", "${B_TOKEN}", "${A}"], + Env = [new McpConnectorValue("KEY", "prefix-${B_TOKEN}", false)], + Headers = [new McpConnectorValue("Authorization", "${HEADER_ONLY}", false)] + }; + + Assert.Equal(["A", "B_TOKEN", "TOOLS"], connector.GetVariables()); + + connector.Kind = McpConnectorKind.Remote; + Assert.Equal(["HEADER_ONLY"], connector.GetVariables()); + } + + [Fact] + public void Names_AndSecretKeys_AreRecognized() + { + Assert.True(McpConnector.IsValidName("ms365.mail_v2-x")); + Assert.False(McpConnector.IsValidName("has space")); + Assert.False(McpConnector.IsValidName("-._")); + + Assert.True(McpConnector.LooksSecret("Authorization")); + Assert.True(McpConnector.LooksSecret("GITHUB_PERSONAL_ACCESS_TOKEN")); + Assert.True(McpConnector.LooksSecret("x-api-key")); + Assert.False(McpConnector.LooksSecret("LOG_LEVEL")); + } + + [Fact] + public void Import_KeepsVariableReferencesAndMarksCredentialsSecret() + { + var imported = Import(""" + { + // comments are allowed + "mcpServers": { + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { "Authorization": "Bearer abc", "X-Ref": "${GH_TOKEN}" } + }, + "mail": { + "command": "npx", + "args": ["-y", "ms365", "${workspaceFolder}/data", "${HOME_DIR:-/tmp}"], + "env": { "TOKEN": "${MS_TOKEN}" }, + "disabled": true + }, + "asana": { + "url": "https://mcp.asana.com/v2/mcp", + "oauthClientId": "client", + "oauthClientSecret": "secret" + }, + } + } + """); + + Assert.Equal(["github", "mail", "asana"], imported.Select(x => x.Connector.Name)); + + var github = imported[0].Connector; + Assert.True(github.Headers.Single(x => x.Key == "Authorization").IsSecret); + // References are resolved by the runtime and stay in mcp.json. + Assert.False(github.Headers.Single(x => x.Key == "X-Ref").IsSecret); + + var mail = imported[1].Connector; + Assert.False(mail.Enabled); + Assert.Equal(["-y", "ms365", $"{_root}/data", "${HOME_DIR}"], mail.Args); + Assert.False(mail.Env.Single().IsSecret); + // A project server ran in its folder. + Assert.Equal(_root, mail.WorkingDirectory); + Assert.Contains(_warnings, x => x.Contains("HOME_DIR")); + + var asana = imported[2]; + Assert.Equal(McpConnectorKind.Remote, asana.Connector.Kind); + Assert.Equal("client", asana.Connector.OAuthClientId); + Assert.Equal("secret", asana.OAuthClientSecret); + Assert.False(asana.Connector.ToCliConfig().ContainsKey("oauthClientSecret")); + } + + [Fact] + public void Import_AcceptsVsCodeAndClaudeFormatsAndReportsInvalidServers() + { + var vsCode = Import("""{"servers":{"a":{"type":"stdio","command":"a","cwd":"sub"}}}"""); + Assert.Equal(Path.Combine(_root, "sub"), vsCode.Single().Connector.WorkingDirectory); + + var claude = Import(""" + {"b":{"type":"streamable-http","url":"https://b.dev"},"c":{"type":"websocket","url":"ws://c"}, + "bad name":{"command":"x"},"d":{"type":"stdio"}} + """); + Assert.Equal("b", claude.Single().Connector.Name); + Assert.Contains(_warnings, x => x.Contains("websocket")); + Assert.Contains(_warnings, x => x.Contains("bad name")); + Assert.Contains(_warnings, x => x.StartsWith("d:")); + + Assert.Empty(Import("{ not json")); + Assert.Contains(_warnings, x => x.Contains("not valid JSON")); + } + + [Theory] + [InlineData("""{"command":"npx","args":["-y","pkg"]}""", "fallback")] + [InlineData(""" + "github": {"command":"npx","args":["-y","pkg"]}, + """, "github")] + [InlineData("""{"mcpServers":{"github":{"command":"npx","args":["-y","pkg"]}}}""", "github")] + [InlineData("""{"servers":{"github":{"command":"npx","args":["-y","pkg"]}}}""", "github")] + public void ParseText_AcceptsPastedSnippets(string text, string expectedName) + { + var connector = McpConnectorImport.ParseText(text, _warnings.Add, "fallback").Single().Connector; + + Assert.Equal(expectedName, connector.Name); + Assert.Equal("npx", connector.Command); + Assert.Equal(["-y", "pkg"], connector.Args); + Assert.Null(connector.WorkingDirectory); + } + + [Fact] + public void ParseText_KeepsWorkspaceFolderAndWarns() + { + var connector = McpConnectorImport.ParseText("""{"command":"srv","args":["${workspaceFolder}"]}""", + _warnings.Add).Single().Connector; + + Assert.Equal(["${workspaceFolder}"], connector.Args); + Assert.Contains(_warnings, x => x.Contains("workspaceFolder")); + Assert.Empty(McpConnectorImport.ParseText("[]", _warnings.Add)); + } + + [Fact] + public void RuntimeEnvironment_LayersLoginShellAndProcess() + { + var sep = Path.PathSeparator; + var loginShell = new Dictionary + { + ["PATH"] = $"/home/u/.nvm/bin{sep}/usr/bin", + ["TOKEN"] = "from-shell", + ["SHELL_ONLY"] = "1" + }; + var process = new Dictionary + { + ["PATH"] = $"/usr/bin{sep}/app/bin", + ["TOKEN"] = "from-process" + }; + var env = CopilotRuntimeEnvironment.Merge(process, loginShell, ignoreCase: false); + + Assert.Equal($"/home/u/.nvm/bin{sep}/usr/bin{sep}/app/bin", env["PATH"]); + Assert.Equal("from-process", env["TOKEN"]); + Assert.Equal("1", env["SHELL_ONLY"]); + + var withoutShell = CopilotRuntimeEnvironment.Merge(process, new Dictionary(), + ignoreCase: false); + Assert.Equal(process["PATH"], withoutShell["PATH"]); + Assert.Equal("from-process", withoutShell["TOKEN"]); + } + + [Fact] + public void SignInErrors_ExplainMissingAppRegistration() + { + var message = McpConnectorHub.DescribeSignInError( + "Client registration failed: Authorization server does not support dynamic client registration"); + + Assert.Contains(McpConnectorHub.OAuthRedirectUrl, message); + Assert.True(McpConnectorHub.IsDynamicRegistrationError(message)); + Assert.Equal("other", McpConnectorHub.DescribeSignInError("other")); + Assert.False(McpConnectorHub.IsDynamicRegistrationError("other")); + } + + [Fact] + public async Task Hub_SavesSecretsInTheStoreAndRenamesInPlace() + { + var hub = CreateHub(); + Assert.Empty(await hub.GetConnectorsAsync()); + + var connector = new McpConnector + { + Name = "github", + Kind = McpConnectorKind.Local, + Command = "npx", + Env = [new McpConnectorValue("GITHUB_TOKEN", "ghp_123", true)] + }; + await hub.SaveAsync(connector, null); + await hub.SaveAsync(new McpConnector { Name = "zeta", Command = "z" }, null); + + var file = await File.ReadAllTextAsync(hub.ConfigFile); + Assert.DoesNotContain("ghp_123", file); + Assert.Contains("ghp_123", _secrets.Get(McpConnectorHub.SecretService, connector.Id)); + + // A second hub reads the file from disk. + var other = CreateHub(); + var loaded = (await other.GetConnectorsAsync()).First(); + Assert.Equal("github", loaded.Name); + Assert.Equal("ghp_123", (await other.ResolveAsync(loaded)).Env.Single().Value); + + var renamed = (await other.ResolveAsync(loaded)).Clone(); + renamed.Name = "gh"; + await other.SaveAsync(renamed, null); + Assert.Equal(["gh", "zeta"], (await other.GetConnectorsAsync()).Select(x => x.Name)); + // The file keeps its order. + var keys = JsonNode.Parse(await File.ReadAllTextAsync(hub.ConfigFile))!["mcpServers"]!.AsObject() + .Select(x => x.Key); + Assert.Equal(["gh", "zeta"], keys); + + await Assert.ThrowsAsync(() => + other.SaveAsync(new McpConnector { Name = "GH", Command = "x" }, null)); + + var resolved = Assert.Single(await other.ResolveEnabledAsync(), x => x.Name == "gh"); + Assert.Equal("ghp_123", resolved.Config["env"]!["GITHUB_TOKEN"]!.GetValue()); + var fingerprint = resolved.Fingerprint; + + await other.SetEnabledAsync(loaded.Id, false); + Assert.DoesNotContain(await other.ResolveEnabledAsync(), x => x.Name == "gh"); + await other.SetEnabledAsync(loaded.Id, true); + Assert.Equal(fingerprint, (await other.ResolveEnabledAsync()).Single(x => x.Name == "gh").Fingerprint); + + await other.RemoveAsync(loaded.Id); + Assert.Null(_secrets.Get(McpConnectorHub.SecretService, loaded.Id)); + Assert.Equal(["zeta"], (await other.GetConnectorsAsync()).Select(x => x.Name)); + } + + [Fact] + public async Task Hub_AssignsIdsToHandWrittenEntriesAndReportsErrors() + { + var hub = CreateHub(); + Directory.CreateDirectory(Path.GetDirectoryName(hub.ConfigFile)!); + await File.WriteAllTextAsync(hub.ConfigFile, """ + { + "mcpServers": { + "a": { "command": "a" }, + "bad name": { "command": "b" } + } + } + """); + + var connector = Assert.Single(await hub.GetConnectorsAsync()); + Assert.False(string.IsNullOrEmpty(connector.Id)); + Assert.Contains(connector.Id, await File.ReadAllTextAsync(hub.ConfigFile)); + + var added = await hub.AddAsync(McpConnectorImport.ParseText(""" + {"mcpServers":{"A":{"command":"dup"},"new":{"url":"https://x.dev/mcp","headers":{"Authorization":"Bearer t"}}}} + """, _warnings.Add), _warnings.Add); + Assert.Equal(["new"], added); + Assert.Contains(_warnings, x => x.Contains("'A' already exists")); + + var broken = CreateHub(); + await File.WriteAllTextAsync(broken.ConfigFile, "{ broken"); + Assert.Empty(await broken.GetConnectorsAsync()); + Assert.NotNull(broken.LoadError); + await Assert.ThrowsAsync(() => + broken.SaveAsync(new McpConnector { Name = "x", Command = "x" }, null)); + Assert.Equal("{ broken", await File.ReadAllTextAsync(broken.ConfigFile)); + } + + public void Dispose() + { + foreach (var hub in _hubs) hub.Dispose(); + try + { + Directory.Delete(_root, true); + } + catch (IOException) + { + } + } + + private sealed class FakeSecretStore : ISecretStore + { + private readonly Dictionary<(string, string), string> _values = new(); + + public string? Get(string service, string account) => _values.GetValueOrDefault((service, account)); + + public void Set(string service, string account, string secret) => _values[(service, account)] = secret; + + public void Remove(string service, string account) => _values.Remove((service, account)); + } + + private sealed class FakePaths(string root) : IPaths + { + public string AppName => "OneWare"; + public string AppIconPath => string.Empty; + public string AppFolderName => "OneWare"; + public string AppDataDirectory => root; + public string TempDirectory => Path.Combine(root, "temp"); + public string SessionDirectory => Path.Combine(root, "session"); + public string LayoutDirectory => Path.Combine(root, "layout"); + public string SettingsPath => Path.Combine(root, "settings.json"); + public string DocumentsDirectory => root; + public string CrashReportsDirectory => root; + public string ProjectsDirectory => root; + public string PackagesDirectory => root; + public string NativeToolsDirectory => root; + public string OnnxRuntimesDirectory => root; + public string PluginsDirectory => root; + public string ChangelogUrl => string.Empty; + public string UpdateInfoUrl => string.Empty; + } +} diff --git a/tests/OneWare.Copilot.UnitTests/CopilotAutoTiersTests.cs b/tests/OneWare.Copilot.UnitTests/CopilotAutoTiersTests.cs new file mode 100644 index 00000000..dd850a06 --- /dev/null +++ b/tests/OneWare.Copilot.UnitTests/CopilotAutoTiersTests.cs @@ -0,0 +1,125 @@ +#pragma warning disable GHCP001 // Auto tier metadata is experimental in the SDK. +using System.Collections.Generic; +using System.Linq; +using GitHub.Copilot; +using GitHub.Copilot.Rpc; +using OneWare.Copilot.Services; +using Xunit; + +namespace OneWare.Copilot.UnitTests; + +public sealed class CopilotAutoTiersTests +{ + private static AutoTierDescriptor Tier(string id, string type = "auto", string? displayName = null, + bool enabled = true, bool? pickerVisible = null) => new() + { + Id = id, + Type = type, + DisplayName = displayName!, + Description = $"{id} description", + Status = new AutoTierStatus { Enabled = enabled, PickerVisible = pickerVisible } + }; + + [Fact] + public void Build_WithoutMetadata_OffersTheBuiltInTiers() + { + Assert.Same(CopilotAutoTiers.Fallback, CopilotAutoTiers.Build(null)); + Assert.Same(CopilotAutoTiers.Fallback, + CopilotAutoTiers.Build(new AutoTierMetadata { Tiers = new List() })); + } + + [Fact] + public void Fallback_UsesTheRuntimeTierIds() + { + var ids = CopilotAutoTiers.Fallback.Select(x => x.Id ?? "").ToArray(); + + Assert.Equal(["", AutoTier.Fast.Value, AutoTier.Efficiency.Value, AutoTier.Balance.Value, + AutoTier.Intelligence.Value], ids); + } + + [Fact] + public void Build_OffersEnabledAutoAndFusionTiersInProviderOrder() + { + var metadata = new AutoTierMetadata + { + DefaultTier = "balance", + Tiers = new List + { + Tier("fast"), + Tier("efficiency", displayName: "Eco"), + Tier("balance"), + Tier("hydrafusion", "fusion", "HydraFusion"), + Tier("hydrafusion-max", "fusion", "HydraFusion Max", enabled: false), + Tier("hidden", pickerVisible: false), + Tier("other", "byok") + } + }; + + var options = CopilotAutoTiers.Build(metadata); + + Assert.Equal(["Default", "Efficiency", "Balance", "HydraFusion"], options.Select(x => x.Label)); + Assert.Equal([false, false, false, true], options.Select(x => x.IsFusion)); + Assert.Contains("Balance", options[0].Description); + } + + [Fact] + public void Build_UsesTheIdWhenDisplayNamesCollide() + { + var options = CopilotAutoTiers.Build(new AutoTierMetadata + { + Tiers = new List { Tier("a", displayName: "Same"), Tier("b", displayName: "Same") } + }); + + Assert.Equal(["Default", "Same", "b"], options.Select(x => x.Label)); + } + + [Fact] + public void ResolveAndDescribe_RoundTripLabelsAndIds() + { + var options = CopilotAutoTiers.Build(new AutoTierMetadata + { + Tiers = new List { Tier("intelligence"), Tier("hydrafusion", "fusion", "HydraFusion") } + }); + + Assert.Equal("hydrafusion", CopilotAutoTiers.ResolveId(options, "HydraFusion")); + Assert.Null(CopilotAutoTiers.ResolveId(options, "Default")); + Assert.Null(CopilotAutoTiers.ResolveId(options, "Unknown")); + + Assert.Equal("HydraFusion", CopilotAutoTiers.Describe(options, "hydrafusion")); + Assert.Equal("Default", CopilotAutoTiers.Describe(options, null)); + Assert.Equal("Fast", CopilotAutoTiers.Describe(options, "fast")); + Assert.Equal("new-tier", CopilotAutoTiers.Describe(options, "new-tier")); + } + + [Fact] + public void WithRuntimeFeatureFlag_KeepsFlagsThatAreAlreadyEnabled() + { + var environment = new Dictionary + { + ["PATH"] = "/usr/bin", + [CopilotSessionConfiguration.EnabledFeatureFlagsVariable] = "SHELL_SPAWN_BACKEND, dynamic_auto_tiers" + }; + + var result = CopilotSessionConfiguration.WithRuntimeFeatureFlag(environment, + CopilotSessionConfiguration.DynamicAutoTiersFeatureFlag, false); + + Assert.Equal("/usr/bin", result["PATH"]); + Assert.Equal("SHELL_SPAWN_BACKEND,dynamic_auto_tiers", + result[CopilotSessionConfiguration.EnabledFeatureFlagsVariable]); + + var added = CopilotSessionConfiguration.WithRuntimeFeatureFlag(new Dictionary(), + CopilotSessionConfiguration.DynamicAutoTiersFeatureFlag, false); + Assert.Equal("DYNAMIC_AUTO_TIERS", added[CopilotSessionConfiguration.EnabledFeatureFlagsVariable]); + } + + [Fact] + public void ApplyFusion_EnablesTheHydraFusionSessionFlags() + { + var config = new SessionConfig { FeatureFlags = new Dictionary { ["OTHER"] = true } }; + + CopilotSessionConfiguration.ApplyFusion(config); + + Assert.True(config.FeatureFlags["OTHER"]); + Assert.All(CopilotSessionConfiguration.FusionFeatureFlags, x => Assert.True(config.FeatureFlags[x])); + } +} diff --git a/tests/OneWare.Copilot.UnitTests/CopilotSessionConfigurationTests.cs b/tests/OneWare.Copilot.UnitTests/CopilotSessionConfigurationTests.cs new file mode 100644 index 00000000..3b53ab66 --- /dev/null +++ b/tests/OneWare.Copilot.UnitTests/CopilotSessionConfigurationTests.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using GitHub.Copilot; +using Microsoft.Extensions.AI; +using OneWare.Copilot.Services; +using Xunit; + +namespace OneWare.Copilot.UnitTests; + +public sealed class CopilotSessionConfigurationTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "OneWare-Copilot-" + Guid.NewGuid()); + + public CopilotSessionConfigurationTests() + { + Directory.CreateDirectory(_root); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ApplyDiscovery_UsesTheSameCustomizationSourcesForCreateAndResume(bool resume) + { + SessionConfigBase config = resume ? new ResumeSessionConfig() : new SessionConfig(); + IList skills = [Path.Combine(_root, "skills")]; + + CopilotSessionConfiguration.ApplyDiscovery(config, _root, skills); + + Assert.Equal(_root, config.WorkingDirectory); + Assert.Equal(CopilotSessionConfiguration.GetConfigDirectory(), config.ConfigDirectory); + Assert.True(config.EnableConfigDiscovery); + Assert.True(config.EnableOnDemandInstructionDiscovery); + Assert.True(config.EnableSkills); + Assert.Same(skills, config.SkillDirectories); + Assert.False(config.EnableFileHooks); + } + + [Fact] + public void GetConfigDirectory_UsesCopilotHomeRatherThanProviderState() + { + var copilotHome = Environment.GetEnvironmentVariable("COPILOT_HOME"); + var expected = string.IsNullOrWhiteSpace(copilotHome) + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".copilot") + : copilotHome; + + Assert.Equal(Path.GetFullPath(expected), CopilotSessionConfiguration.GetConfigDirectory()); + } + + [Fact] + public void BuildSkillDirectories_MergesPluginAndUserRootsAndResolvesProjectRelativePaths() + { + var plugin = Directory.CreateDirectory(Path.Combine(_root, "plugin")).FullName; + var user = Directory.CreateDirectory(Path.Combine(_root, "user skills")).FullName; + var warnings = new List(); + + var directories = CopilotSessionConfiguration.BuildSkillDirectories( + [plugin], ["user skills", $" {plugin} ", "", " "], _root, warnings.Add); + + Assert.Equal([plugin, user], directories); + Assert.Empty(warnings); + } + + [Fact] + public void BuildSkillDirectories_ExpandsUserHomeAndDeduplicatesNormalizedPaths() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var warnings = new List(); + + var directories = CopilotSessionConfiguration.BuildSkillDirectories( + [], ["~", "~/.", home], _root, warnings.Add); + + Assert.Equal([Path.GetFullPath(home)], directories); + Assert.Empty(warnings); + } + + [Fact] + public void BuildSkillDirectories_ReportsMissingRootsWithoutDiscardingValidOnes() + { + var missing = Path.Combine(_root, "missing"); + var warnings = new List(); + + var directories = CopilotSessionConfiguration.BuildSkillDirectories( + [_root], ["missing"], _root, warnings.Add); + + Assert.Equal([_root], directories); + Assert.Equal([$"Skill directory does not exist: {missing}"], warnings); + } + + [Fact] + public void BuildAvailableTools_AllowsMcpWithoutEnablingHostBuiltIns() + { + var tools = CopilotSessionConfiguration.BuildAvailableTools(); + + Assert.Contains("custom:*", tools); + Assert.Contains("mcp:*", tools); + foreach (var name in BuiltInTools.Isolated) + Assert.Contains($"builtin:{name}", tools); + Assert.DoesNotContain("builtin:*", tools); + foreach (var name in new[] { "view", "edit", "create", "bash" }) + Assert.DoesNotContain($"builtin:{name}", tools); + } + + [Theory] + [InlineData("filesystem-write_file", false, true)] + [InlineData("github-list_issues", false, true)] + [InlineData("readFile", true, false)] + [InlineData("editFile", true, false)] + [InlineData("task", false, false)] + [InlineData("skill", false, false)] + [InlineData("ask_user", false, false)] + [InlineData("tool_search_tool", false, false)] + public void IsExternalTool_SeparatesMcpPermissionsFromTrustedIdeAndIsolatedTools( + string toolName, bool isClientTool, bool expected) + { + Assert.Equal(expected, CopilotSessionConfiguration.IsExternalTool(toolName, isClientTool)); + } + + [Fact] + public async Task ApplyToolDeferral_KeepsOnlyAlwaysLoadedToolsLoaded() + { + var core = AIFunctionFactory.Create(() => "content", new AIFunctionFactoryOptions + { + Name = "readFile", + AdditionalProperties = new Dictionary { ["custom"] = 1 } + }); + var plugin = AIFunctionFactory.Create(() => "x", "pluginTool"); + + var tools = CopilotSessionConfiguration.ApplyToolDeferral([core, plugin], name => name == "readFile"); + + var wrapped = Assert.IsAssignableFrom(tools[0]); + Assert.Equal("readFile", wrapped.Name); + Assert.Equal(CopilotToolDefer.Never, wrapped.AdditionalProperties["defer"]); + Assert.Equal(1, wrapped.AdditionalProperties["custom"]); + Assert.Equal("content", (await wrapped.InvokeAsync()).ToString()?.Trim('"')); + + Assert.Same(plugin, tools[1]); + Assert.False(plugin.AdditionalProperties.ContainsKey("defer")); + } + + public void Dispose() + { + Directory.Delete(_root, true); + } +} diff --git a/tests/OneWare.Copilot.UnitTests/CopilotToolImageTests.cs b/tests/OneWare.Copilot.UnitTests/CopilotToolImageTests.cs new file mode 100644 index 00000000..2d35bab5 --- /dev/null +++ b/tests/OneWare.Copilot.UnitTests/CopilotToolImageTests.cs @@ -0,0 +1,104 @@ +#pragma warning disable GHCP001 // Binary tool results are experimental in the SDK. +using System.Collections.Generic; +using System.Linq; +using GitHub.Copilot; +using OneWare.Copilot.Services; +using Xunit; + +namespace OneWare.Copilot.UnitTests; + +public sealed class CopilotToolImageTests +{ + private const string Png = "iVBORw0KGgo="; + private const string Jpeg = "/9j/4AAQ"; + + private static SessionBinaryAssetData? NoAssets(string _) => null; + + [Fact] + public void ExtractToolImages_ReturnsEachImageOnce() + { + var result = new ToolExecutionCompleteResult + { + Content = string.Empty, + Contents = + [ + new ToolExecutionCompleteContentImage { Data = Png, MimeType = "image/png" }, + new ToolExecutionCompleteContentImage { Data = "AAAA", MimeType = "audio/wav" } + ], + BinaryResultsForLlm = + [ + new PersistedBinaryResult(new PersistedBinaryImage + { + Type = PersistedBinaryImageType.Image, Data = Png, MimeType = "image/png", Description = "Screenshot" }), + new PersistedBinaryResult(new PersistedBinaryImage + { + Type = PersistedBinaryImageType.Image, Data = Jpeg, MimeType = "image/jpeg", Description = "Photo" }) + ] + }; + + var images = CopilotChatServiceBase.ExtractToolImages(result, NoAssets, out var missing); + + Assert.Equal([Png, Jpeg], images.Select(x => x.Base64)); + Assert.Equal("Photo", images[1].Description); + Assert.Empty(missing); + } + + [Fact] + public void ExtractToolImages_ResolvesAssetReferences() + { + var asset = new SessionBinaryAssetData { Type = BinaryAssetType.Image, ByteLength = 8, AssetId = "sha256:1", Data = Png, MimeType = "image/png" }; + var result = new ToolExecutionCompleteResult + { + Content = string.Empty, + BinaryResultsForLlm = + [ + new PersistedBinaryResult(new BinaryAssetReference + { + Type = BinaryAssetReferenceType.Image, ByteLength = 8, AssetId = "sha256:1", MimeType = "image/png", Description = "Generated" }), + new PersistedBinaryResult(new BinaryAssetReference { Type = BinaryAssetReferenceType.Image, ByteLength = 8, AssetId = "sha256:2", MimeType = "image/png" }) + ] + }; + + var images = CopilotChatServiceBase.ExtractToolImages(result, + id => id == asset.AssetId ? asset : null, out var missing); + + var image = Assert.Single(images); + Assert.Equal(Png, image.Base64); + Assert.Equal("Generated", image.Description); + Assert.Equal("sha256:2", Assert.Single(missing).AssetId); + } + + [Fact] + public void ExtractToolImages_DoesNotWaitForAssetsTheContentsAlreadyCarried() + { + var result = new ToolExecutionCompleteResult + { + Content = string.Empty, + Contents = [new ToolExecutionCompleteContentImage { Data = Png, MimeType = "image/png" }], + BinaryResultsForLlm = + [new PersistedBinaryResult(new BinaryAssetReference { Type = BinaryAssetReferenceType.Image, ByteLength = 8, AssetId = "sha256:1", MimeType = "image/png" })] + }; + + var images = CopilotChatServiceBase.ExtractToolImages(result, NoAssets, out var missing); + + Assert.Single(images); + Assert.Empty(missing); + } + + [Fact] + public void ExtractToolImages_IgnoresResultsWithoutImages() + { + var result = new ToolExecutionCompleteResult + { + Content = "text", + BinaryResultsForLlm = + [ + new PersistedBinaryResult(new OmittedBinaryResult { Type = OmittedBinaryType.Image, OmittedReason = OmittedBinaryOmittedReason.TooLarge, MimeType = "image/png", ByteLength = 1 << 30 }), + new PersistedBinaryResult(new PersistedBinaryImage { Type = PersistedBinaryImageType.Resource, Data = "JVBE", MimeType = "application/pdf" }) + ] + }; + + Assert.Empty(CopilotChatServiceBase.ExtractToolImages(result, NoAssets, out var missing)); + Assert.Empty(missing); + } +} diff --git a/tests/OneWare.Copilot.UnitTests/ProjectWorkspaceTests.cs b/tests/OneWare.Copilot.UnitTests/ProjectWorkspaceTests.cs new file mode 100644 index 00000000..63ac80c4 --- /dev/null +++ b/tests/OneWare.Copilot.UnitTests/ProjectWorkspaceTests.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using OneWare.Copilot.Services; +using Xunit; + +namespace OneWare.Copilot.UnitTests; + +public sealed class ProjectWorkspaceTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "OneWare-Workspace-" + Guid.NewGuid()); + + public ProjectWorkspaceTests() + { + Directory.CreateDirectory(_root); + } + + private string Project(string name) + { + return Directory.CreateDirectory(Path.Combine(_root, name)).FullName; + } + + private static void Write(string path, string content) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + + [Fact] + public void LoginShellEnvironment_ParsesOutputAfterProfileBanners() + { + var env = LoginShellEnvironment.Parse( + "Welcome!\n__ONEWARE_SHELL_ENV__TOKEN=a=b\0MULTI=line1\nline2\0\0BROKEN\0"); + + Assert.Equal("a=b", env["TOKEN"]); + Assert.Equal("line1\nline2", env["MULTI"]); + Assert.Equal(2, env.Count); + Assert.Empty(LoginShellEnvironment.Parse("no marker")); + } + + [Fact] + public void LoginShellEnvironment_ResolvesBareOrMissingShell() + { + if (OperatingSystem.IsWindows() || !File.Exists("/bin/sh")) return; + var directory = Path.GetDirectoryName(Path.GetFullPath("/bin/sh"))!; + + Assert.Equal("/bin/sh", LoginShellEnvironment.ResolveShell("/bin/sh", null, () => null)); + // Bare names, as set by some launchers, are looked up in PATH. + Assert.Equal(Path.Combine(directory, "sh"), LoginShellEnvironment.ResolveShell("sh", directory, () => null)); + // Otherwise the account's shell is used. + Assert.Equal("/bin/sh", LoginShellEnvironment.ResolveShell(null, null, () => "/bin/sh")); + Assert.Equal("/bin/sh", LoginShellEnvironment.ResolveShell("no-such-shell", "", () => "/missing")); + } + + [Fact] + public void GetProjectSkillDirectories_SkipsTheWorkingDirectoryProject() + { + var active = Project("active"); + var other = Project("other"); + Directory.CreateDirectory(Path.Combine(active, ".github", "skills")); + Directory.CreateDirectory(Path.Combine(other, ".github", "skills")); + Directory.CreateDirectory(Path.Combine(other, ".agents", "skills")); + + var directories = CopilotSessionConfiguration.GetProjectSkillDirectories([active, other], active).ToList(); + + Assert.Equal([Path.Combine(other, ".github", "skills"), Path.Combine(other, ".agents", "skills")], + directories); + } + + [Fact] + public void BuildWorkspaceContext_InlinesInstructionsOfOtherProjects() + { + var active = Project("active"); + var other = Project("other"); + Write(Path.Combine(active, "AGENTS.md"), "active rules"); + Write(Path.Combine(other, "AGENTS.md"), "other rules"); + Write(Path.Combine(other, ".github", "copilot-instructions.md"), "copilot rules"); + + var text = CopilotSessionConfiguration.BuildWorkspaceContext([active, other], active, out var key); + + Assert.NotNull(text); + Assert.Contains($"- active: {active} (working directory)", text); + Assert.Contains($"- other: {other}", text); + Assert.Contains("other rules", text); + Assert.Contains("copilot rules", text); + Assert.DoesNotContain("active rules", text); + + // The key only changes with what the session would be told. + CopilotSessionConfiguration.BuildWorkspaceContext([active, other], active, out var unchanged); + Assert.Equal(key, unchanged); + Write(Path.Combine(other, "AGENTS.md"), "other rules, updated"); + CopilotSessionConfiguration.BuildWorkspaceContext([active, other], active, out var changed); + Assert.NotEqual(key, changed); + } + + [Fact] + public void BuildWorkspaceContext_IsEmptyWhenOnlyTheWorkingDirectoryIsOpen() + { + var active = Project("active"); + Write(Path.Combine(active, "AGENTS.md"), "rules"); + + Assert.Null(CopilotSessionConfiguration.BuildWorkspaceContext([active], active, out _)); + } + + [Fact] + public void BuildWorkspaceContext_TruncatesLargeInstructionFiles() + { + var active = Project("active"); + var other = Project("other"); + Write(Path.Combine(other, "AGENTS.md"), new string('x', CopilotSessionConfiguration.MaxInstructionFileChars + 10)); + + var text = CopilotSessionConfiguration.BuildWorkspaceContext([active, other], active, out _); + + Assert.Contains("[truncated, read", text); + } + + public void Dispose() + { + Directory.Delete(_root, true); + } +}