diff --git a/.github/workflows/build-dotnet.yml b/.github/workflows/build-dotnet.yml index e3b9b72608..4dc31bd160 100644 --- a/.github/workflows/build-dotnet.yml +++ b/.github/workflows/build-dotnet.yml @@ -41,6 +41,9 @@ jobs: filters: | dotnet: - "dotnet/**" + - "ts/packages/agents/visualStudio/**" + - "ts/packages/utils/webSocketUtils/**" + - "ts/packages/agentServer/protocol/**" - "ts/packages/agents/powershell/**" - "ts/packages/config/**" - "ts/packages/agents/desktop/src/**Schema.ts" @@ -91,6 +94,28 @@ jobs: if: ${{ github.event_name != 'pull_request' || steps.filter.outputs.dotnet != 'false' }} working-directory: dotnet run: msbuild.exe typeagent/TypeAgent.sln /p:platform="Any CPU" /p:configuration="${{ matrix.configuration }}" + - name: Build Visual Studio solution, WebView, and VSIX + if: ${{ github.event_name != 'pull_request' || steps.filter.outputs.dotnet != 'false' }} + working-directory: dotnet + run: msbuild.exe visualStudioTypeAgent/VisualStudioTypeAgent.sln /restore /p:platform="Any CPU" /p:configuration="${{ matrix.configuration }}" /p:DeployExtension=false + - name: Build Visual Studio bridge TypeScript + if: ${{ (github.event_name != 'pull_request' || steps.filter.outputs.dotnet != 'false') && matrix.configuration == 'Release' }} + working-directory: ts + run: pnpm exec fluid-build "@typeagent/visualstudio-agent" -t tsc --dep + - name: Test Visual Studio bridge TypeScript authentication + if: ${{ (github.event_name != 'pull_request' || steps.filter.outputs.dotnet != 'false') && matrix.configuration == 'Release' }} + working-directory: ts + run: pnpm --filter @typeagent/visualstudio-agent run test:local -- --runInBand + - name: Build Visual Studio bridge for .NET Framework + if: ${{ (github.event_name != 'pull_request' || steps.filter.outputs.dotnet != 'false') && matrix.configuration == 'Release' }} + run: dotnet build dotnet/visualStudioTypeAgent.Tests/VisualStudioTypeAgent.BridgeCompile.csproj --configuration Release --nologo + - name: Test Visual Studio bridge authentication + if: ${{ (github.event_name != 'pull_request' || steps.filter.outputs.dotnet != 'false') && matrix.configuration == 'Release' }} + run: dotnet run --project dotnet/visualStudioTypeAgent.Tests/VisualStudioTypeAgent.Tests.csproj --configuration Release + - name: Test Visual Studio bridge TypeScript/C# interoperability + if: ${{ (github.event_name != 'pull_request' || steps.filter.outputs.dotnet != 'false') && matrix.configuration == 'Release' }} + shell: pwsh + run: ./dotnet/visualStudioTypeAgent.Tests/RunInterop.ps1 -Configuration Release - name: Build PowerShell sandbox broker if: ${{ github.event_name != 'pull_request' || steps.filter.outputs.dotnet != 'false' }} run: dotnet build dotnet/powerShellSandboxBroker/PowerShellSandboxBroker.csproj --configuration ${{ matrix.configuration }} --runtime win-x64 diff --git a/dotnet/visualStudioTypeAgent.Tests/BridgeCompileStubs.cs b/dotnet/visualStudioTypeAgent.Tests/BridgeCompileStubs.cs new file mode 100644 index 0000000000..e776986288 --- /dev/null +++ b/dotnet/visualStudioTypeAgent.Tests/BridgeCompileStubs.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.Shell; +using Newtonsoft.Json.Linq; + +namespace Microsoft.VisualStudio.Shell +{ + public class AsyncPackage + { + } +} + +namespace Microsoft.TypeAgent.VisualStudio.Bridge +{ + internal sealed class DTEActionExecutor : IBridgeActionExecutor + { + public DTEActionExecutor(AsyncPackage package) + { + } + + public Task ExecuteAsync( + string actionName, + JObject parameters, + CancellationToken cancellation) => + Task.FromResult(null); + } +} diff --git a/dotnet/visualStudioTypeAgent.Tests/Directory.Build.props b/dotnet/visualStudioTypeAgent.Tests/Directory.Build.props new file mode 100644 index 0000000000..2fbfeb3849 --- /dev/null +++ b/dotnet/visualStudioTypeAgent.Tests/Directory.Build.props @@ -0,0 +1,6 @@ + + + obj\$(MSBuildProjectName)\ + $(BaseIntermediateOutputPath) + + diff --git a/dotnet/visualStudioTypeAgent.Tests/Program.cs b/dotnet/visualStudioTypeAgent.Tests/Program.cs new file mode 100644 index 0000000000..9e53d6b607 --- /dev/null +++ b/dotnet/visualStudioTypeAgent.Tests/Program.cs @@ -0,0 +1,979 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Net.WebSockets; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.TypeAgent.VisualStudio.Bridge; +using Newtonsoft.Json.Linq; + +internal static class Program +{ + private static int _assertions; + private static readonly string[] HeaderSeparators = { "\r\n" }; + private static readonly Encoding Utf8NoBom = new UTF8Encoding(false); + + public static async Task Main(string[] args) + { + try + { + if (args.Length > 0) + { + return RunInteropCommand(args); + } + if (Environment.GetEnvironmentVariable("TYPEAGENT_VS_TEST_LOAD_CREDENTIAL") == "1") + { + BridgeCredential storedCredential = BridgeAuthentication.LoadCredential(); + Assert( + storedCredential.Secret.Length == 32, + "The C# client must load the TypeScript-provisioned credential"); + } + TestCredentialPathResolution(); + TestDiscoveryAuthentication(); + AuthenticatedBridgeSession session = TestMutualHandshake(); + await TestAuthenticationTimeoutIsRetryableAsync(); + await TestAuthenticatedMessageSizeLimitAsync(session); + await TestExecutorGatingAsync(session); + Console.WriteLine($"Visual Studio bridge authentication tests passed ({_assertions} assertions)."); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + return 1; + } + } + + private static int RunInteropCommand(string[] args) + { + switch (args[0]) + { + case "write-interop-request" when args.Length == 2: + WriteInteropRequest(args[1]); + return 0; + case "verify-interop-response" when args.Length == 4: + VerifyInteropResponse(args[1], args[2], args[3]); + return 0; + default: + throw new InvalidOperationException( + "Usage: write-interop-request | verify-interop-response "); + } + } + + private static void WriteInteropRequest(string outputPath) + { + BridgeCredential credential = CreateCredential(0x53); + long now = BridgeAuthentication.CurrentUnixMilliseconds(); + DiscoveryAuthContext discovery = BridgeAuthentication.CreateDiscoveryRequest( + credential, + BridgeAuthentication.VisualStudioAgentName, + BridgeAuthentication.VisualStudioAgentRole, + now); + ClientHelloContext clientHello = BridgeAuthentication.CreateClientHello( + credential, + BridgeAuthentication.CreateClientInstanceId(), + now); + + string? originalOverride = Environment.GetEnvironmentVariable( + BridgeAuthentication.CredentialPathOverrideEnvironmentVariable); + string? originalGeneral = Environment.GetEnvironmentVariable( + "TYPEAGENT_USER_DATA_DIR"); + string fallbackPath; + string canonicalOverrideInput = Path.Combine( + Path.GetDirectoryName(Path.GetFullPath(outputPath)) + ?? throw new InvalidOperationException("Interop output path has no directory"), + "credential-root", + "..", + "bridge-credential.json"); + string canonicalOverridePath; + try + { + Environment.SetEnvironmentVariable( + BridgeAuthentication.CredentialPathOverrideEnvironmentVariable, + null); + Environment.SetEnvironmentVariable( + "TYPEAGENT_USER_DATA_DIR", + Path.Combine(Path.GetTempPath(), "csharp-only-user-data")); + fallbackPath = BridgeAuthentication.GetCredentialPath(); + Environment.SetEnvironmentVariable( + BridgeAuthentication.CredentialPathOverrideEnvironmentVariable, + canonicalOverrideInput); + canonicalOverridePath = BridgeAuthentication.GetCredentialPath(); + } + finally + { + Environment.SetEnvironmentVariable( + BridgeAuthentication.CredentialPathOverrideEnvironmentVariable, + originalOverride); + Environment.SetEnvironmentVariable( + "TYPEAGENT_USER_DATA_DIR", + originalGeneral); + } + + var request = new JObject + { + ["credential"] = new JObject + { + ["version"] = BridgeAuthentication.ProtocolVersion, + ["credentialId"] = credential.CredentialId, + ["secret"] = Hex(credential.Secret), + }, + ["now"] = now, + ["fallbackCredentialPath"] = fallbackPath, + ["canonicalOverrideInput"] = canonicalOverrideInput, + ["canonicalOverridePath"] = canonicalOverridePath, + ["discovery"] = new JObject + { + ["agentName"] = BridgeAuthentication.VisualStudioAgentName, + ["role"] = BridgeAuthentication.VisualStudioAgentRole, + ["auth"] = discovery.Auth, + }, + ["clientHello"] = clientHello.Message, + }; + File.WriteAllText( + outputPath, + request.ToString(Newtonsoft.Json.Formatting.Indented), + Utf8NoBom); + } + + private static void VerifyInteropResponse( + string requestPath, + string responsePath, + string outputPath) + { + var request = JObject.Parse(File.ReadAllText(requestPath, Encoding.UTF8)); + var response = JObject.Parse(File.ReadAllText(responsePath, Encoding.UTF8)); + JObject credentialValue = RequireObject(request, "credential"); + var credential = new BridgeCredential( + RequireString(credentialValue, "credentialId"), + ParseHex(RequireString(credentialValue, "secret"))); + long now = request.Value("now") + ?? throw new InvalidOperationException("Interop request timestamp was missing"); + + JObject discoveryValue = RequireObject(request, "discovery"); + JObject discoveryAuth = RequireObject(discoveryValue, "auth"); + var discoveryContext = new DiscoveryAuthContext( + discoveryAuth, + RequireString(discoveryAuth, "nonce")); + JObject discoveryResponse = RequireObject(response, "discoveryResponse"); + int? port = discoveryResponse.Value("port"); + string? url = discoveryResponse.Value("url"); + Assert( + BridgeAuthentication.VerifyDiscoveryResponse( + credential, + discoveryContext, + RequireString(discoveryValue, "agentName"), + RequireString(discoveryValue, "role"), + port, + url, + RequireObject(discoveryResponse, "auth"), + now), + "C# must verify the TypeScript discovery response"); + + JObject clientHelloValue = RequireObject(request, "clientHello"); + var clientHello = new ClientHelloContext( + clientHelloValue, + RequireString(clientHelloValue, "clientInstanceId"), + RequireString(clientHelloValue, "clientNonce")); + AuthenticatedBridgeSession session = + BridgeAuthentication.VerifyServerHello( + credential, + clientHello, + RequireObject(response, "serverHello"), + now) + ?? throw new InvalidOperationException( + "C# rejected the TypeScript server hello"); + VerifiedSecureFrame requestFrame = + BridgeAuthentication.VerifySecureFrame( + RequireObject(response, "requestFrame"), + session, + 1, + "request", + session.ServerToClientKey, + now) + ?? throw new InvalidOperationException( + "C# rejected the TypeScript request frame"); + Assert( + requestFrame.Payload.Value("actionName") == "getActiveFile", + "C# must decode the TypeScript request payload"); + + var responsePayload = new JObject + { + ["id"] = requestFrame.Id, + ["success"] = true, + ["result"] = new JObject + { + ["source"] = "csharp", + }, + }; + JObject responseFrame = BridgeAuthentication.CreateSecureFrame( + session.SessionId, + 1, + "response", + requestFrame.Id, + responsePayload, + session.ClientToServerKey); + File.WriteAllText( + outputPath, + new JObject { ["responseFrame"] = responseFrame }.ToString( + Newtonsoft.Json.Formatting.Indented), + Utf8NoBom); + } + + private static JObject RequireObject(JObject value, string propertyName) => + value[propertyName] as JObject + ?? throw new InvalidOperationException( + $"Interop property '{propertyName}' was missing"); + + private static string RequireString(JObject value, string propertyName) => + value.Value(propertyName) + ?? throw new InvalidOperationException( + $"Interop property '{propertyName}' was missing"); + + private static byte[] ParseHex(string value) + { + if (value.Length % 2 != 0) + { + throw new InvalidOperationException("Interop hex value was malformed"); + } + var bytes = new byte[value.Length / 2]; + for (int i = 0; i < bytes.Length; i++) + { + bytes[i] = byte.Parse( + value.Substring(i * 2, 2), + NumberStyles.HexNumber, + CultureInfo.InvariantCulture); + } + return bytes; + } + + private static void TestCredentialPathResolution() + { + const string generalUserDataEnvironmentVariable = "TYPEAGENT_USER_DATA_DIR"; + string? originalOverride = Environment.GetEnvironmentVariable( + BridgeAuthentication.CredentialPathOverrideEnvironmentVariable); + string? originalGeneralUserData = Environment.GetEnvironmentVariable( + generalUserDataEnvironmentVariable); + string originalWorkingDirectory = Directory.GetCurrentDirectory(); + string temporaryDirectory = Path.Combine( + Path.GetTempPath(), + $"typeagent-vs-path-{Guid.NewGuid():N}"); + Directory.CreateDirectory(temporaryDirectory); + try + { + Environment.SetEnvironmentVariable( + BridgeAuthentication.CredentialPathOverrideEnvironmentVariable, + null); + string fallback = BridgeAuthentication.GetCredentialPath(); + string expectedFallback = Path.GetFullPath(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".typeagent", + "visual-studio", + "bridge-credential.json")); + Assert( + string.Equals(fallback, expectedFallback, StringComparison.OrdinalIgnoreCase), + "The C# credential fallback must use the stable per-user path"); + + Environment.SetEnvironmentVariable( + generalUserDataEnvironmentVariable, + Path.Combine(temporaryDirectory, "different-process-user-data")); + Assert( + string.Equals( + BridgeAuthentication.GetCredentialPath(), + fallback, + StringComparison.OrdinalIgnoreCase), + "The general user-data override must not affect the bridge credential"); + + foreach (string invalid in new[] + { + " ", + @"relative\bridge-credential.json", + }) + { + Environment.SetEnvironmentVariable( + BridgeAuthentication.CredentialPathOverrideEnvironmentVariable, + invalid); + AssertThrowsInvalidOperation( + BridgeAuthentication.GetCredentialPath, + "Invalid bridge credential overrides must fail closed"); + } + + string otherWorkingDirectory = Path.Combine( + temporaryDirectory, + "working-directory"); + Directory.CreateDirectory(otherWorkingDirectory); + string overridePath = Path.Combine( + temporaryDirectory, + "credential-root", + "..", + "bridge-credential.json"); + string expectedOverride = Path.GetFullPath(overridePath); + Environment.SetEnvironmentVariable( + BridgeAuthentication.CredentialPathOverrideEnvironmentVariable, + overridePath); + Assert( + string.Equals( + BridgeAuthentication.GetCredentialPath(), + expectedOverride, + StringComparison.OrdinalIgnoreCase), + "The dedicated credential override must be canonicalized"); + Directory.SetCurrentDirectory(otherWorkingDirectory); + Assert( + string.Equals( + BridgeAuthentication.GetCredentialPath(), + expectedOverride, + StringComparison.OrdinalIgnoreCase), + "An absolute credential override must not depend on the working directory"); + } + finally + { + Directory.SetCurrentDirectory(originalWorkingDirectory); + Environment.SetEnvironmentVariable( + BridgeAuthentication.CredentialPathOverrideEnvironmentVariable, + originalOverride); + Environment.SetEnvironmentVariable( + generalUserDataEnvironmentVariable, + originalGeneralUserData); + Directory.Delete(temporaryDirectory, recursive: true); + } + } + + private static void TestDiscoveryAuthentication() + { + BridgeCredential credential = CreateCredential(0x31); + const long now = 1_800_000_000_000; + DiscoveryAuthContext request = BridgeAuthentication.CreateDiscoveryRequest( + credential, + BridgeAuthentication.VisualStudioAgentName, + BridgeAuthentication.VisualStudioAgentRole, + now); + const string serverNonce = + "abababababababababababababababababababababababababababababababab"; + long expiresAt = now + 15_000; + var auth = new JObject + { + ["version"] = 1, + ["credentialId"] = credential.CredentialId, + ["requestNonce"] = request.Nonce, + ["serverNonce"] = serverNonce, + ["issuedAt"] = now, + ["expiresAt"] = expiresAt, + ["proof"] = Proof( + credential.Secret, + "typeagent-vs-discovery-response-v1", + BridgeAuthentication.VisualStudioAgentName, + BridgeAuthentication.VisualStudioAgentRole, + credential.CredentialId, + request.Nonce, + serverNonce, + Format(now), + Format(expiresAt), + "43123", + string.Empty), + }; + + Assert( + BridgeAuthentication.VerifyDiscoveryResponse( + credential, + request, + BridgeAuthentication.VisualStudioAgentName, + BridgeAuthentication.VisualStudioAgentRole, + 43123, + null, + auth, + now), + "A valid signed discovery response must authenticate"); + Assert( + !BridgeAuthentication.VerifyDiscoveryResponse( + credential, + request, + BridgeAuthentication.VisualStudioAgentName, + BridgeAuthentication.VisualStudioAgentRole, + 43124, + null, + auth, + now), + "A fake syntactically valid port must invalidate the response proof"); + Assert( + !BridgeAuthentication.VerifyDiscoveryResponse( + credential, + request, + BridgeAuthentication.VisualStudioAgentName, + BridgeAuthentication.VisualStudioAgentRole, + 43123, + null, + null, + now), + "A missing discovery proof must fail closed"); + auth["proof"] = new string('0', 64); + Assert( + !BridgeAuthentication.VerifyDiscoveryResponse( + credential, + request, + BridgeAuthentication.VisualStudioAgentName, + BridgeAuthentication.VisualStudioAgentRole, + 43123, + null, + auth, + now), + "A wrong discovery proof must fail closed"); + } + + private static AuthenticatedBridgeSession TestMutualHandshake() + { + BridgeCredential credential = CreateCredential(0x31); + long now = BridgeAuthentication.CurrentUnixMilliseconds(); + const string clientInstanceId = "1234567890abcdef1234567890abcdef"; + ClientHelloContext clientHello = BridgeAuthentication.CreateClientHello( + credential, + clientInstanceId, + now); + const string serverNonce = + "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"; + const string sessionId = "abcdefabcdefabcdefabcdefabcdefab"; + long expiresAt = now + 10 * 60_000; + var serverHello = new JObject + { + ["type"] = "serverHello", + ["version"] = 1, + ["credentialId"] = credential.CredentialId, + ["clientNonce"] = clientHello.ClientNonce, + ["serverNonce"] = serverNonce, + ["sessionId"] = sessionId, + ["issuedAt"] = now, + ["expiresAt"] = expiresAt, + ["proof"] = Proof( + credential.Secret, + "typeagent-vs-server-hello-v1", + credential.CredentialId, + clientInstanceId, + clientHello.ClientNonce, + serverNonce, + sessionId, + Format(now), + Format(expiresAt)), + }; + AuthenticatedBridgeSession? session = BridgeAuthentication.VerifyServerHello( + credential, + clientHello, + serverHello, + now); + Assert(session is not null, "A valid server proof must complete mutual authentication"); + + serverHello["proof"] = new string('f', 64); + Assert( + BridgeAuthentication.VerifyServerHello( + credential, + clientHello, + serverHello, + now) is null, + "A wrong server proof must fail before action processing"); + return session!; + } + + private static async Task TestAuthenticationTimeoutIsRetryableAsync() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + using var serverCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + Task server = RunSilentWebSocketServerAsync( + listener, + expectedConnections: 2, + serverCancellation.Token); + try + { + int retryableFailures = 0; + for (int attempt = 0; attempt < 2; attempt++) + { + try + { + await AuthenticatedBridgeConnection.RunAsync( + new Uri($"ws://127.0.0.1:{port}"), + CreateCredential(0x31), + BridgeAuthentication.CreateClientInstanceId(), + new RecordingExecutor(), + CancellationToken.None, + TimeSpan.FromMilliseconds(100)); + throw new InvalidOperationException( + "Assertion failed: A silent peer must not authenticate"); + } + catch (InvalidOperationException ex) + when (ex.Message.Contains( + "authentication timed out", + StringComparison.Ordinal)) + { + retryableFailures++; + } + catch (OperationCanceledException ex) + { + throw new InvalidOperationException( + "Assertion failed: Authentication timeout must be retryable", + ex); + } + } + Assert( + retryableFailures == 2, + "A silent peer must time out as a retryable authentication failure"); + } + finally + { + listener.Stop(); + serverCancellation.Cancel(); + try + { + await server; + } + catch (OperationCanceledException) + { + } + catch (SocketException) + { + } + } + } + + private static async Task RunSilentWebSocketServerAsync( + TcpListener listener, + int expectedConnections, + CancellationToken cancellation) + { + var connections = new List(); + for (int i = 0; i < expectedConnections; i++) + { + TcpClient client = await listener + .AcceptTcpClientAsync(cancellation) + .ConfigureAwait(false); + connections.Add( + CompleteWebSocketHandshakeAndStaySilentAsync(client, cancellation)); + } + await Task.WhenAll(connections).ConfigureAwait(false); + } + + private static async Task CompleteWebSocketHandshakeAndStaySilentAsync( + TcpClient client, + CancellationToken cancellation) + { + using (client) + using (NetworkStream stream = client.GetStream()) + { + string headers = await ReadHttpHeadersAsync(stream, cancellation) + .ConfigureAwait(false); + string? webSocketKey = null; + foreach (string line in headers.Split(HeaderSeparators, StringSplitOptions.None)) + { + const string keyHeader = "Sec-WebSocket-Key:"; + if (line.StartsWith(keyHeader, StringComparison.OrdinalIgnoreCase)) + { + webSocketKey = line.Substring(keyHeader.Length).Trim(); + break; + } + } + if (string.IsNullOrEmpty(webSocketKey)) + { + throw new InvalidOperationException("WebSocket key was missing"); + } + + byte[] acceptInput = Encoding.ASCII.GetBytes( + webSocketKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); +#pragma warning disable CA5350 // RFC 6455 requires SHA-1 for Sec-WebSocket-Accept. + string accept = Convert.ToBase64String(SHA1.HashData(acceptInput)); +#pragma warning restore CA5350 + byte[] response = Encoding.ASCII.GetBytes( + "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + $"Sec-WebSocket-Accept: {accept}\r\n\r\n"); + await stream.WriteAsync(response.AsMemory(), cancellation) + .ConfigureAwait(false); + await stream.FlushAsync(cancellation).ConfigureAwait(false); + await Task.Delay(500, cancellation).ConfigureAwait(false); + } + } + + private static async Task ReadHttpHeadersAsync( + NetworkStream stream, + CancellationToken cancellation) + { + var headers = new StringBuilder(); + var buffer = new byte[1024]; + while (!headers.ToString().Contains("\r\n\r\n", StringComparison.Ordinal)) + { + int count = await stream + .ReadAsync(buffer.AsMemory(), cancellation) + .ConfigureAwait(false); + if (count == 0) + { + throw new IOException("WebSocket peer closed during handshake"); + } + headers.Append(Encoding.ASCII.GetString(buffer, 0, count)); + if (headers.Length > 16 * 1024) + { + throw new InvalidOperationException("WebSocket headers exceeded the size limit"); + } + } + return headers.ToString(); + } + + private static async Task TestExecutorGatingAsync(AuthenticatedBridgeSession session) + { + Assert( + session.ExpiresAt > BridgeAuthentication.CurrentUnixMilliseconds(), + "The valid-action fixture must use a live authentication session"); + var executor = new RecordingExecutor(); + var processor = new AuthenticatedBridgeRequestProcessor(session, executor); + const string requestId = "00112233445566778899aabbccddeeff"; + JObject validFrame = BridgeAuthentication.CreateSecureFrame( + session.SessionId, + 1, + "request", + requestId, + new JObject + { + ["actionName"] = "executeCommand", + ["parameters"] = new JObject + { + ["commandName"] = "File.SaveAll", + }, + }, + session.ServerToClientKey); + + string responseText = await processor.ProcessRequestAsync( + validFrame.ToString(Newtonsoft.Json.Formatting.None), + CancellationToken.None); + Assert(executor.CallCount == 1, "An authenticated action must reach the executor once"); + VerifiedSecureFrame? response = BridgeAuthentication.VerifySecureFrame( + JObject.Parse(responseText), + session, + 1, + "response", + session.ClientToServerKey); + Assert(response is not null, "The executor response must be protected by the session key"); + Assert(response!.Id == requestId, "The protected response must preserve the request ID"); + + await AssertRejectedWithoutExecutorCallAsync( + processor, + validFrame, + executor, + expectedCalls: 1, + "A replayed frame must not execute twice"); + + var missingAuthExecutor = new RecordingExecutor(); + var missingAuthProcessor = new AuthenticatedBridgeRequestProcessor( + session, + missingAuthExecutor); + await AssertRejectedWithoutExecutorCallAsync( + missingAuthProcessor, + new JObject + { + ["id"] = requestId, + ["actionName"] = "executeCommand", + ["parameters"] = new JObject(), + }, + missingAuthExecutor, + expectedCalls: 0, + "An unprotected action must not reach the executor"); + + var wrongCredentialExecutor = new RecordingExecutor(); + var wrongCredentialProcessor = new AuthenticatedBridgeRequestProcessor( + session, + wrongCredentialExecutor); + JObject wrongCredentialFrame = BridgeAuthentication.CreateSecureFrame( + session.SessionId, + 1, + "request", + requestId, + new JObject + { + ["actionName"] = "executeCommand", + ["parameters"] = new JObject(), + }, + new byte[32]); + await AssertRejectedWithoutExecutorCallAsync( + wrongCredentialProcessor, + wrongCredentialFrame, + wrongCredentialExecutor, + expectedCalls: 0, + "A wrong or cross-user session key must not reach the executor"); + + var expiredExecutor = new RecordingExecutor(); + var expiredSession = new AuthenticatedBridgeSession( + session.SessionId, + BridgeAuthentication.CurrentUnixMilliseconds() - 1, + session.ClientToServerKey, + session.ServerToClientKey); + var expiredProcessor = new AuthenticatedBridgeRequestProcessor( + expiredSession, + expiredExecutor); + JObject expiredFrame = BridgeAuthentication.CreateSecureFrame( + expiredSession.SessionId, + 1, + "request", + requestId, + new JObject + { + ["actionName"] = "executeCommand", + ["parameters"] = new JObject(), + }, + expiredSession.ServerToClientKey); + await AssertRejectedWithoutExecutorCallAsync( + expiredProcessor, + expiredFrame, + expiredExecutor, + expectedCalls: 0, + "An expired session must not reach the executor"); + } + + private static async Task TestAuthenticatedMessageSizeLimitAsync( + AuthenticatedBridgeSession session) + { + JObject frame = BridgeAuthentication.CreateSecureFrame( + session.SessionId, + 1, + "request", + "45454545454545454545454545454545", + new JObject + { + ["actionName"] = "saveAll", + ["parameters"] = new JObject(), + }, + session.ServerToClientKey); + string frameText = frame.ToString(Newtonsoft.Json.Formatting.None); + string boundaryFrame = PadUtf8( + frameText, + AuthenticatedBridgeConnection.MaxMessageBytes); + Assert( + Encoding.UTF8.GetByteCount(boundaryFrame) + == AuthenticatedBridgeConnection.MaxMessageBytes, + "The size-test frame must reach the exact byte boundary"); + + var boundaryExecutor = new RecordingExecutor(); + var boundaryProcessor = new AuthenticatedBridgeRequestProcessor( + session, + boundaryExecutor); + using (var socket = new TextMessageWebSocket(boundaryFrame)) + { + string received = await AuthenticatedBridgeConnection.ReceiveTextAsync( + socket, + CancellationToken.None) + .ConfigureAwait(false); + _ = await boundaryProcessor.ProcessRequestAsync( + received, + CancellationToken.None) + .ConfigureAwait(false); + } + Assert( + boundaryExecutor.CallCount == 1, + "An authenticated message at the size limit must execute"); + + string oversizedFrame = boundaryFrame + " "; + var oversizedExecutor = new RecordingExecutor(); + var oversizedProcessor = new AuthenticatedBridgeRequestProcessor( + session, + oversizedExecutor); + bool rejected = false; + using (var socket = new TextMessageWebSocket(oversizedFrame)) + { + try + { + string received = await AuthenticatedBridgeConnection.ReceiveTextAsync( + socket, + CancellationToken.None) + .ConfigureAwait(false); + _ = await oversizedProcessor.ProcessRequestAsync( + received, + CancellationToken.None) + .ConfigureAwait(false); + } + catch (InvalidOperationException ex) + when (ex.Message.Contains("size limit", StringComparison.Ordinal)) + { + rejected = true; + } + } + Assert(rejected, "An oversized authenticated message must fail closed"); + Assert( + oversizedExecutor.CallCount == 0, + "An oversized authenticated message must not reach the executor"); + } + + private static string PadUtf8(string value, int targetBytes) + { + int currentBytes = Encoding.UTF8.GetByteCount(value); + if (currentBytes > targetBytes) + { + throw new InvalidOperationException("Size-test frame exceeded its target"); + } + return value + new string(' ', targetBytes - currentBytes); + } + + private static async Task AssertRejectedWithoutExecutorCallAsync( + AuthenticatedBridgeRequestProcessor processor, + JObject frame, + RecordingExecutor executor, + int expectedCalls, + string message) + { + try + { + await processor.ProcessRequestAsync( + frame.ToString(Newtonsoft.Json.Formatting.None), + CancellationToken.None); + throw new InvalidOperationException($"Assertion failed: {message}"); + } + catch (InvalidOperationException ex) + { + Assert( + !ex.Message.Contains("001122", StringComparison.Ordinal), + "Authentication failures must not disclose authenticated envelopes"); + } + Assert(executor.CallCount == expectedCalls, message); + } + + private static BridgeCredential CreateCredential(byte fill) + { + byte[] secret = new byte[32]; + Array.Fill(secret, fill); + using var sha256 = SHA256.Create(); + string credentialId = Hex(sha256.ComputeHash(secret)).Substring(0, 32); + return new BridgeCredential(credentialId, secret); + } + + private static string Proof(byte[] key, string domain, params string[] fields) + { + using var hmac = new HMACSHA256(key); + var input = new StringBuilder(domain); + foreach (string field in fields) + { + input.Append('\n'); + input.Append(field); + } + return Hex(hmac.ComputeHash(Encoding.UTF8.GetBytes(input.ToString()))); + } + + private static string Hex(byte[] value) + { + var builder = new StringBuilder(value.Length * 2); + foreach (byte b in value) + { + builder.Append(b.ToString("x2", CultureInfo.InvariantCulture)); + } + return builder.ToString(); + } + + private static string Format(long value) => + value.ToString(CultureInfo.InvariantCulture); + + private static void Assert(bool condition, string message) + { + _assertions++; + if (!condition) + { + throw new InvalidOperationException($"Assertion failed: {message}"); + } + } + + private static void AssertThrowsInvalidOperation( + Func action, + string message) + { + try + { + action(); + throw new InvalidOperationException($"Assertion failed: {message}"); + } + catch (InvalidOperationException ex) + { + Assert( + !ex.Message.StartsWith( + "Assertion failed:", + StringComparison.Ordinal), + message); + } + } + + private sealed class RecordingExecutor : IBridgeActionExecutor + { + public int CallCount { get; private set; } + + public Task ExecuteAsync( + string actionName, + JObject parameters, + CancellationToken cancellation) + { + CallCount++; + return Task.FromResult(new + { + actionName, + parameters, + }); + } + } + + private sealed class TextMessageWebSocket : WebSocket + { + private readonly byte[] _payload; + private int _offset; + private WebSocketState _state = WebSocketState.Open; + + public TextMessageWebSocket(string payload) + { + _payload = Encoding.UTF8.GetBytes(payload); + } + + public override WebSocketCloseStatus? CloseStatus => null; + public override string? CloseStatusDescription => null; + public override string? SubProtocol => null; + public override WebSocketState State => _state; + + public override void Abort() => _state = WebSocketState.Aborted; + + public override Task CloseAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken) + { + _state = WebSocketState.Closed; + return Task.CompletedTask; + } + + public override Task CloseOutputAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken) + { + _state = WebSocketState.CloseSent; + return Task.CompletedTask; + } + + public override void Dispose() => _state = WebSocketState.Closed; + + public override Task ReceiveAsync( + ArraySegment buffer, + CancellationToken cancellationToken) + { + int count = Math.Min(buffer.Count, _payload.Length - _offset); + Array.Copy(_payload, _offset, buffer.Array!, buffer.Offset, count); + _offset += count; + return Task.FromResult(new WebSocketReceiveResult( + count, + WebSocketMessageType.Text, + _offset == _payload.Length)); + } + + public override Task SendAsync( + ArraySegment buffer, + WebSocketMessageType messageType, + bool endOfMessage, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } +} diff --git a/dotnet/visualStudioTypeAgent.Tests/RunInterop.ps1 b/dotnet/visualStudioTypeAgent.Tests/RunInterop.ps1 new file mode 100644 index 0000000000..176a9c8420 --- /dev/null +++ b/dotnet/visualStudioTypeAgent.Tests/RunInterop.ps1 @@ -0,0 +1,39 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +param( + [ValidateSet("Debug", "Release")] + [string] $Configuration = "Debug" +) + +$ErrorActionPreference = "Stop" +$repositoryRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..") +$project = Join-Path $PSScriptRoot "VisualStudioTypeAgent.Tests.csproj" +$interopScript = Join-Path $repositoryRoot "ts\packages\agents\visualStudio\dist\test\visualStudioBridgeInterop.js" +$temporaryDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ("typeagent-vs-interop-" + [Guid]::NewGuid().ToString("N")) + +New-Item -ItemType Directory -Path $temporaryDirectory | Out-Null +try { + $request = Join-Path $temporaryDirectory "request.json" + $response = Join-Path $temporaryDirectory "response.json" + $final = Join-Path $temporaryDirectory "final.json" + + dotnet run --project $project --configuration $Configuration --no-build -- write-interop-request $request + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + node $interopScript respond $request $response + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + dotnet run --project $project --configuration $Configuration --no-build -- verify-interop-response $request $response $final + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + node $interopScript verify $request $response $final + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + Write-Output "Visual Studio bridge TypeScript/C# interoperability passed." +} +finally { + if (Test-Path -LiteralPath $temporaryDirectory) { + [System.IO.Directory]::Delete($temporaryDirectory, $true) + } +} diff --git a/dotnet/visualStudioTypeAgent.Tests/VisualStudioTypeAgent.BridgeCompile.csproj b/dotnet/visualStudioTypeAgent.Tests/VisualStudioTypeAgent.BridgeCompile.csproj new file mode 100644 index 0000000000..9214542cdf --- /dev/null +++ b/dotnet/visualStudioTypeAgent.Tests/VisualStudioTypeAgent.BridgeCompile.csproj @@ -0,0 +1,27 @@ + + + net472 + disable + enable + latest + false + + + + + + + + + + + + + + + + diff --git a/dotnet/visualStudioTypeAgent.Tests/VisualStudioTypeAgent.Tests.csproj b/dotnet/visualStudioTypeAgent.Tests/VisualStudioTypeAgent.Tests.csproj new file mode 100644 index 0000000000..080f01cb9c --- /dev/null +++ b/dotnet/visualStudioTypeAgent.Tests/VisualStudioTypeAgent.Tests.csproj @@ -0,0 +1,28 @@ + + + Exe + net8.0-windows + disable + enable + latest + false + + + + + + + + + + + + + + + + diff --git a/dotnet/visualStudioTypeAgent/Bridge/AgentBridgeClient.cs b/dotnet/visualStudioTypeAgent/Bridge/AgentBridgeClient.cs index eb29cfe8d5..e080a92f5d 100644 --- a/dotnet/visualStudioTypeAgent/Bridge/AgentBridgeClient.cs +++ b/dotnet/visualStudioTypeAgent/Bridge/AgentBridgeClient.cs @@ -3,13 +3,9 @@ using System; using System.Diagnostics; -using System.Net.WebSockets; -using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.VisualStudio.Shell; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; namespace Microsoft.TypeAgent.VisualStudio.Bridge; @@ -30,23 +26,21 @@ namespace Microsoft.TypeAgent.VisualStudio.Bridge; /// See for the wire protocol and the /// `AGENT_SERVER_PORT` env-var knob. /// -/// Wire format (matches packages/agents/visualStudio/src/visualStudioActionHandler.ts): -/// request: { id, actionName, parameters } -/// response: { id, success, result?, error? } +/// Discovery and every action connection are authenticated with the +/// per-user bridge credential. Explicit port overrides skip discovery +/// only; they still require the mutual handshake and protected frames. /// internal sealed class AgentBridgeClient : IDisposable { private const string BridgePortOverrideEnv = "TYPEAGENT_VS_BRIDGE_PORT"; private static readonly TimeSpan ReconnectDelay = TimeSpan.FromSeconds(3); - private readonly AsyncPackage _package; private readonly DTEActionExecutor _executor; private readonly CancellationTokenSource _cts = new CancellationTokenSource(); - private ClientWebSocket? _ws; + private readonly string _clientInstanceId = BridgeAuthentication.CreateClientInstanceId(); public AgentBridgeClient(AsyncPackage package) { - _package = package; _executor = new DTEActionExecutor(package); } @@ -58,12 +52,15 @@ public async Task StartAsync(CancellationToken cancellation) int port = 0; try { + BridgeCredential credential = BridgeAuthentication.LoadCredential(); // Resolve the port fresh on every attempt: the agent may // have restarted on a different ephemeral port since the // last loop iteration, and the standalone shell may have // come up while we were retrying. uint? resolved = ResolvePortOverride() - ?? await BridgeDiscovery.ResolveBridgePortAsync(linked.Token).ConfigureAwait(false); + ?? await BridgeDiscovery + .ResolveBridgePortAsync(credential, linked.Token) + .ConfigureAwait(false); if (resolved is null) { // Discovery succeeded but the agent isn't registered @@ -73,10 +70,15 @@ public async Task StartAsync(CancellationToken cancellation) else { port = (int)resolved.Value; - await ConnectAndReceiveAsync(port, linked.Token).ConfigureAwait(false); + await AuthenticatedBridgeConnection.RunAsync( + new Uri($"ws://localhost:{port}"), + credential, + _clientInstanceId, + _executor, + linked.Token).ConfigureAwait(false); } } - catch (OperationCanceledException) + catch (OperationCanceledException) when (linked.IsCancellationRequested) { return; } @@ -114,94 +116,8 @@ public async Task StartAsync(CancellationToken cancellation) return null; } - private async Task ConnectAndReceiveAsync(int port, CancellationToken cancellation) - { - var uri = new Uri($"ws://localhost:{port}"); - _ws = new ClientWebSocket(); - await _ws.ConnectAsync(uri, cancellation).ConfigureAwait(false); - Debug.WriteLine($"[TypeAgent] Bridge connected to {uri}"); - - var buffer = new ArraySegment(new byte[16 * 1024]); - var assembly = new StringBuilder(); - - while (_ws.State == WebSocketState.Open && !cancellation.IsCancellationRequested) - { - assembly.Clear(); - WebSocketReceiveResult result; - do - { - result = await _ws.ReceiveAsync(buffer, cancellation).ConfigureAwait(false); - if (result.MessageType == WebSocketMessageType.Close) - { - await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, cancellation).ConfigureAwait(false); - return; - } - assembly.Append(Encoding.UTF8.GetString(buffer.Array!, 0, result.Count)); - } while (!result.EndOfMessage); - - _ = HandleRequestAsync(assembly.ToString(), cancellation); - } - } - - private async Task HandleRequestAsync(string json, CancellationToken cancellation) - { - string id = ""; - try - { - var root = JObject.Parse(json); - id = root.Value("id") ?? ""; - var actionName = root.Value("actionName") ?? ""; - var parameters = root["parameters"] as JObject ?? new JObject(); - - var result = await _executor.ExecuteAsync(actionName, parameters, cancellation); - await SendResponseAsync(id, success: true, result: result, error: null, cancellation); - } - catch (Exception ex) - { - await SendResponseAsync(id, success: false, result: null, error: ex.Message, cancellation); - } - } - - private async Task SendResponseAsync(string id, bool success, object? result, string? error, CancellationToken cancellation) - { - var ws = _ws; - if (ws is null || ws.State != WebSocketState.Open) - { - return; - } - - var payload = new BridgeResponse - { - id = id, - success = success, - result = result, - error = error, - }; - var json = JsonConvert.SerializeObject(payload, BridgeJson.Settings); - var bytes = Encoding.UTF8.GetBytes(json); - await ws.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, endOfMessage: true, cancellation); - } - public void Dispose() { _cts.Cancel(); - try { _ws?.Dispose(); } catch { } - } - - private sealed class BridgeResponse - { - public string id { get; set; } = ""; - public bool success { get; set; } - public object? result { get; set; } - public string? error { get; set; } } } - -internal static class BridgeJson -{ - // Preserve property names as authored (already camelCase) and drop nulls. - public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings - { - NullValueHandling = NullValueHandling.Ignore, - }; -} diff --git a/dotnet/visualStudioTypeAgent/Bridge/AuthenticatedBridgeConnection.cs b/dotnet/visualStudioTypeAgent/Bridge/AuthenticatedBridgeConnection.cs new file mode 100644 index 0000000000..664bdceedd --- /dev/null +++ b/dotnet/visualStudioTypeAgent/Bridge/AuthenticatedBridgeConnection.cs @@ -0,0 +1,270 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.TypeAgent.VisualStudio.Bridge; + +internal interface IBridgeActionExecutor +{ + Task ExecuteAsync( + string actionName, + JObject parameters, + CancellationToken cancellation); +} + +internal sealed class AuthenticatedBridgeRequestProcessor +{ + private readonly AuthenticatedBridgeSession _session; + private readonly IBridgeActionExecutor _executor; + private long _receiveSequence; + private long _sendSequence; + + public AuthenticatedBridgeRequestProcessor( + AuthenticatedBridgeSession session, + IBridgeActionExecutor executor) + { + _session = session; + _executor = executor; + } + + public async Task ProcessRequestAsync( + string json, + CancellationToken cancellation) + { + JObject root; + try + { + root = JObject.Parse(json); + } + catch (JsonException ex) + { + throw new InvalidOperationException("Authenticated bridge frame was malformed", ex); + } + + VerifiedSecureFrame? verified = BridgeAuthentication.VerifySecureFrame( + root, + _session, + _receiveSequence + 1, + "request", + _session.ServerToClientKey); + if (verified is null) + { + throw new InvalidOperationException("Authenticated bridge frame was rejected"); + } + + string? actionName = verified.Payload.Value("actionName"); + JObject? parameters = verified.Payload["parameters"] as JObject; + if (actionName is null + || actionName.Trim().Length == 0 + || parameters is null) + { + throw new InvalidOperationException("Authenticated bridge request was malformed"); + } + + _receiveSequence = verified.Sequence; + var response = new JObject + { + ["id"] = verified.Id, + }; + try + { + object? result = await _executor + .ExecuteAsync(actionName, parameters, cancellation) + .ConfigureAwait(false); + response["success"] = true; + if (result is not null) + { + response["result"] = JToken.FromObject(result); + } + } + catch (Exception ex) + { + response["success"] = false; + response["error"] = ex.Message; + } + + JObject frame = BridgeAuthentication.CreateSecureFrame( + _session.SessionId, + ++_sendSequence, + "response", + verified.Id, + response, + _session.ClientToServerKey); + return frame.ToString(Formatting.None); + } +} + +internal static class AuthenticatedBridgeConnection +{ + // This channel carries only authenticated VisualStudioActions and compact + // EnvDTE results. Chat and attachment data use the dispatcher connection, + // so 1 MiB is ample while bounding peer-controlled buffering. + internal const int MaxMessageBytes = 1024 * 1024; + private static readonly TimeSpan DefaultAuthenticationTimeout = + TimeSpan.FromSeconds(5); + + public static async Task RunAsync( + Uri uri, + BridgeCredential credential, + string clientInstanceId, + IBridgeActionExecutor executor, + CancellationToken cancellation, + TimeSpan? authenticationTimeout = null) + { + using var ws = new ClientWebSocket(); + await ws.ConnectAsync(uri, cancellation).ConfigureAwait(false); + + ClientHelloContext clientHello = BridgeAuthentication.CreateClientHello( + credential, + clientInstanceId); + await SendTextAsync( + ws, + clientHello.Message.ToString(Formatting.None), + cancellation).ConfigureAwait(false); + + TimeSpan timeout = authenticationTimeout ?? DefaultAuthenticationTimeout; + if (timeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(authenticationTimeout), + "Authentication timeout must be positive"); + } + + string serverHelloText; + using (var authenticationCancellation = + CancellationTokenSource.CreateLinkedTokenSource(cancellation)) + { + authenticationCancellation.CancelAfter(timeout); + try + { + serverHelloText = await ReceiveTextAsync( + ws, + authenticationCancellation.Token).ConfigureAwait(false); + } + catch (OperationCanceledException ex) + when (!cancellation.IsCancellationRequested) + { + await CloseForPolicyViolationAsync(ws).ConfigureAwait(false); + throw new InvalidOperationException( + "Bridge peer authentication timed out", + ex); + } + } + AuthenticatedBridgeSession? session = BridgeAuthentication.VerifyServerHello( + credential, + clientHello, + JObject.Parse(serverHelloText)); + if (session is null) + { + await CloseForPolicyViolationAsync(ws).ConfigureAwait(false); + throw new InvalidOperationException("Bridge peer authentication failed"); + } + + var processor = new AuthenticatedBridgeRequestProcessor(session, executor); + while (ws.State == WebSocketState.Open && !cancellation.IsCancellationRequested) + { + string request; + try + { + request = await ReceiveTextAsync(ws, cancellation).ConfigureAwait(false); + } + catch (WebSocketException) when (ws.State != WebSocketState.Open) + { + return; + } + catch (InvalidOperationException) + { + await CloseForPolicyViolationAsync(ws).ConfigureAwait(false); + throw; + } + + string response; + try + { + response = await processor + .ProcessRequestAsync(request, cancellation) + .ConfigureAwait(false); + } + catch (InvalidOperationException) + { + await CloseForPolicyViolationAsync(ws).ConfigureAwait(false); + throw; + } + await SendTextAsync(ws, response, cancellation).ConfigureAwait(false); + } + } + + internal static async Task ReceiveTextAsync( + WebSocket ws, + CancellationToken cancellation) + { + var buffer = new ArraySegment(new byte[16 * 1024]); + var assembly = new StringBuilder(); + int totalBytes = 0; + WebSocketReceiveResult result; + do + { + result = await ws.ReceiveAsync(buffer, cancellation).ConfigureAwait(false); + if (result.MessageType == WebSocketMessageType.Close) + { + if (ws.State == WebSocketState.CloseReceived) + { + await ws.CloseOutputAsync( + WebSocketCloseStatus.NormalClosure, + null, + CancellationToken.None).ConfigureAwait(false); + } + throw new WebSocketException("Bridge WebSocket closed"); + } + if (result.MessageType != WebSocketMessageType.Text) + { + throw new InvalidOperationException("Bridge sent a non-text message"); + } + totalBytes += result.Count; + if (totalBytes > MaxMessageBytes) + { + throw new InvalidOperationException("Bridge message exceeded the size limit"); + } + assembly.Append(Encoding.UTF8.GetString(buffer.Array!, 0, result.Count)); + } while (!result.EndOfMessage); + return assembly.ToString(); + } + + private static Task SendTextAsync( + ClientWebSocket ws, + string text, + CancellationToken cancellation) + { + byte[] bytes = Encoding.UTF8.GetBytes(text); + return ws.SendAsync( + new ArraySegment(bytes), + WebSocketMessageType.Text, + endOfMessage: true, + cancellation); + } + + private static async Task CloseForPolicyViolationAsync(ClientWebSocket ws) + { + if (ws.State != WebSocketState.Open && ws.State != WebSocketState.CloseReceived) + { + return; + } + try + { + await ws.CloseOutputAsync( + WebSocketCloseStatus.PolicyViolation, + "Authentication failed", + CancellationToken.None).ConfigureAwait(false); + } + catch (WebSocketException) + { + } + } +} diff --git a/dotnet/visualStudioTypeAgent/Bridge/BridgeAuthentication.cs b/dotnet/visualStudioTypeAgent/Bridge/BridgeAuthentication.cs new file mode 100644 index 0000000000..8a0c0d84ad --- /dev/null +++ b/dotnet/visualStudioTypeAgent/Bridge/BridgeAuthentication.cs @@ -0,0 +1,627 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Principal; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.TypeAgent.VisualStudio.Bridge; + +internal sealed class BridgeCredential +{ + public BridgeCredential(string credentialId, byte[] secret) + { + CredentialId = credentialId; + Secret = secret; + } + + public string CredentialId { get; } + public byte[] Secret { get; } +} + +internal sealed class DiscoveryAuthContext +{ + public DiscoveryAuthContext(JObject auth, string nonce) + { + Auth = auth; + Nonce = nonce; + } + + public JObject Auth { get; } + public string Nonce { get; } +} + +internal sealed class ClientHelloContext +{ + public ClientHelloContext(JObject message, string clientInstanceId, string clientNonce) + { + Message = message; + ClientInstanceId = clientInstanceId; + ClientNonce = clientNonce; + } + + public JObject Message { get; } + public string ClientInstanceId { get; } + public string ClientNonce { get; } +} + +internal sealed class AuthenticatedBridgeSession +{ + public AuthenticatedBridgeSession( + string sessionId, + long expiresAt, + byte[] clientToServerKey, + byte[] serverToClientKey) + { + SessionId = sessionId; + ExpiresAt = expiresAt; + ClientToServerKey = clientToServerKey; + ServerToClientKey = serverToClientKey; + } + + public string SessionId { get; } + public long ExpiresAt { get; } + public byte[] ClientToServerKey { get; } + public byte[] ServerToClientKey { get; } +} + +internal sealed class VerifiedSecureFrame +{ + public VerifiedSecureFrame(string id, long sequence, JObject payload) + { + Id = id; + Sequence = sequence; + Payload = payload; + } + + public string Id { get; } + public long Sequence { get; } + public JObject Payload { get; } +} + +internal static class BridgeAuthentication +{ + public const int ProtocolVersion = 1; + public const string VisualStudioAgentName = "visualStudio"; + public const string VisualStudioAgentRole = "default"; + public const string CredentialPathOverrideEnvironmentVariable = + "TYPEAGENT_VS_BRIDGE_CREDENTIAL_PATH"; + + // Handshake and discovery proofs are deliberately short-lived. Verification + // also caps the sender-selected window and permits only small clock skew. + private const long AuthWindowMilliseconds = 15_000; + private const long MaxAuthWindowMilliseconds = 30_000; + private const long ClockSkewMilliseconds = 5_000; + private const long SessionLifetimeMilliseconds = 10 * 60_000; + private static readonly Regex IdPattern = new Regex("^[0-9a-f]{32}$", RegexOptions.CultureInvariant); + private static readonly Regex NoncePattern = new Regex("^[0-9a-f]{64}$", RegexOptions.CultureInvariant); + private static readonly Regex ProofPattern = new Regex("^[0-9a-f]{64}$", RegexOptions.CultureInvariant); + + public static BridgeCredential LoadCredential() + { + string credentialPath = GetCredentialPath(); + string directory = Path.GetDirectoryName(credentialPath) + ?? throw new InvalidOperationException("Credential path has no directory"); + ValidatePrivatePath(directory, isDirectory: true); + ValidatePrivatePath(credentialPath, isDirectory: false); + + var root = JObject.Parse(File.ReadAllText(credentialPath, Encoding.UTF8)); + int? version = root.Value("version"); + string? credentialId = root.Value("credentialId"); + string? secretText = root.Value("secret"); + if (version != ProtocolVersion + || credentialId is null + || !IdPattern.IsMatch(credentialId) + || secretText is null + || !ProofPattern.IsMatch(secretText)) + { + throw new InvalidOperationException("Visual Studio bridge credential is malformed"); + } + + byte[] secret = FromHex(secretText); + string expectedId = ToHex(Sha256(secret)).Substring(0, 32); + if (!FixedTimeEquals(credentialId, expectedId)) + { + throw new InvalidOperationException("Visual Studio bridge credential identifier mismatch"); + } + return new BridgeCredential(credentialId, secret); + } + + public static string CreateClientInstanceId() => ToHex(RandomBytes(16)); + + public static DiscoveryAuthContext CreateDiscoveryRequest( + BridgeCredential credential, + string agentName, + string role, + long? nowOverride = null) + { + long now = nowOverride ?? CurrentUnixMilliseconds(); + long expiresAt = now + AuthWindowMilliseconds; + string nonce = ToHex(RandomBytes(32)); + var auth = new JObject + { + ["version"] = ProtocolVersion, + ["credentialId"] = credential.CredentialId, + ["nonce"] = nonce, + ["issuedAt"] = now, + ["expiresAt"] = expiresAt, + ["proof"] = CreateProof( + credential.Secret, + "typeagent-vs-discovery-request-v1", + agentName, + NormalizeRole(agentName, role), + credential.CredentialId, + nonce, + FormatLong(now), + FormatLong(expiresAt)), + }; + return new DiscoveryAuthContext(auth, nonce); + } + + public static bool VerifyDiscoveryResponse( + BridgeCredential credential, + DiscoveryAuthContext request, + string agentName, + string role, + int? port, + string? url, + JObject? auth, + long? nowOverride = null) + { + if (auth is null) + { + return false; + } + int? version = auth.Value("version"); + string? credentialId = auth.Value("credentialId"); + string? requestNonce = auth.Value("requestNonce"); + string? serverNonce = auth.Value("serverNonce"); + long? issuedAt = auth.Value("issuedAt"); + long? expiresAt = auth.Value("expiresAt"); + string? proof = auth.Value("proof"); + long now = nowOverride ?? CurrentUnixMilliseconds(); + if (version != ProtocolVersion + || credentialId is null + || !FixedTimeEquals(credentialId, credential.CredentialId) + || requestNonce is null + || !FixedTimeEquals(requestNonce, request.Nonce) + || serverNonce is null + || !NoncePattern.IsMatch(serverNonce) + || issuedAt is null + || expiresAt is null + || proof is null + || !ProofPattern.IsMatch(proof) + || !IsFresh(issuedAt.Value, expiresAt.Value, now, MaxAuthWindowMilliseconds)) + { + return false; + } + + string expected = CreateProof( + credential.Secret, + "typeagent-vs-discovery-response-v1", + agentName, + NormalizeRole(agentName, role), + credential.CredentialId, + request.Nonce, + serverNonce, + FormatLong(issuedAt.Value), + FormatLong(expiresAt.Value), + port is null ? "null" : FormatLong(port.Value), + url ?? string.Empty); + return FixedTimeEquals(proof, expected); + } + + public static ClientHelloContext CreateClientHello( + BridgeCredential credential, + string clientInstanceId, + long? nowOverride = null) + { + long now = nowOverride ?? CurrentUnixMilliseconds(); + long expiresAt = now + AuthWindowMilliseconds; + string clientNonce = ToHex(RandomBytes(32)); + var message = new JObject + { + ["type"] = "clientHello", + ["version"] = ProtocolVersion, + ["credentialId"] = credential.CredentialId, + ["clientInstanceId"] = clientInstanceId, + ["clientNonce"] = clientNonce, + ["issuedAt"] = now, + ["expiresAt"] = expiresAt, + ["proof"] = CreateProof( + credential.Secret, + "typeagent-vs-client-hello-v1", + credential.CredentialId, + clientInstanceId, + clientNonce, + FormatLong(now), + FormatLong(expiresAt)), + }; + return new ClientHelloContext(message, clientInstanceId, clientNonce); + } + + public static AuthenticatedBridgeSession? VerifyServerHello( + BridgeCredential credential, + ClientHelloContext request, + JObject value, + long? nowOverride = null) + { + string? type = value.Value("type"); + int? version = value.Value("version"); + string? credentialId = value.Value("credentialId"); + string? clientNonce = value.Value("clientNonce"); + string? serverNonce = value.Value("serverNonce"); + string? sessionId = value.Value("sessionId"); + long? issuedAt = value.Value("issuedAt"); + long? expiresAt = value.Value("expiresAt"); + string? proof = value.Value("proof"); + long now = nowOverride ?? CurrentUnixMilliseconds(); + if (type != "serverHello" + || version != ProtocolVersion + || credentialId is null + || !FixedTimeEquals(credentialId, credential.CredentialId) + || clientNonce is null + || !FixedTimeEquals(clientNonce, request.ClientNonce) + || serverNonce is null + || !NoncePattern.IsMatch(serverNonce) + || sessionId is null + || !IdPattern.IsMatch(sessionId) + || issuedAt is null + || expiresAt is null + || proof is null + || !ProofPattern.IsMatch(proof) + || !IsFresh(issuedAt.Value, expiresAt.Value, now, SessionLifetimeMilliseconds)) + { + return null; + } + + string expected = CreateProof( + credential.Secret, + "typeagent-vs-server-hello-v1", + credential.CredentialId, + request.ClientInstanceId, + request.ClientNonce, + serverNonce, + sessionId, + FormatLong(issuedAt.Value), + FormatLong(expiresAt.Value)); + if (!FixedTimeEquals(proof, expected)) + { + return null; + } + + string[] keyFields = + { + credential.CredentialId, + request.ClientInstanceId, + request.ClientNonce, + serverNonce, + sessionId, + }; + // Direction-specific domain labels prevent a valid request MAC from + // being reflected as a response MAC (or vice versa). + return new AuthenticatedBridgeSession( + sessionId, + expiresAt.Value, + CreateMac( + credential.Secret, + "typeagent-vs-session-client-to-server-v1", + keyFields), + CreateMac( + credential.Secret, + "typeagent-vs-session-server-to-client-v1", + keyFields)); + } + + public static JObject CreateSecureFrame( + string sessionId, + long sequence, + string kind, + string id, + JObject payload, + byte[] key) + { + string encodedPayload = Convert.ToBase64String( + Encoding.UTF8.GetBytes(payload.ToString(Formatting.None))); + return new JObject + { + ["type"] = "secureFrame", + ["version"] = ProtocolVersion, + ["sessionId"] = sessionId, + ["sequence"] = sequence, + ["kind"] = kind, + ["id"] = id, + ["payload"] = encodedPayload, + ["proof"] = CreateProof( + key, + "typeagent-vs-secure-frame-v1", + sessionId, + FormatLong(sequence), + kind, + id, + encodedPayload), + }; + } + + public static VerifiedSecureFrame? VerifySecureFrame( + JObject value, + AuthenticatedBridgeSession session, + long expectedSequence, + string expectedKind, + byte[] key, + long? nowOverride = null) + { + string? type = value.Value("type"); + int? version = value.Value("version"); + string? sessionId = value.Value("sessionId"); + long? sequence = value.Value("sequence"); + string? kind = value.Value("kind"); + string? id = value.Value("id"); + string? payloadText = value.Value("payload"); + string? proof = value.Value("proof"); + long now = nowOverride ?? CurrentUnixMilliseconds(); + if (type != "secureFrame" + || version != ProtocolVersion + || sessionId is null + || !FixedTimeEquals(sessionId, session.SessionId) + || sequence != expectedSequence + || kind != expectedKind + || id is null + || !IdPattern.IsMatch(id) + || payloadText is null + || proof is null + || !ProofPattern.IsMatch(proof) + || now > session.ExpiresAt) + { + return null; + } + + string expected = CreateProof( + key, + "typeagent-vs-secure-frame-v1", + session.SessionId, + FormatLong(sequence.Value), + kind, + id, + payloadText); + if (!FixedTimeEquals(proof, expected)) + { + return null; + } + + try + { + string payloadJson = Encoding.UTF8.GetString(Convert.FromBase64String(payloadText)); + var payload = JObject.Parse(payloadJson); + return new VerifiedSecureFrame(id, sequence.Value, payload); + } + catch (Exception) + { + return null; + } + } + + public static string CreateRequestId() => ToHex(RandomBytes(16)); + + public static long CurrentUnixMilliseconds() => + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + internal static string GetCredentialPath() + { + string? overridePath = Environment.GetEnvironmentVariable( + CredentialPathOverrideEnvironmentVariable); + if (overridePath is not null) + { + return CanonicalizeCredentialPathOverride(overridePath); + } + + string userProfile = Environment.GetFolderPath( + Environment.SpecialFolder.UserProfile); + if (string.IsNullOrWhiteSpace(userProfile)) + { + throw new InvalidOperationException( + "Current user profile path is unavailable"); + } + return Path.GetFullPath(Path.Combine( + userProfile, + ".typeagent", + "visual-studio", + "bridge-credential.json")); + } + + private static string CanonicalizeCredentialPathOverride(string value) + { + if (value.Length == 0 + || !string.Equals(value, value.Trim(), StringComparison.Ordinal) + || value.Contains('\0') + || !IsFullyQualifiedPath(value)) + { + throw new InvalidOperationException( + $"{CredentialPathOverrideEnvironmentVariable} must be a canonicalizable absolute path without surrounding whitespace"); + } + try + { + return Path.GetFullPath(value); + } + catch (Exception ex) + when (ex is ArgumentException + || ex is NotSupportedException + || ex is PathTooLongException) + { + throw new InvalidOperationException( + $"{CredentialPathOverrideEnvironmentVariable} is invalid", + ex); + } + } + + private static bool IsFullyQualifiedPath(string value) + { + if (value.StartsWith(@"\\", StringComparison.Ordinal) + || value.StartsWith("//", StringComparison.Ordinal)) + { + return true; + } + return value.Length >= 3 + && char.IsLetter(value[0]) + && value[1] == ':' + && (value[2] == Path.DirectorySeparatorChar + || value[2] == Path.AltDirectorySeparatorChar); + } + + private static void ValidatePrivatePath(string targetPath, bool isDirectory) + { + if (!OperatingSystemIsWindows()) + { + throw new PlatformNotSupportedException( + "Visual Studio bridge credentials require Windows ACL validation"); + } + + FileAttributes attributes = File.GetAttributes(targetPath); + if ((attributes & FileAttributes.ReparsePoint) != 0 + || isDirectory != ((attributes & FileAttributes.Directory) != 0)) + { + throw new InvalidOperationException("Visual Studio bridge credential path is unsafe"); + } + + WindowsIdentity identity = WindowsIdentity.GetCurrent(); + SecurityIdentifier currentSid = identity.User + ?? throw new InvalidOperationException("Current Windows identity has no SID"); + var systemSid = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); + FileSystemSecurity security = isDirectory + ? (FileSystemSecurity)new DirectoryInfo(targetPath).GetAccessControl( + AccessControlSections.Owner | AccessControlSections.Access) + : new FileInfo(targetPath).GetAccessControl( + AccessControlSections.Owner | AccessControlSections.Access); + if (security.GetOwner(typeof(SecurityIdentifier)) is not SecurityIdentifier owner + || !owner.Equals(currentSid) + || !security.AreAccessRulesProtected) + { + throw new InvalidOperationException("Visual Studio bridge credential ACL is invalid"); + } + + bool currentUserAllowed = false; + AuthorizationRuleCollection rules = security.GetAccessRules( + includeExplicit: true, + includeInherited: true, + targetType: typeof(SecurityIdentifier)); + foreach (FileSystemAccessRule rule in rules.Cast()) + { + var sid = (SecurityIdentifier)rule.IdentityReference; + if (rule.IsInherited + || rule.AccessControlType != AccessControlType.Allow + || (!sid.Equals(currentSid) && !sid.Equals(systemSid)) + || (rule.FileSystemRights & FileSystemRights.FullControl) != FileSystemRights.FullControl) + { + throw new InvalidOperationException("Visual Studio bridge credential ACL is invalid"); + } + if (sid.Equals(currentSid)) + { + currentUserAllowed = true; + } + } + if (!currentUserAllowed) + { + throw new InvalidOperationException("Visual Studio bridge credential ACL is invalid"); + } + } + + private static bool OperatingSystemIsWindows() => + Environment.OSVersion.Platform == PlatformID.Win32NT; + + private static bool IsFresh(long issuedAt, long expiresAt, long now, long maxWindow) => + expiresAt > issuedAt + && expiresAt - issuedAt <= maxWindow + && issuedAt <= now + ClockSkewMilliseconds + && expiresAt >= now - ClockSkewMilliseconds; + + private static string NormalizeRole(string agentName, string role) => + // Discovery historically permits an omitted Visual Studio role. Both + // runtimes authenticate that spelling as the canonical "default" tuple. + agentName == VisualStudioAgentName && string.IsNullOrEmpty(role) + ? VisualStudioAgentRole + : role; + + private static string CreateProof(byte[] key, string domain, params string[] fields) => + ToHex(CreateMac(key, domain, fields)); + + private static byte[] CreateMac(byte[] key, string domain, params string[] fields) + { + // This is the cross-runtime canonical encoding: UTF-8 domain label, + // followed by each ordered field prefixed with LF. Domain separation + // keeps proofs from one protocol phase valid in another. + using var hmac = new HMACSHA256(key); + var input = new StringBuilder(domain); + foreach (string field in fields) + { + input.Append('\n'); + input.Append(field); + } + return hmac.ComputeHash(Encoding.UTF8.GetBytes(input.ToString())); + } + + private static byte[] Sha256(byte[] value) + { + using var sha256 = SHA256.Create(); + return sha256.ComputeHash(value); + } + + private static byte[] RandomBytes(int count) + { + var value = new byte[count]; + using var random = RandomNumberGenerator.Create(); + random.GetBytes(value); + return value; + } + + private static string FormatLong(long value) => + value.ToString(CultureInfo.InvariantCulture); + + private static string ToHex(byte[] value) + { + var builder = new StringBuilder(value.Length * 2); + foreach (byte b in value) + { + builder.Append(b.ToString("x2", CultureInfo.InvariantCulture)); + } + return builder.ToString(); + } + + private static byte[] FromHex(string value) + { + var bytes = new byte[value.Length / 2]; + for (int i = 0; i < bytes.Length; i++) + { + bytes[i] = byte.Parse( + value.Substring(i * 2, 2), + NumberStyles.HexNumber, + CultureInfo.InvariantCulture); + } + return bytes; + } + + private static bool FixedTimeEquals(string left, string right) + { + // Do not short-circuit on a differing byte or length; peer-provided + // identifiers and proofs must not expose comparison timing. + byte[] leftBytes = Encoding.UTF8.GetBytes(left); + byte[] rightBytes = Encoding.UTF8.GetBytes(right); + int difference = leftBytes.Length ^ rightBytes.Length; + int length = Math.Max(leftBytes.Length, rightBytes.Length); + for (int i = 0; i < length; i++) + { + byte leftByte = i < leftBytes.Length ? leftBytes[i] : (byte)0; + byte rightByte = i < rightBytes.Length ? rightBytes[i] : (byte)0; + difference |= leftByte ^ rightByte; + } + return difference == 0; + } +} diff --git a/dotnet/visualStudioTypeAgent/Bridge/BridgeDiscovery.cs b/dotnet/visualStudioTypeAgent/Bridge/BridgeDiscovery.cs index 3d15fd882c..a2e7641084 100644 --- a/dotnet/visualStudioTypeAgent/Bridge/BridgeDiscovery.cs +++ b/dotnet/visualStudioTypeAgent/Bridge/BridgeDiscovery.cs @@ -21,11 +21,13 @@ namespace Microsoft.TypeAgent.VisualStudio.Bridge; /// { "name": "discovery", /// "message": { "type": "invoke", "callId": N, "name": "lookupPort", /// "args": [{ "agentName": "visualStudio", -/// "role": "default" }] } } +/// "role": "default", +/// "auth": { ... } }] } } /// server → client: /// { "name": "discovery", /// "message": { "type": "invokeResult", "callId": N, -/// "result": { "port": <int|null> } } } +/// "result": { "port": <int|null>, +/// "auth": { ... } } } } /// /// Returns the discovered port, or null when the agent isn't yet /// registered with the agent-server (transient — caller should retry). @@ -66,10 +68,15 @@ internal static class BridgeDiscovery /// Throws on transport failure (agent-server unreachable, timeout, /// malformed response) so the caller can log and retry. /// - public static async Task ResolveBridgePortAsync(CancellationToken cancellation) + public static async Task ResolveBridgePortAsync( + BridgeCredential credential, + CancellationToken cancellation) { uint agentServerPort = GetAgentServerPort(); - uint? discovered = await LookupPortAsync(agentServerPort, cancellation).ConfigureAwait(false); + uint? discovered = await LookupPortAsync( + agentServerPort, + credential, + cancellation).ConfigureAwait(false); if (discovered is uint p) { Debug.WriteLine($"[TypeAgent] Discovery resolved bridge port {p}"); @@ -91,7 +98,10 @@ private static uint GetAgentServerPort() return DefaultAgentServerPort; } - private static async Task LookupPortAsync(uint agentServerPort, CancellationToken cancellation) + private static async Task LookupPortAsync( + uint agentServerPort, + BridgeCredential credential, + CancellationToken cancellation) { var uri = new Uri($"{AgentServerHost}:{agentServerPort}/"); using var ws = new ClientWebSocket(); @@ -106,6 +116,10 @@ private static uint GetAgentServerPort() // callId is arbitrary — the server echoes it back verbatim, // and we only have one outstanding request per socket. const int callId = 1; + DiscoveryAuthContext discoveryAuth = BridgeAuthentication.CreateDiscoveryRequest( + credential, + AgentName, + Role); var request = new JObject { ["name"] = "discovery", @@ -120,6 +134,7 @@ private static uint GetAgentServerPort() { ["agentName"] = AgentName, ["role"] = Role, + ["auth"] = discoveryAuth.Auth, }, }, }, @@ -171,9 +186,22 @@ await ws.SendAsync( { return null; } + int? portValue = result.Value("port"); + string? url = result.Value("url"); + var responseAuth = result["auth"] as JObject; + if (!BridgeAuthentication.VerifyDiscoveryResponse( + credential, + discoveryAuth, + AgentName, + Role, + portValue, + url, + responseAuth)) + { + throw new InvalidOperationException("Discovery peer authentication failed"); + } // `port` is `int|null` on the wire; clamp to the valid port // range and surface anything else as "not registered". - int? portValue = result.Value("port"); if (portValue is int pv && pv > 0 && pv <= 65535) { return (uint)pv; diff --git a/dotnet/visualStudioTypeAgent/Bridge/DTEActionExecutor.cs b/dotnet/visualStudioTypeAgent/Bridge/DTEActionExecutor.cs index 8e12a90b73..9db0a96c92 100644 --- a/dotnet/visualStudioTypeAgent/Bridge/DTEActionExecutor.cs +++ b/dotnet/visualStudioTypeAgent/Bridge/DTEActionExecutor.cs @@ -18,7 +18,7 @@ namespace Microsoft.TypeAgent.VisualStudio.Bridge; /// /// All DTE access happens on the VS UI thread. /// -internal sealed class DTEActionExecutor +internal sealed class DTEActionExecutor : IBridgeActionExecutor { private readonly AsyncPackage _package; diff --git a/dotnet/visualStudioTypeAgent/VerifyVsixContents.ps1 b/dotnet/visualStudioTypeAgent/VerifyVsixContents.ps1 new file mode 100644 index 0000000000..d84a0df1c7 --- /dev/null +++ b/dotnet/visualStudioTypeAgent/VerifyVsixContents.ps1 @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +param( + [Parameter(Mandatory = $true)] + [string] $VsixPath +) + +$ErrorActionPreference = "Stop" + +Add-Type -AssemblyName System.IO.Compression.FileSystem + +$resolvedVsixPath = (Resolve-Path -LiteralPath $VsixPath).Path +$archive = [System.IO.Compression.ZipFile]::OpenRead($resolvedVsixPath) +try { + $entries = @($archive.Entries) + $index = $entries | Where-Object { + $_.FullName.Replace("\", "/") -eq "webview-content/index.html" + } + if ($null -eq $index -or $index.Length -eq 0) { + throw "VSIX is missing webview-content/index.html" + } + + $javascript = @($entries | Where-Object { + $name = $_.FullName.Replace("\", "/") + $name.StartsWith("webview-content/assets/", [System.StringComparison]::Ordinal) -and + $name.EndsWith(".js", [System.StringComparison]::OrdinalIgnoreCase) -and + $_.Length -gt 0 + }) + if ($javascript.Count -eq 0) { + throw "VSIX is missing a nonempty WebView JavaScript bundle" + } + + Write-Output "Verified Visual Studio WebView assets in $resolvedVsixPath" +} +finally { + $archive.Dispose() +} diff --git a/dotnet/visualStudioTypeAgent/VisualStudioTypeAgent.csproj b/dotnet/visualStudioTypeAgent/VisualStudioTypeAgent.csproj index 866c8f99e7..6ce7103321 100644 --- a/dotnet/visualStudioTypeAgent/VisualStudioTypeAgent.csproj +++ b/dotnet/visualStudioTypeAgent/VisualStudioTypeAgent.csproj @@ -30,6 +30,9 @@ /rootsuffix Exp latest enable + $(MSBuildProjectDirectory)\..\..\ts + $(TypeAgentTsWorkspace)\packages\agents\visualStudio\host\webview + $(VisualStudioWebViewDirectory)\dist @@ -63,6 +66,7 @@ + @@ -73,6 +77,8 @@ ChatToolWindowControl.xaml + + @@ -88,16 +94,31 @@ Designer + Menus.ctmenu - - - true - - + + + + <_VisualStudioWebViewJavaScript Include="$(VisualStudioWebViewDistDirectory)\assets\*.js" /> + + + + + + webview-content + + + webview-content\assets + + +