| 1 | using System.Collections.Frozen; |
| 2 | using System.Text.Json; |
| 3 | using CommunityToolkit.Mvvm.ComponentModel; |
| 4 | |
| 5 | namespace CascadeIDE.Features.WebAiPortal.Application; |
| 6 | |
| 7 | /// <summary>Мост веб-портала ADR 0108: JSON с <c>command_id</c> + <c>args</c> → whitelist → <see cref="IIdeMcpActions"/>.</summary> |
| 8 | public sealed class WebAiPortalCommandBridge : ObservableObject |
| 9 | { |
| 10 | private readonly IIdeMcpActions _actions; |
| 11 | |
| 12 | private bool _bridgeConsented; |
| 13 | private bool _bridgeArmed; |
| 14 | private bool _allowWritesForSession; |
| 15 | |
| 16 | /// <summary>Согласие с политикой моста (ADR 0108 §2.6).</summary> |
| 17 | public bool BridgeConsented |
| 18 | { |
| 19 | get => _bridgeConsented; |
| 20 | set => SetProperty(ref _bridgeConsented, value); |
| 21 | } |
| 22 | |
| 23 | /// <summary>Включить приём сообщений от страницы (<c>invokeCSharpAction</c>).</summary> |
| 24 | public bool BridgeArmed |
| 25 | { |
| 26 | get => _bridgeArmed; |
| 27 | set => SetProperty(ref _bridgeArmed, value); |
| 28 | } |
| 29 | |
| 30 | /// <summary>Разрешить write без модалки на каждый вызов (ADR 0108 §2.3 п.8).</summary> |
| 31 | public bool AllowWritesForSession |
| 32 | { |
| 33 | get => _allowWritesForSession; |
| 34 | set => SetProperty(ref _allowWritesForSession, value); |
| 35 | } |
| 36 | |
| 37 | /// <summary>PoC-allowlist ADR 0108 §2.2 (канонические <c>IdeCommands</c>).</summary> |
| 38 | internal static readonly FrozenSet<string> Whitelist = FrozenSet.ToFrozenSet( |
| 39 | new[] |
| 40 | { |
| 41 | IdeCommands.GetEditorContentRange, |
| 42 | IdeCommands.GetEditorState, |
| 43 | IdeCommands.CodebaseIndexStatus, |
| 44 | IdeCommands.CodebaseIndexSearch, |
| 45 | IdeCommands.CodebaseIndexExplain, |
| 46 | IdeCommands.GetCurrentFileDiagnostics, |
| 47 | }, |
| 48 | StringComparer.Ordinal); |
| 49 | |
| 50 | /// <summary>Команды, считающиеся write и требующие подтверждения (пока пусто — весь whitelist чтение).</summary> |
| 51 | internal static readonly FrozenSet<string> WriteCommands = FrozenSet.ToFrozenSet( |
| 52 | Array.Empty<string>(), |
| 53 | StringComparer.Ordinal); |
| 54 | |
| 55 | public WebAiPortalCommandBridge(IIdeMcpActions actions) => _actions = actions; |
| 56 | |
| 57 | public void RevokeBridge() |
| 58 | { |
| 59 | BridgeArmed = false; |
| 60 | BridgeConsented = false; |
| 61 | AllowWritesForSession = false; |
| 62 | } |
| 63 | |
| 64 | /// <summary>Тело сообщения от <c>invokeCSharpAction</c>: JSON одной строкой или объект с полями MCP execute_command.</summary> |
| 65 | public async Task<string> ExecuteFromWebJsonAsync(string body, CancellationToken cancellationToken = default) |
| 66 | { |
| 67 | if (string.IsNullOrWhiteSpace(body)) |
| 68 | return "Empty body"; |
| 69 | if (!BridgeConsented || !BridgeArmed) |
| 70 | return "Web AI bridge is not armed (consent and enable required in IDE)."; |
| 71 | |
| 72 | Dictionary<string, JsonElement>? dict; |
| 73 | try |
| 74 | { |
| 75 | dict = ParseToArgDictionary(body); |
| 76 | } |
| 77 | catch (JsonException ex) |
| 78 | { |
| 79 | return "Invalid JSON: " + ex.Message; |
| 80 | } |
| 81 | |
| 82 | if (dict is null) |
| 83 | return "Invalid JSON: expected object."; |
| 84 | |
| 85 | var merged = IdeExecuteCommandArgs.MergeNestedArgs(dict); |
| 86 | var commandId = merged is not null && merged.TryGetValue("command_id", out var cid) ? cid.GetString() : null; |
| 87 | if (string.IsNullOrEmpty(commandId)) |
| 88 | return "Missing command_id"; |
| 89 | |
| 90 | if (!Whitelist.Contains(commandId)) |
| 91 | return $"Command not allowed by web portal whitelist: {commandId}"; |
| 92 | |
| 93 | if (WriteCommands.Contains(commandId)) |
| 94 | { |
| 95 | if (!AllowWritesForSession) |
| 96 | { |
| 97 | var ok = await _actions |
| 98 | .RequestConfirmationAsync( |
| 99 | $"Веб-портал запрашивает команду (изменение): {commandId}. Разрешить один раз?", |
| 100 | cancellationToken) |
| 101 | .ConfigureAwait(false); |
| 102 | if (!string.Equals(ok, ConfirmationResponses.Ok, StringComparison.OrdinalIgnoreCase)) |
| 103 | return "Denied by user"; |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | return await _actions.ExecuteCommandAsync(commandId, merged, cancellationToken).ConfigureAwait(false); |
| 108 | } |
| 109 | |
| 110 | private static Dictionary<string, JsonElement>? ParseToArgDictionary(string body) |
| 111 | { |
| 112 | using var doc = JsonDocument.Parse(body); |
| 113 | if (doc.RootElement.ValueKind != JsonValueKind.Object) |
| 114 | return null; |
| 115 | var dict = new Dictionary<string, JsonElement>(StringComparer.Ordinal); |
| 116 | foreach (var p in doc.RootElement.EnumerateObject()) |
| 117 | dict[p.Name] = p.Value.Clone(); |
| 118 | return dict; |
| 119 | } |
| 120 | } |
| 121 | |