@atelierai/uco 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/codegen/emit.js +1 -1
  3. package/dist/codegen/fetch.js +1 -1
  4. package/dist/codegen/fetch.js.map +1 -1
  5. package/dist/commands/devops/login.js +1 -1
  6. package/dist/commands/devops/login.js.map +1 -1
  7. package/dist/commands/devops/setup-mcp.js +1 -1
  8. package/dist/commands/devops/setup-mcp.js.map +1 -1
  9. package/dist/commands/gen.js +2 -2
  10. package/dist/commands/gen.js.map +1 -1
  11. package/dist/commands/ping.js +2 -2
  12. package/dist/commands/ping.js.map +1 -1
  13. package/dist/config/resolve.js +1 -1
  14. package/dist/config/resolve.js.map +1 -1
  15. package/dist/generated/tools.js +1 -1
  16. package/dist/generated/tools.js.map +1 -1
  17. package/dist/server/app.js +1 -1
  18. package/dist/server/app.js.map +1 -1
  19. package/dist/server/index.d.ts +1 -1
  20. package/dist/server/index.js +2 -2
  21. package/dist/server/index.js.map +1 -1
  22. package/dist/server/rest/help.js +1 -1
  23. package/dist/server/rest/help.js.map +1 -1
  24. package/package.json +1 -1
  25. package/skills/uco-setup/references/recovery.md +26 -2
  26. package/vendor/plugin/com.atelierai.unity.copilot/CHANGELOG.md +24 -0
  27. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/CopilotServerManager.cs +31 -41
  28. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.Connection.cs +177 -409
  29. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.CreateGUI.cs +1 -8
  30. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.cs +0 -69
  31. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/UpdatePopupWindow.cs +30 -0
  32. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.Config.cs +6 -1
  33. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.Static.cs +9 -12
  34. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPlugin.Config.cs +11 -62
  35. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPlugin.cs +1 -1
  36. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Utils/EnvironmentUtils.cs +7 -25
  37. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Environment/EnvironmentUtilsTests.cs +260 -320
  38. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/UI/MainWindowEditor.StatusLogicTests.cs +0 -125
  39. package/vendor/plugin/com.atelierai.unity.copilot/package.json +1 -1
  40. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Services/DeviceAuthFlow.cs +0 -133
  41. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Services/DeviceAuthFlow.cs.meta +0 -11
  42. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/CopilotServerManagerAutoStartTests.cs +0 -43
  43. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/CopilotServerManagerAutoStartTests.cs.meta +0 -11
