diff --git a/Unity-MCP-Plugin/Assets/Plugins/NuGet/McpPlugin.Common.dll b/Unity-MCP-Plugin/Assets/Plugins/NuGet/McpPlugin.Common.dll index bf15822c6..42bd785fe 100644 Binary files a/Unity-MCP-Plugin/Assets/Plugins/NuGet/McpPlugin.Common.dll and b/Unity-MCP-Plugin/Assets/Plugins/NuGet/McpPlugin.Common.dll differ diff --git a/Unity-MCP-Plugin/Assets/Plugins/NuGet/McpPlugin.dll b/Unity-MCP-Plugin/Assets/Plugins/NuGet/McpPlugin.dll index 462d0a62d..b4e19e11c 100644 Binary files a/Unity-MCP-Plugin/Assets/Plugins/NuGet/McpPlugin.dll and b/Unity-MCP-Plugin/Assets/Plugins/NuGet/McpPlugin.dll differ diff --git a/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Runtime/UnityMcpPlugin.cs b/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Runtime/UnityMcpPlugin.cs index b5c0c0655..d94ba408f 100644 --- a/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Runtime/UnityMcpPlugin.cs +++ b/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Runtime/UnityMcpPlugin.cs @@ -14,6 +14,7 @@ using System.Threading; using System.Threading.Tasks; using com.IvanMurzak.McpPlugin; +using com.IvanMurzak.McpPlugin.AgentConfig; using com.IvanMurzak.McpPlugin.Common.Model; using com.IvanMurzak.ReflectorNet; using Microsoft.AspNetCore.SignalR.Client; @@ -246,30 +247,30 @@ public static string GenerateToken() } /// - /// Generate a deterministic TCP port based on current directory. - /// Uses SHA256 hash for better distribution and less collisions. - /// Port range: 20000-29999 (avoids Windows ephemeral/reserved port ranges). + /// Generate a deterministic TCP port for the current working directory + /// (). Port range: 20000-29999 (avoids Windows + /// ephemeral/reserved port ranges). /// + /// + /// Defect B10 fix (auth-fixes d1): the derivation runs the directory through the shared + /// v2 normalization (trim trailing separators, convert + /// '\\' to '/', then ) via + /// — instead of hashing the raw, untrimmed, un-separator- + /// normalized string it used before. This keeps the + /// local port in lock-step with the routing pin (also v2-normalized), so a Windows working + /// directory reported with backslashes hashes identically to its forward-slash form. The port + /// byte-math (first 4 hash bytes, little-endian, mapped into 20000-29999) is unchanged, so a + /// path with no backslashes and no trailing separator yields the same port as before. + /// public static int GeneratePortFromDirectory() - { - const int MinPort = 20000; // Range chosen to avoid Windows ephemeral/reserved ports (49152-65535) - const int MaxPort = 29999; - const int PortRange = MaxPort - MinPort + 1; - - var currentDir = Environment.CurrentDirectory.ToLowerInvariant(); - - using (var sha256 = System.Security.Cryptography.SHA256.Create()) - { - var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(currentDir)); - - // Use first 4 bytes as an unsigned integer to avoid Math.Abs(int.MinValue) overflow - var hash = (uint)BitConverter.ToInt32(hashBytes, 0); + => GeneratePortFromDirectory(Environment.CurrentDirectory); - // Map to port range - var port = MinPort + (int)(hash % PortRange); - - return port; - } - } + /// + /// Deterministic TCP port for an explicit , derived via the shared + /// v2 normalization (see ). + /// Exposed so the derivation is unit-testable independent of the process working directory. + /// + public static int GeneratePortFromDirectory(string directory) + => ProjectIdentity.DerivePortV2(directory); } } diff --git a/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Auth/PortDerivationV2Tests.cs b/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Auth/PortDerivationV2Tests.cs new file mode 100644 index 000000000..57e6f397f --- /dev/null +++ b/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Auth/PortDerivationV2Tests.cs @@ -0,0 +1,81 @@ +/* +┌──────────────────────────────────────────────────────────────────┐ +│ Author: Ivan Murzak (https://github.com/IvanMurzak) │ +│ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │ +│ Copyright (c) 2025 Ivan Murzak │ +│ Licensed under the Apache License, Version 2.0. │ +│ See the LICENSE file in the project root for more information. │ +└──────────────────────────────────────────────────────────────────┘ +*/ + +#nullable enable +using System; +using com.IvanMurzak.McpPlugin.AgentConfig; +using NUnit.Framework; + +namespace com.IvanMurzak.Unity.MCP.Editor.Tests +{ + /// + /// Defect B10 fix (auth-fixes d1): + /// now derives the deterministic local port through the shared v2 + /// normalization (trim trailing separators, '\\''/', ) + /// instead of hashing the raw, untrimmed working-directory string. That keeps the port in lock-step with + /// the routing pin, so a Windows working directory reported with backslashes maps to the SAME port as its + /// forward-slash form. Expected values are the committed v2 golden vectors + /// (MCP-Plugin-dotnet/McpPlugin/src/AgentConfig/ProjectIdentity.GoldenVectors.v2.json). + /// + public class PortDerivationV2Tests + { + [Test] + public void GeneratePortFromDirectory_DelegatesToProjectIdentityV2() + { + const string dir = @"C:\Users\user\my-game"; + Assert.AreEqual(ProjectIdentity.DerivePortV2(dir), UnityMcpPlugin.GeneratePortFromDirectory(dir)); + } + + // v2 golden vectors: backslash and forward-slash forms of the same Windows root converge (the B5/B10 + // fix); a trailing separator is trimmed; a POSIX path is unaffected (v2 == v1 there). + [TestCase(@"C:\Users\user\my-game", 24298)] // Windows backslash form + [TestCase(@"C:\Users\user\my-game\", 24298)] // trailing backslash trimmed → identical + [TestCase("C:/Users/user/my-game", 24298)] // forward-slash form → SAME port under v2 + [TestCase("C:/Users/user/my-game/", 24298)] // trailing slash trimmed → identical + [TestCase("/home/user/my-game", 23940)] // POSIX typical → v2 == v1 + [TestCase("/home/user/my-game/", 23940)] // trailing slash trimmed → identical + public void GeneratePortFromDirectory_MatchesV2GoldenPort(string dir, int expectedPort) + { + Assert.AreEqual(expectedPort, UnityMcpPlugin.GeneratePortFromDirectory(dir)); + } + + [Test] + public void GeneratePortFromDirectory_BackslashAndForwardSlash_Converge() + { + // The defining B10/B5 property: the two separator forms of one root yield the SAME port, + // whereas the pre-fix raw-string derivation gave two different ports on Windows. + Assert.AreEqual( + UnityMcpPlugin.GeneratePortFromDirectory(@"C:\Users\user\my-game"), + UnityMcpPlugin.GeneratePortFromDirectory("C:/Users/user/my-game")); + } + + [Test] + public void GeneratePortFromDirectory_TrailingSeparatorInsensitive() + { + Assert.AreEqual( + UnityMcpPlugin.GeneratePortFromDirectory(@"C:\Users\user\my-game"), + UnityMcpPlugin.GeneratePortFromDirectory(@"C:\Users\user\my-game\")); + } + + [Test] + public void GeneratePortFromDirectory_StaysInDeterministicRange() + { + var port = UnityMcpPlugin.GeneratePortFromDirectory(@"C:\Users\user\my-game"); + Assert.GreaterOrEqual(port, ProjectIdentity.MinPort); + Assert.LessOrEqual(port, ProjectIdentity.MaxPort); + } + + [Test] + public void GeneratePortFromDirectory_NullDirectory_Throws() + { + Assert.Throws(() => UnityMcpPlugin.GeneratePortFromDirectory(null!)); + } + } +} diff --git a/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Auth/PortDerivationV2Tests.cs.meta b/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Auth/PortDerivationV2Tests.cs.meta new file mode 100644 index 000000000..4c9ad8206 --- /dev/null +++ b/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Auth/PortDerivationV2Tests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8971f228deb68044c9c1b4443bcf241d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Auth/ProjectInstanceServiceTests.cs b/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Auth/ProjectInstanceServiceTests.cs index 680e0f91b..d12ed32e1 100644 --- a/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Auth/ProjectInstanceServiceTests.cs +++ b/Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Auth/ProjectInstanceServiceTests.cs @@ -13,6 +13,7 @@ using System.IO; using com.IvanMurzak.McpPlugin; using com.IvanMurzak.McpPlugin.AgentConfig; +using com.IvanMurzak.McpPlugin.Common; using com.IvanMurzak.Unity.MCP.Editor.Services; using NUnit.Framework; @@ -60,6 +61,41 @@ public void BuildMetadata_ProducesUnityHandshakePayload_WithPinAsHashPrefix() StringAssert.StartsWith(ProjectIdentity.DerivePin(ProjectRoot), metadata.ProjectPathHash); } + [Test] + public void BuildMetadata_SendsDualHash_V2PrimaryAndLegacyV1() + { + // A Windows-style backslash root is the case where the v1 and v2 normalizations diverge + // (v2 converts '\' -> '/', v1 does not), so both hashes must be present and distinct — this + // is exactly the dual-hash transition (auth-fixes T3 / defect B5) that lets a session pinned + // by an OLD (v1-pin) config still match this NEW plugin. + const string winRoot = @"C:\Users\dev\MyGame"; + var metadata = ProjectInstanceService.BuildMetadata(winRoot, "MyGame", instanceId: "sess-dh"); + + // Primary hash = v2 (separator-normalized); legacy hash = v1 (separators NOT normalized). + Assert.AreEqual(ProjectIdentity.DeriveProjectPathHashV2(winRoot), metadata.ProjectPathHash); + Assert.AreEqual(ProjectIdentity.DeriveProjectPathHash(winRoot), metadata.ProjectPathHashLegacy); + Assert.AreEqual(64, metadata.ProjectPathHash.Length); + Assert.AreEqual(64, metadata.ProjectPathHashLegacy.Length); + Assert.AreNotEqual(metadata.ProjectPathHash, metadata.ProjectPathHashLegacy, + "on a Windows backslash root the v2 and v1 hashes must diverge, proving both are sent"); + + // The v2 routing pin is a prefix of the primary hash (server pin-matches by prefix). + StringAssert.StartsWith(ProjectIdentity.DerivePinV2(winRoot), metadata.ProjectPathHash); + } + + [Test] + public void BuildMetadata_ToQuery_CarriesBothHashKeys() + { + const string winRoot = @"C:\Users\dev\MyGame"; + var metadata = ProjectInstanceService.BuildMetadata(winRoot, "MyGame", instanceId: "sess-dh2"); + + var query = metadata.ToQuery(); + + // Both hashes travel as hub-handshake query parameters (dual-hash visible in the handshake). + Assert.AreEqual(metadata.ProjectPathHash, query[Consts.MCP.Server.HubQuery.ProjectPathHash]); + Assert.AreEqual(metadata.ProjectPathHashLegacy, query[Consts.MCP.Server.HubQuery.ProjectPathHashLegacy]); + } + [Test] public void BuildMetadata_DefaultsInstanceId_ToSessionInstanceId() { diff --git a/Unity-Tests/2022.3.62f3/Assets/Plugins/NuGet/McpPlugin.Common.dll b/Unity-Tests/2022.3.62f3/Assets/Plugins/NuGet/McpPlugin.Common.dll index bf15822c6..42bd785fe 100644 Binary files a/Unity-Tests/2022.3.62f3/Assets/Plugins/NuGet/McpPlugin.Common.dll and b/Unity-Tests/2022.3.62f3/Assets/Plugins/NuGet/McpPlugin.Common.dll differ diff --git a/Unity-Tests/2022.3.62f3/Assets/Plugins/NuGet/McpPlugin.dll b/Unity-Tests/2022.3.62f3/Assets/Plugins/NuGet/McpPlugin.dll index 462d0a62d..b4e19e11c 100644 Binary files a/Unity-Tests/2022.3.62f3/Assets/Plugins/NuGet/McpPlugin.dll and b/Unity-Tests/2022.3.62f3/Assets/Plugins/NuGet/McpPlugin.dll differ diff --git a/Unity-Tests/2023.2.22f1/Assets/Plugins/NuGet/McpPlugin.Common.dll b/Unity-Tests/2023.2.22f1/Assets/Plugins/NuGet/McpPlugin.Common.dll index bf15822c6..42bd785fe 100644 Binary files a/Unity-Tests/2023.2.22f1/Assets/Plugins/NuGet/McpPlugin.Common.dll and b/Unity-Tests/2023.2.22f1/Assets/Plugins/NuGet/McpPlugin.Common.dll differ diff --git a/Unity-Tests/2023.2.22f1/Assets/Plugins/NuGet/McpPlugin.dll b/Unity-Tests/2023.2.22f1/Assets/Plugins/NuGet/McpPlugin.dll index 462d0a62d..b4e19e11c 100644 Binary files a/Unity-Tests/2023.2.22f1/Assets/Plugins/NuGet/McpPlugin.dll and b/Unity-Tests/2023.2.22f1/Assets/Plugins/NuGet/McpPlugin.dll differ diff --git a/Unity-Tests/6000.3.1f1/Assets/Plugins/NuGet/McpPlugin.Common.dll b/Unity-Tests/6000.3.1f1/Assets/Plugins/NuGet/McpPlugin.Common.dll index bf15822c6..42bd785fe 100644 Binary files a/Unity-Tests/6000.3.1f1/Assets/Plugins/NuGet/McpPlugin.Common.dll and b/Unity-Tests/6000.3.1f1/Assets/Plugins/NuGet/McpPlugin.Common.dll differ diff --git a/Unity-Tests/6000.3.1f1/Assets/Plugins/NuGet/McpPlugin.dll b/Unity-Tests/6000.3.1f1/Assets/Plugins/NuGet/McpPlugin.dll index 462d0a62d..b4e19e11c 100644 Binary files a/Unity-Tests/6000.3.1f1/Assets/Plugins/NuGet/McpPlugin.dll and b/Unity-Tests/6000.3.1f1/Assets/Plugins/NuGet/McpPlugin.dll differ diff --git a/Unity-Tests/6000.5.0b3/Assets/Plugins/NuGet/McpPlugin.Common.dll b/Unity-Tests/6000.5.0b3/Assets/Plugins/NuGet/McpPlugin.Common.dll index bf15822c6..42bd785fe 100644 Binary files a/Unity-Tests/6000.5.0b3/Assets/Plugins/NuGet/McpPlugin.Common.dll and b/Unity-Tests/6000.5.0b3/Assets/Plugins/NuGet/McpPlugin.Common.dll differ diff --git a/Unity-Tests/6000.5.0b3/Assets/Plugins/NuGet/McpPlugin.dll b/Unity-Tests/6000.5.0b3/Assets/Plugins/NuGet/McpPlugin.dll index 462d0a62d..b4e19e11c 100644 Binary files a/Unity-Tests/6000.5.0b3/Assets/Plugins/NuGet/McpPlugin.dll and b/Unity-Tests/6000.5.0b3/Assets/Plugins/NuGet/McpPlugin.dll differ diff --git a/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.Common.dll b/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.Common.dll index bf15822c6..42bd785fe 100644 Binary files a/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.Common.dll and b/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.Common.dll differ diff --git a/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.dll b/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.dll index 462d0a62d..b4e19e11c 100644 Binary files a/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.dll and b/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.dll differ