@@ -1,320 +1,260 @@
1
- /*
2
- ┌──────────────────────────────────────────────────────────────────┐
3
- │ Author: Ivan Murzak (https://github.com/IvanMurzak) │
4
- │ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │
5
- │ Copyright (c) 2025 Ivan Murzak │
6
- │ Licensed under the Apache License, Version 2.0. │
7
- │ See the LICENSE file in the project root for more information. │
8
- └──────────────────────────────────────────────────────────────────┘
9
- */
10
-
11
- #nullable enable
12
- using System;
13
- using System.Collections.Generic;
14
- using System.Text.Json;
15
- using System.Text.Json.Serialization;
16
- using com.AtelierAI.Unity.Copilot.Runtime.Utils;
17
- using NUnit.Framework;
18
- using static com.AtelierAI.Uco.Framework.Common.Consts.MCP.Server;
19
-
20
- namespace com.AtelierAI.Unity.Copilot.Editor.Tests
21
- {
22
- /// <summary>
23
- /// Covers the layered config-loader contract:
24
- /// flags > env vars > on-disk config > defaults
25
- /// implemented by <see cref="EnvironmentUtils.ApplyEnvironmentOverrides"/>.
26
- /// </summary>
27
- public class EnvironmentUtilsTests
28
- {
29
- const string DiskHost = "http://localhost:24029";
30
- const string DiskLocalToken = "DISK_LOCAL_TOKEN";
31
- const string DiskCloudToken = "DISK_CLOUD_TOKEN";
32
-
33
- static UnityCopilotPlugin.UnityConnectionConfig BuildDiskConfig(ConnectionMode mode = ConnectionMode.Cloud)
34
- {
35
- // Simulates a config that came back from disk: explicit values for both
36
- // local and cloud tokens, default mode = Cloud (matching the plugin's default).
37
- return new UnityCopilotPlugin.UnityConnectionConfig
38
- {
39
- LocalHost = DiskHost,
40
- LocalToken = DiskLocalToken,
41
- CloudToken = DiskCloudToken,
42
- ConnectionMode = mode,
43
- AuthOption = AuthOption.required,
44
- KeepConnected = true,
45
- KeepServerRunning = false,
46
- TransportMethod = TransportMethod.streamableHttp
47
- };
48
- }
49
-
50
- static IReadOnlyDictionary<string, string> NoArgs()
51
- => new Dictionary<string, string>();
52
- static Func<string, string?> NoEnv()
53
- => _ => null;
54
- static Func<string, string?> Env(params (string k, string v)[] pairs)
55
- {
56
- var dict = new Dictionary<string, string?>(StringComparer.Ordinal);
57
- foreach (var (k, v) in pairs) dict[k] = v;
58
- return key => dict.TryGetValue(key, out var val) ? val : null;
59
- }
60
- static IReadOnlyDictionary<string, string> Args(params (string k, string v)[] pairs)
61
- {
62
- var dict = new Dictionary<string, string>(StringComparer.Ordinal);
63
- foreach (var (k, v) in pairs) dict[k] = v;
64
- return dict;
65
- }
66
-
67
- // --- Disk-only path: env / args absent ---
68
-
69
- [Test]
70
- public void Override_DiskOnly_NoEnvNoArgs_LeavesConfigUnchanged()
71
- {
72
- var config = BuildDiskConfig();
73
- var record = EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), NoEnv());
74
-
75
- Assert.IsFalse(record.HasAny, "Expected no overrides when env and args are empty.");
76
- Assert.AreEqual(DiskHost, config.LocalHost);
77
- Assert.AreEqual(DiskCloudToken, config.CloudToken);
78
- Assert.AreEqual(DiskLocalToken, config.LocalToken);
79
- Assert.AreEqual(ConnectionMode.Cloud, config.ConnectionMode);
80
- Assert.AreEqual(AuthOption.required, config.AuthOption);
81
- }
82
-
83
- // --- Env var override ---
84
-
85
- [Test]
86
- public void Override_EnvToken_WritesToCloudTokenWhenModeIsCloud()
87
- {
88
- var config = BuildDiskConfig(mode: ConnectionMode.Cloud);
89
- var env = Env((EnvironmentUtils.EnvToken, "ENV_TOKEN"));
90
-
91
- var record = EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env);
92
-
93
- Assert.IsTrue(record.HasAny);
94
- Assert.IsTrue(record.Contains(EnvironmentUtils.FieldCloudToken),
95
- "Expected the CloudToken backing field to be tracked when mode is Cloud.");
96
- Assert.AreEqual("ENV_TOKEN", config.CloudToken);
97
- Assert.AreEqual(DiskLocalToken, config.LocalToken,
98
- "LocalToken must remain at the disk baseline when mode is Cloud.");
99
- }
100
-
101
- [Test]
102
- public void Override_EnvToken_WritesToLocalTokenWhenModeIsCustom()
103
- {
104
- var config = BuildDiskConfig(mode: ConnectionMode.Custom);
105
- var env = Env((EnvironmentUtils.EnvToken, "ENV_TOKEN"));
106
-
107
- EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env);
108
-
109
- Assert.AreEqual("ENV_TOKEN", config.LocalToken);
110
- Assert.AreEqual(DiskCloudToken, config.CloudToken);
111
- }
112
-
113
- // --- Flag override (highest priority, beats env) ---
114
-
115
- [Test]
116
- public void Override_FlagToken_BeatsEnvToken()
117
- {
118
- var config = BuildDiskConfig(mode: ConnectionMode.Cloud);
119
- var args = Args((EnvironmentUtils.FlagToken, "FLAG_TOKEN"));
120
- var env = Env((EnvironmentUtils.EnvToken, "ENV_TOKEN"));
121
-
122
- EnvironmentUtils.ApplyEnvironmentOverrides(config, args, env);
123
-
124
- Assert.AreEqual("FLAG_TOKEN", config.CloudToken,
125
- "When both --token and UNITY_MCP_TOKEN are set, the flag must win.");
126
- }
127
-
128
- [Test]
129
- public void Override_UnityMcpStyleFlag_BeatsEnvVar()
130
- {
131
- // CLI translates --token foo into UNITY_MCP_TOKEN env, but the plugin still
132
- // accepts --UNITY_MCP_TOKEN=foo style flags directly. They must beat env vars.
133
- var config = BuildDiskConfig(mode: ConnectionMode.Cloud);
134
- var args = Args((EnvironmentUtils.EnvToken, "FLAG_VIA_FULL_NAME"));
135
- var env = Env((EnvironmentUtils.EnvToken, "ENV_TOKEN"));
136
-
137
- EnvironmentUtils.ApplyEnvironmentOverrides(config, args, env);
138
-
139
- Assert.AreEqual("FLAG_VIA_FULL_NAME", config.CloudToken);
140
- }
141
-
142
- // --- Per-field layering ---
143
-
144
- [Test]
145
- public void Override_PerField_TokenFromEnv_HostFromDisk()
146
- {
147
- var config = BuildDiskConfig();
148
- var env = Env((EnvironmentUtils.EnvToken, "ENV_TOKEN"));
149
-
150
- EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env);
151
-
152
- Assert.AreEqual(DiskHost, config.LocalHost,
153
- "Host must remain at the disk baseline when only the token was overridden.");
154
- Assert.AreEqual("ENV_TOKEN", config.CloudToken);
155
- }
156
-
157
- // --- Trailing-slash robustness ---
158
-
159
- [Test]
160
- public void Override_TrailingSlashOnHostUrl_IsStripped()
161
- {
162
- var config = BuildDiskConfig();
163
- var env = Env((EnvironmentUtils.EnvCloudUrl, "http://localhost:5220/"));
164
-
165
- EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env);
166
-
167
- Assert.AreEqual("http://localhost:5220", config.LocalHost,
168
- "Trailing slash must be stripped defensively.");
169
- }
170
-
171
- [TestCase("http://localhost:5220/", true)]
172
- [TestCase("http://127.0.0.1:5220", true)]
173
- [TestCase("http://[::1]:5220/", true)]
174
- [TestCase("https://ai-game.dev", false)]
175
- [TestCase("not-a-url", false)]
176
- [TestCase("", false)]
177
- public void IsLoopbackUrl_RecognisesLoopbackHosts(string url, bool expected)
178
- {
179
- Assert.AreEqual(expected, EnvironmentUtils.IsLoopbackUrl(url),
180
- $"IsLoopbackUrl mismatch for '{url}'.");
181
- }
182
-
183
- [Test]
184
- public void Override_LoopbackHostUrl_InfersCustomMode()
185
- {
186
- // Worktree scenario: disk says Cloud, env supplies a localhost URL.
187
- // Expected: ConnectionMode is inferred to Custom so the worktree's local
188
- // dev token routes to LocalToken (not CloudToken) and the SignalR client
189
- // talks to <host>/hub/mcp-server (not <host>/mcp/hub/mcp-server).
190
- var config = BuildDiskConfig(mode: ConnectionMode.Cloud);
191
- var env = Env(
192
- (EnvironmentUtils.EnvCloudUrl, "http://localhost:5220/"),
193
- (EnvironmentUtils.EnvToken, "WORKTREE_TOKEN"));
194
-
195
- var record = EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env);
196
-
197
- Assert.AreEqual(ConnectionMode.Custom, config.ConnectionMode,
198
- "Loopback host URL should infer Custom mode.");
199
- Assert.AreEqual("http://localhost:5220", config.LocalHost);
200
- Assert.AreEqual("WORKTREE_TOKEN", config.LocalToken,
201
- "Token override must route to LocalToken once mode is inferred Custom.");
202
- Assert.AreEqual(DiskCloudToken, config.CloudToken,
203
- "CloudToken on disk must NOT be clobbered by the env override.");
204
- Assert.IsTrue(record.Contains(EnvironmentUtils.FieldConnectionMode));
205
- Assert.IsTrue(record.Contains(EnvironmentUtils.FieldHost));
206
- Assert.IsTrue(record.Contains(EnvironmentUtils.FieldLocalToken));
207
- }
208
-
209
- [Test]
210
- public void Override_RemoteHostUrl_DoesNotInferCustomMode()
211
- {
212
- var config = BuildDiskConfig(mode: ConnectionMode.Cloud);
213
- var env = Env((EnvironmentUtils.EnvCloudUrl, "https://ai-game.dev"));
214
-
215
- EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env);
216
-
217
- Assert.AreEqual(ConnectionMode.Cloud, config.ConnectionMode,
218
- "Non-loopback URL must NOT auto-flip the mode.");
219
- }
220
-
221
- [Test]
222
- public void Override_ExplicitConnectionMode_BeatsLoopbackInference()
223
- {
224
- var config = BuildDiskConfig(mode: ConnectionMode.Cloud);
225
- var env = Env(
226
- (EnvironmentUtils.EnvCloudUrl, "http://localhost:5220"),
227
- (EnvironmentUtils.EnvConnectionMode, "Cloud"));
228
-
229
- EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env);
230
-
231
- Assert.AreEqual(ConnectionMode.Cloud, config.ConnectionMode,
232
- "Explicit UNITY_MCP_CONNECTION_MODE must beat loopback inference.");
233
- }
234
-
235
- // --- Persistence: overrides MUST NOT be written to disk ---
236
-
237
- [Test]
238
- public void Persistence_BaselineRoundTripsToDiskWithoutOverrides()
239
- {
240
- var config = BuildDiskConfig(mode: ConnectionMode.Cloud);
241
- var env = Env(
242
- (EnvironmentUtils.EnvCloudUrl, "http://localhost:5220"),
243
- (EnvironmentUtils.EnvToken, "ENV_TOKEN"));
244
-
245
- var record = EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env);
246
- // sanity
247
- Assert.AreEqual(ConnectionMode.Custom, config.ConnectionMode);
248
- Assert.AreEqual("http://localhost:5220", config.LocalHost);
249
- Assert.AreEqual("ENV_TOKEN", config.LocalToken);
250
-
251
- // Simulate Save: restore baseline → serialize → restore overrides.
252
- EnvironmentUtils.ApplyBaseline(config, record);
253
-
254
- Assert.AreEqual(DiskHost, config.LocalHost, "Baseline restore failed for LocalHost.");
255
- Assert.AreEqual(DiskLocalToken, config.LocalToken, "Baseline restore failed for LocalToken.");
256
- Assert.AreEqual(ConnectionMode.Cloud, config.ConnectionMode, "Baseline restore failed for ConnectionMode.");
257
-
258
- var json = SerializeForDisk(config);
259
- // The serialized JSON must NOT contain the runtime override values.
260
- StringAssert.DoesNotContain("http://localhost:5220", json);
261
- StringAssert.DoesNotContain("ENV_TOKEN", json);
262
- StringAssert.Contains(DiskHost, json);
263
- StringAssert.Contains(DiskLocalToken, json);
264
-
265
- // After serialization, re-apply overrides.
266
- EnvironmentUtils.ApplyOverrides(config, record);
267
- Assert.AreEqual(ConnectionMode.Custom, config.ConnectionMode);
268
- Assert.AreEqual("http://localhost:5220", config.LocalHost);
269
- Assert.AreEqual("ENV_TOKEN", config.LocalToken);
270
- }
271
-
272
- [Test]
273
- public void Persistence_EmptyRecord_NoOpOnApplyBaselineAndOverrides()
274
- {
275
- var config = BuildDiskConfig();
276
- var record = new EnvironmentUtils.OverrideRecord();
277
- EnvironmentUtils.ApplyBaseline(config, record);
278
- EnvironmentUtils.ApplyOverrides(config, record);
279
- // No exceptions, values unchanged.
280
- Assert.AreEqual(DiskHost, config.LocalHost);
281
- }
282
-
283
- // --- Validation: bad inputs are tolerated ---
284
-
285
- [Test]
286
- public void Override_InvalidEnumValue_IsIgnored()
287
- {
288
- var config = BuildDiskConfig(mode: ConnectionMode.Cloud);
289
- var env = Env((EnvironmentUtils.EnvConnectionMode, "Bogus"));
290
-
291
- EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env);
292
-
293
- Assert.AreEqual(ConnectionMode.Cloud, config.ConnectionMode,
294
- "Unparseable enum values must be silently ignored.");
295
- }
296
-
297
- [Test]
298
- public void Override_QuotedValue_IsTrimmed()
299
- {
300
- var config = BuildDiskConfig();
301
- var env = Env((EnvironmentUtils.EnvToken, "\"QUOTED_TOKEN\""));
302
-
303
- EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env);
304
-
305
- Assert.AreEqual("QUOTED_TOKEN", config.CloudToken);
306
- }
307
-
308
- // --- Helpers ---
309
-
310
- static string SerializeForDisk(UnityCopilotPlugin.UnityConnectionConfig config)
311
- {
312
- return JsonSerializer.Serialize(config, new JsonSerializerOptions
313
- {
314
- WriteIndented = true,
315
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
316
- Converters = { new JsonStringEnumConverter() }
317
- });
318
- }
319
- }
320
- }
1
+ /*
2
+ ┌──────────────────────────────────────────────────────────────────┐
3
+ │ Author: Ivan Murzak (https://github.com/IvanMurzak) │
4
+ │ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │
5
+ │ Copyright (c) 2025 Ivan Murzak │
6
+ │ Licensed under the Apache License, Version 2.0. │
7
+ │ See the LICENSE file in the project root for more information. │
8
+ └──────────────────────────────────────────────────────────────────┘
9
+ */
10
+
11
+ #nullable enable
12
+ using System;
13
+ using System.Collections.Generic;
14
+ using System.Text.Json;
15
+ using System.Text.Json.Serialization;
16
+ using com.AtelierAI.Unity.Copilot.Runtime.Utils;
17
+ using NUnit.Framework;
18
+ using static com.AtelierAI.Uco.Framework.Common.Consts.MCP.Server;
19
+
20
+ namespace com.AtelierAI.Unity.Copilot.Editor.Tests
21
+ {
22
+ /// <summary>
23
+ /// Covers the layered config-loader contract:
24
+ /// flags > env vars > on-disk config > defaults
25
+ /// implemented by <see cref="EnvironmentUtils.ApplyEnvironmentOverrides"/>.
26
+ /// Since 1.0.3 removed the Cloud connection mode, every config is local
27
+ /// (ConnectionMode.Custom) and token overrides always route to LocalToken.
28
+ /// </summary>
29
+ public class EnvironmentUtilsTests
30
+ {
31
+ const string DiskHost = "http://localhost:24029";
32
+ const string DiskLocalToken = "DISK_LOCAL_TOKEN";
33
+
34
+ static UnityCopilotPlugin.UnityConnectionConfig BuildDiskConfig()
35
+ {
36
+ // Simulates a config that came back from disk: explicit values,
37
+ // default mode Custom (matching the plugin's default).
38
+ return new UnityCopilotPlugin.UnityConnectionConfig
39
+ {
40
+ LocalHost = DiskHost,
41
+ LocalToken = DiskLocalToken,
42
+ ConnectionMode = ConnectionMode.Custom,
43
+ AuthOption = AuthOption.required,
44
+ KeepConnected = true,
45
+ KeepServerRunning = false,
46
+ TransportMethod = TransportMethod.streamableHttp
47
+ };
48
+ }
49
+
50
+ static IReadOnlyDictionary<string, string> NoArgs()
51
+ => new Dictionary<string, string>();
52
+ static Func<string, string?> NoEnv()
53
+ => _ => null;
54
+ static Func<string, string?> Env(params (string k, string v)[] pairs)
55
+ {
56
+ var dict = new Dictionary<string, string?>(StringComparer.Ordinal);
57
+ foreach (var (k, v) in pairs) dict[k] = v;
58
+ return key => dict.TryGetValue(key, out var val) ? val : null;
59
+ }
60
+ static IReadOnlyDictionary<string, string> Args(params (string k, string v)[] pairs)
61
+ {
62
+ var dict = new Dictionary<string, string>(StringComparer.Ordinal);
63
+ foreach (var (k, v) in pairs) dict[k] = v;
64
+ return dict;
65
+ }
66
+
67
+ // --- Disk-only path: env / args absent ---
68
+
69
+ [Test]
70
+ public void Override_DiskOnly_NoEnvNoArgs_LeavesConfigUnchanged()
71
+ {
72
+ var config = BuildDiskConfig();
73
+ var record = EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), NoEnv());
74
+
75
+ Assert.IsFalse(record.HasAny, "Expected no overrides when env and args are empty.");
76
+ Assert.AreEqual(DiskHost, config.LocalHost);
77
+ Assert.AreEqual(DiskLocalToken, config.LocalToken);
78
+ Assert.AreEqual(ConnectionMode.Custom, config.ConnectionMode);
79
+ Assert.AreEqual(AuthOption.required, config.AuthOption);
80
+ }
81
+
82
+ // --- Env var override ---
83
+
84
+ [Test]
85
+ public void Override_EnvToken_WritesToLocalToken()
86
+ {
87
+ var config = BuildDiskConfig();
88
+ var env = Env((EnvironmentUtils.EnvToken, "ENV_TOKEN"));
89
+
90
+ var record = EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env);
91
+
92
+ Assert.IsTrue(record.HasAny);
93
+ Assert.IsTrue(record.Contains(EnvironmentUtils.FieldLocalToken),
94
+ "Expected the LocalToken backing field to be tracked.");
95
+ Assert.AreEqual("ENV_TOKEN", config.LocalToken);
96
+ }
97
+
98
+ // --- Flag override (highest priority, beats env) ---
99
+
100
+ [Test]
101
+ public void Override_FlagToken_BeatsEnvToken()
102
+ {
103
+ var config = BuildDiskConfig();
104
+ var args = Args((EnvironmentUtils.FlagToken, "FLAG_TOKEN"));
105
+ var env = Env((EnvironmentUtils.EnvToken, "ENV_TOKEN"));
106
+
107
+ EnvironmentUtils.ApplyEnvironmentOverrides(config, args, env);
108
+
109
+ Assert.AreEqual("FLAG_TOKEN", config.LocalToken,
110
+ "When both --token and UNITY_MCP_TOKEN are set, the flag must win.");
111
+ }
112
+
113
+ [Test]
114
+ public void Override_UnityMcpStyleFlag_BeatsEnvVar()
115
+ {
116
+ // CLI translates --token foo into UNITY_MCP_TOKEN env, but the plugin still
117
+ // accepts --UNITY_MCP_TOKEN=foo style flags directly. They must beat env vars.
118
+ var config = BuildDiskConfig();
119
+ var args = Args((EnvironmentUtils.EnvToken, "FLAG_VIA_FULL_NAME"));
120
+ var env = Env((EnvironmentUtils.EnvToken, "ENV_TOKEN"));
121
+
122
+ EnvironmentUtils.ApplyEnvironmentOverrides(config, args, env);
123
+
124
+ Assert.AreEqual("FLAG_VIA_FULL_NAME", config.LocalToken);
125
+ }
126
+
127
+ // --- Per-field layering ---
128
+
129
+ [Test]
130
+ public void Override_PerField_TokenFromEnv_HostFromDisk()
131
+ {
132
+ var config = BuildDiskConfig();
133
+ var env = Env((EnvironmentUtils.EnvToken, "ENV_TOKEN"));
134
+
135
+ EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env);
136
+
137
+ Assert.AreEqual(DiskHost, config.LocalHost,
138
+ "Host must remain at the disk baseline when only the token was overridden.");
139
+ Assert.AreEqual("ENV_TOKEN", config.LocalToken);
140
+ }
141
+
142
+ // --- Trailing-slash robustness ---
143
+
144
+ [Test]
145
+ public void Override_TrailingSlashOnHostUrl_IsStripped()
146
+ {
147
+ var config = BuildDiskConfig();
148
+ var env = Env((EnvironmentUtils.EnvHost, "http://localhost:5220/"));
149
+
150
+ EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env);
151
+
152
+ Assert.AreEqual("http://localhost:5220", config.LocalHost,
153
+ "Trailing slash must be stripped defensively.");
154
+ }
155
+
156
+ [TestCase("http://localhost:5220/", true)]
157
+ [TestCase("http://127.0.0.1:5220", true)]
158
+ [TestCase("http://[::1]:5220/", true)]
159
+ [TestCase("https://example.com", false)]
160
+ [TestCase("not-a-url", false)]
161
+ [TestCase("", false)]
162
+ public void IsLoopbackUrl_RecognisesLoopbackHosts(string url, bool expected)
163
+ {
164
+ Assert.AreEqual(expected, EnvironmentUtils.IsLoopbackUrl(url),
165
+ $"IsLoopbackUrl mismatch for '{url}'.");
166
+ }
167
+
168
+ [Test]
169
+ public void Override_RemoteHostUrl_NeverFlipsMode()
170
+ {
171
+ var config = BuildDiskConfig();
172
+ var env = Env((EnvironmentUtils.EnvHost, "https://example.com"));
173
+
174
+ EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env);
175
+
176
+ Assert.AreEqual(ConnectionMode.Custom, config.ConnectionMode);
177
+ Assert.AreEqual("https://example.com", config.LocalHost);
178
+ }
179
+
180
+ [Test]
181
+ public void Override_InvalidEnumValue_IsIgnored()
182
+ {
183
+ var config = BuildDiskConfig();
184
+ var env = Env((EnvironmentUtils.EnvConnectionMode, "Bogus"));
185
+
186
+ EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env);
187
+
188
+ Assert.AreEqual(ConnectionMode.Custom, config.ConnectionMode,
189
+ "Unparseable enum values must be silently ignored (legacy Cloud included).");
190
+ }
191
+
192
+ [Test]
193
+ public void Override_QuotedValue_IsTrimmed()
194
+ {
195
+ var config = BuildDiskConfig();
196
+ var env = Env((EnvironmentUtils.EnvToken, "\"QUOTED_TOKEN\""));
197
+
198
+ EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env);
199
+
200
+ Assert.AreEqual("QUOTED_TOKEN", config.LocalToken);
201
+ }
202
+
203
+ // --- Persistence: overrides MUST NOT be written to disk ---
204
+
205
+ [Test]
206
+ public void Persistence_BaselineRoundTripsToDiskWithoutOverrides()
207
+ {
208
+ var config = BuildDiskConfig();
209
+ var env = Env(
210
+ (EnvironmentUtils.EnvHost, "http://localhost:5220"),
211
+ (EnvironmentUtils.EnvToken, "ENV_TOKEN"));
212
+
213
+ var record = EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env);
214
+ // sanity
215
+ Assert.AreEqual("http://localhost:5220", config.LocalHost);
216
+ Assert.AreEqual("ENV_TOKEN", config.LocalToken);
217
+
218
+ // Simulate Save: restore baseline serialize → restore overrides.
219
+ EnvironmentUtils.ApplyBaseline(config, record);
220
+
221
+ Assert.AreEqual(DiskHost, config.LocalHost, "Baseline restore failed for LocalHost.");
222
+ Assert.AreEqual(DiskLocalToken, config.LocalToken, "Baseline restore failed for LocalToken.");
223
+
224
+ var json = SerializeForDisk(config);
225
+ // The serialized JSON must NOT contain the runtime override values.
226
+ StringAssert.DoesNotContain("http://localhost:5220", json);
227
+ StringAssert.DoesNotContain("ENV_TOKEN", json);
228
+ StringAssert.Contains(DiskHost, json);
229
+ StringAssert.Contains(DiskLocalToken, json);
230
+
231
+ // After serialization, re-apply overrides.
232
+ EnvironmentUtils.ApplyOverrides(config, record);
233
+ Assert.AreEqual("http://localhost:5220", config.LocalHost);
234
+ Assert.AreEqual("ENV_TOKEN", config.LocalToken);
235
+ }
236
+
237
+ [Test]
238
+ public void Persistence_EmptyRecord_NoOpOnApplyBaselineAndOverrides()
239
+ {
240
+ var config = BuildDiskConfig();
241
+ var record = new EnvironmentUtils.OverrideRecord();
242
+ EnvironmentUtils.ApplyBaseline(config, record);
243
+ EnvironmentUtils.ApplyOverrides(config, record);
244
+ // No exceptions, values unchanged.
245
+ Assert.AreEqual(DiskHost, config.LocalHost);
246
+ }
247
+
248
+ // --- Helpers ---
249
+
250
+ static string SerializeForDisk(UnityCopilotPlugin.UnityConnectionConfig config)
251
+ {
252
+ return JsonSerializer.Serialize(config, new JsonSerializerOptions
253
+ {
254
+ WriteIndented = true,
255
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
256
+ Converters = { new JsonStringEnumConverter() }
257
+ });
258
+ }
259
+ }
260
+ }