diff --git a/agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs b/agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs
index 523e4887d..1a8ff6257 100644
--- a/agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs
+++ b/agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs
@@ -433,32 +433,22 @@ public void StartAgent(IAgentApplicationConfiguration configuration, bool verbos
var freshlyConstructed = (existingAgentInformation == null);
var agentInformation = existingAgentInformation ?? new MTConnectAgentInformation();
- // Apply explicit AgentUuid config override (if set). This
- // pins the Agent meta-device UUID across restarts without
- // requiring agent.information.json to be present on disk,
- // and takes precedence over any UUID previously stored in
- // that file. See MTConnect v2.7 XSD UuidType ("for its
- // entire life") vs Header.instanceId (per-boot).
- if (!string.IsNullOrEmpty(configuration.AgentUuid))
- {
- agentInformation.Uuid = configuration.AgentUuid;
- }
-
- // When no operator config override and no persisted state file,
- // derive a deterministic UUID v5 (RFC 4122 §4.3, DNS namespace,
- // SHA-1) from ServiceName:port so the Agent meta-device satisfies
- // UuidType's "for it's entire life" annotation across every restart
- // in the ephemeral-container deployment path. Mirrors cppagent's
- // name_generator prior art. Port is 0 (sentinel) because
- // IAgentApplicationConfiguration does not surface a listener-port
- // property; the seed is still unique per ServiceName.
- if (freshlyConstructed && string.IsNullOrEmpty(configuration.AgentUuid))
- {
- agentInformation.Uuid = DeterministicAgentUuid.Derive(
- configuration.ServiceName,
- System.Environment.MachineName,
- port: 0);
- }
+ // Resolve the Agent meta-device UUID via the shared three-path
+ // algorithm in AgentUuidResolver so this application and the
+ // test fixtures exercise the same code and cannot silently
+ // drift. Path 1 (validated operator override) wins, else Path 2
+ // (validated persisted state), else Path 3 (deterministic
+ // UUID v5 derivation from ServiceName). Malformed input on
+ // either the override or the persisted path logs a warning
+ // and falls through — silently forwarding non-UUID content
+ // would break MTConnect Part 1's wire-XSD validation on every
+ // typed enum/decimal DataItem.
+ agentInformation.Uuid = AgentUuidResolver.Resolve(
+ operatorSuppliedUuid: configuration.AgentUuid,
+ persistedUuid: freshlyConstructed ? null : agentInformation.Uuid,
+ agentName: configuration.ServiceName,
+ hostname: System.Environment.MachineName,
+ warn: message => _applicationLogger?.Warn(message));
// Create Observation File Buffer
if (configuration.Durable)
diff --git a/libraries/MTConnect.NET-Common/Agents/AgentUuidResolver.cs b/libraries/MTConnect.NET-Common/Agents/AgentUuidResolver.cs
new file mode 100644
index 000000000..2bb865452
--- /dev/null
+++ b/libraries/MTConnect.NET-Common/Agents/AgentUuidResolver.cs
@@ -0,0 +1,161 @@
+// Copyright (c) 2026 TrakHound Inc., All Rights Reserved.
+// TrakHound Inc. licenses this file to you under the MIT license.
+
+using System;
+
+namespace MTConnect.Agents
+{
+ ///
+ /// Resolves the Agent meta-device UUID from the three canonical sources —
+ /// operator-supplied config override, persisted agent.information.json
+ /// state, and a deterministic UUID v5 derivation — with RFC 4122 validation
+ /// applied uniformly on both the override and the persisted paths.
+ ///
+ ///
+ /// Shared between MTConnectAgentApplication.StartAgent and the test
+ /// fixtures so the boot-time resolution is exercised by the same code in
+ /// both places; the tests cannot silently drift from production semantics.
+ ///
+ ///
+ ///
+ /// Resolution order:
+ ///
+ ///
+ /// -
+ /// Path 1 — operator-supplied override. If
+ /// parses as an RFC 4122 UUID
+ /// (via ), the canonical
+ /// hyphenated form wins. Malformed input logs a warning via
+ /// and falls through to Path 2 / Path 3.
+ ///
+ /// -
+ /// Path 2 — persisted state. If
+ /// parses as an RFC 4122 UUID, the
+ /// canonical form wins. Malformed persisted state (e.g. a pre-hardening
+ /// agent version wrote a non-UUID string, or the file was hand-edited)
+ /// logs a warning and falls through to Path 3 so a spec-conformant UUID
+ /// always reaches the wire.
+ ///
+ /// -
+ /// Path 3 — deterministic derivation.
+ /// over
+ /// (agentName ?? hostname, hostname, port: 0). Port is 0
+ /// because IAgentApplicationConfiguration does not surface a
+ /// listener-port property; the seed is still unique per agent name.
+ ///
+ ///
+ ///
+ ///
+ /// Spec rationale — MTConnect Part 1 types the uuid attribute as the
+ /// UUID DataType (RFC 4122). Silently forwarding a non-UUID string
+ /// (from any source) breaks XSD validation on every typed enum/decimal
+ /// DataItem and diverges from the cppagent reference implementation, which
+ /// rejects malformed input at ingress.
+ ///
+ ///
+ public static class AgentUuidResolver
+ {
+ ///
+ /// Maximum length of an AgentUuid value echoed to the warning
+ /// log. Valid UUIDs are 32–38 characters; anything longer is almost
+ /// certainly a paste-in-wrong-field mistake (e.g. an API key) whose
+ /// full value should not persist in log archives.
+ ///
+ private const int LogValueMaxLength = 64;
+
+ ///
+ /// Resolves the Agent meta-device UUID per the three-path algorithm.
+ ///
+ ///
+ /// Raw value from AgentApplicationConfiguration.AgentUuid; may
+ /// be , empty, or malformed.
+ ///
+ ///
+ /// Raw value from MTConnectAgentInformation.Read().Uuid, or
+ /// when no agent.information.json exists
+ /// (freshly constructed lifecycle). May itself be malformed if a prior
+ /// agent boot wrote non-UUID content.
+ ///
+ ///
+ /// The logical agent name (typically configuration.ServiceName).
+ /// Passed verbatim to ,
+ /// which falls back to when this is
+ /// or empty.
+ ///
+ ///
+ /// The machine host name (typically
+ /// ). Used by
+ /// as both the fallback
+ /// seed component and the deterministic derivation input.
+ ///
+ ///
+ /// Optional delegate invoked with a human-readable message when Path 1
+ /// or Path 2 rejects malformed input. Kept as a plain
+ /// so MTConnect.NET-Common does not
+ /// take a hard dependency on any logging framework; the caller adapts
+ /// it to NLog, Serilog, or Microsoft.Extensions.Logging. Raw
+ /// values echoed via this delegate are pre-sanitised (CRLF stripped,
+ /// truncated to characters) to guard
+ /// against log-injection and secret leakage.
+ ///
+ ///
+ /// The canonical hyphenated RFC 4122 UUID string that the agent must
+ /// adopt for its meta-device.
+ ///
+ public static string Resolve(
+ string operatorSuppliedUuid,
+ string persistedUuid,
+ string agentName,
+ string hostname,
+ Action warn = null)
+ {
+ // Path 1 — validated operator override wins.
+ if (DeterministicAgentUuid.TryValidate(operatorSuppliedUuid, out var normalizedOverride))
+ {
+ return normalizedOverride;
+ }
+
+ // Path 1 rejected but operator supplied something → warn.
+ if (!string.IsNullOrEmpty(operatorSuppliedUuid))
+ {
+ var fallbackKind = DeterministicAgentUuid.TryValidate(persistedUuid, out _)
+ ? "persisted"
+ : "derived";
+ warn?.Invoke(string.Format(
+ "AgentUuid override '{0}' is not a valid RFC 4122 UUID; falling back to {1} UUID.",
+ SanitiseForLog(operatorSuppliedUuid),
+ fallbackKind));
+ }
+
+ // Path 2 — validated persisted state wins over derivation.
+ if (DeterministicAgentUuid.TryValidate(persistedUuid, out var normalizedPersisted))
+ {
+ return normalizedPersisted;
+ }
+
+ // Path 2 rejected but persisted state carried something → warn.
+ if (!string.IsNullOrEmpty(persistedUuid))
+ {
+ warn?.Invoke(
+ "Persisted AgentUuid in agent.information.json is not a valid RFC 4122 UUID; falling back to derived UUID.");
+ }
+
+ // Path 3 — deterministic derivation.
+ return DeterministicAgentUuid.Derive(agentName, hostname, port: 0);
+ }
+
+ ///
+ /// Strips CR/LF (log-injection guard) and truncates to
+ /// characters (secret-leakage guard)
+ /// before echoing an operator-supplied value to the warning log.
+ ///
+ private static string SanitiseForLog(string value)
+ {
+ if (string.IsNullOrEmpty(value)) return value;
+ var stripped = value.Replace("\r", string.Empty).Replace("\n", string.Empty);
+ return stripped.Length > LogValueMaxLength
+ ? stripped.Substring(0, LogValueMaxLength) + "…"
+ : stripped;
+ }
+ }
+}
diff --git a/libraries/MTConnect.NET-Common/Agents/DeterministicAgentUuid.cs b/libraries/MTConnect.NET-Common/Agents/DeterministicAgentUuid.cs
index 518fd8c44..a2cac9c9b 100644
--- a/libraries/MTConnect.NET-Common/Agents/DeterministicAgentUuid.cs
+++ b/libraries/MTConnect.NET-Common/Agents/DeterministicAgentUuid.cs
@@ -129,5 +129,49 @@ private static byte[] BigEndianToGuidBytes(byte[] beBytes)
Buffer.BlockCopy(beBytes, 8, result, 8, 8);
return result;
}
+
+ ///
+ /// Validates and normalizes an operator-supplied Agent UUID string.
+ ///
+ ///
+ /// Accepts any format that
+ /// recognizes — hyphenated "D" (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx),
+ /// braced "B", parenthesized "P", bare-hex "N", or hex-braced "X" — and
+ /// returns the canonical hyphenated "D" form so the wire representation
+ /// is stable regardless of input format. Rejects ,
+ /// empty, whitespace-only, and unparseable inputs.
+ ///
+ /// Motivation: MTConnect Part 1 types the uuid attribute as the
+ /// UUID DataType (RFC 4122). Silently forwarding a non-UUID
+ /// string emits wire content that fails XSD validation on any typed
+ /// enum/decimal DataItem and breaks parity with the cppagent reference
+ /// implementation. Callers that supply malformed input should log a
+ /// warning and fall through to persisted or derived UUIDs — the
+ /// three-path resolution in .
+ ///
+ ///
+ ///
+ /// The raw operator-supplied value from
+ /// AgentApplicationConfiguration.AgentUuid; may be
+ /// or empty.
+ ///
+ ///
+ /// On success, the canonical hyphenated "D" form of the parsed UUID
+ /// (e.g. cfbff0d1-9375-5685-968a-48ce8b50a653);
+ /// on failure.
+ ///
+ ///
+ /// if parses as an
+ /// RFC 4122 UUID; for ,
+ /// empty, whitespace-only, or unparseable inputs.
+ ///
+ public static bool TryValidate(string input, out string normalized)
+ {
+ normalized = null;
+ if (string.IsNullOrWhiteSpace(input)) return false;
+ if (!Guid.TryParse(input, out var parsed)) return false;
+ normalized = parsed.ToString();
+ return true;
+ }
}
}
diff --git a/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidConfigOverrideTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidConfigOverrideTests.cs
index f46444b56..b8951378b 100644
--- a/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidConfigOverrideTests.cs
+++ b/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidConfigOverrideTests.cs
@@ -27,6 +27,7 @@ namespace MTConnect.Tests.Common.Agents
/// Mirrors cppagent's AgentDeviceUUID configuration knob.
///
[TestFixture]
+ [NonParallelizable]
public class AgentUuidConfigOverrideTests
{
private string? _stateFilePath;
@@ -37,6 +38,18 @@ public class AgentUuidConfigOverrideTests
public void SetUp()
{
_stateFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, MTConnectAgentInformation.Filename);
+ _backupStateFile = null;
+
+ // Sweep orphan .bak.* files from a prior crashed test run so
+ // successive TearDowns cannot restore stale state.
+ var directory = Path.GetDirectoryName(_stateFilePath);
+ if (directory != null && Directory.Exists(directory))
+ {
+ foreach (var stale in Directory.EnumerateFiles(directory, MTConnectAgentInformation.Filename + ".bak.*"))
+ {
+ try { File.Delete(stale); } catch { /* best-effort */ }
+ }
+ }
// Back up any pre-existing state file so we do not perturb other
// tests or the developer environment.
@@ -65,38 +78,30 @@ public void TearDown()
///
/// Test (a) — pre-condition: no agent.information.json on disk.
- /// Setting configuration.AgentUuid pins the agent UUID to that
- /// exact value (overriding the Guid.NewGuid() in
+ /// Setting configuration.AgentUuid to a valid RFC 4122 UUID
+ /// pins the agent UUID to that exact value (overriding the
+ /// Guid.NewGuid() in
/// 's parameterless ctor).
///
[Test]
public void AgentUuid_set_in_config_flows_through_to_Agent_uuid()
{
- const string PinnedUuid = "fixture-stable-uuid-001";
+ const string PinnedUuid = "11111111-1111-4111-8111-111111111111";
var configuration = new AgentApplicationConfiguration
{
AgentUuid = PinnedUuid,
};
- // Mirror the exact StartAgent threading slice under test:
- // var agentInformation = MTConnectAgentInformation.Read();
- // if (agentInformation == null) agentInformation = new MTConnectAgentInformation();
- // if (!string.IsNullOrEmpty(configuration.AgentUuid))
- // agentInformation.Uuid = configuration.AgentUuid;
- var agentInformation = MTConnectAgentInformation.Read();
- if (agentInformation == null)
- {
- agentInformation = new MTConnectAgentInformation();
- }
- if (!string.IsNullOrEmpty(configuration.AgentUuid))
- {
- agentInformation.Uuid = configuration.AgentUuid;
- }
+ // Route through the production resolver so the test exercises the
+ // same code path StartAgent uses (see
+ // agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs
+ // RunAgent — resolves via AgentUuidResolver.Resolve).
+ var agentInformation = ResolveViaProduction(configuration);
agentInformation.Save();
// The override must hold both in-memory and after the file
- // round-trip that StartAgent performs at line 393.
+ // round-trip that StartAgent performs.
Assert.That(agentInformation.Uuid, Is.EqualTo(PinnedUuid));
var reloaded = MTConnectAgentInformation.Read();
@@ -106,15 +111,16 @@ public void AgentUuid_set_in_config_flows_through_to_Agent_uuid()
///
/// Test (b) — pre-condition: agent.information.json already
- /// stores a different UUID. The config-level AgentUuid wins.
+ /// stores a different (valid) UUID. The config-level AgentUuid
+ /// wins.
///
[Test]
public void AgentUuid_set_in_config_takes_precedence_over_state_file()
{
- const string FromStateFileUuid = "from-state-file-uuid";
- const string FromConfigUuid = "from-config-uuid";
+ const string FromStateFileUuid = "22222222-2222-4222-8222-222222222222";
+ const string FromConfigUuid = "33333333-3333-4333-8333-333333333333";
- // Pre-write the state file with a stale UUID.
+ // Pre-write the state file with a stale (but valid) UUID.
var preexisting = new MTConnectAgentInformation(FromStateFileUuid);
preexisting.Save();
@@ -123,15 +129,12 @@ public void AgentUuid_set_in_config_takes_precedence_over_state_file()
AgentUuid = FromConfigUuid,
};
- var agentInformation = MTConnectAgentInformation.Read();
- Assert.That(agentInformation, Is.Not.Null);
- Assert.That(agentInformation!.Uuid, Is.EqualTo(FromStateFileUuid),
+ var initial = MTConnectAgentInformation.Read();
+ Assert.That(initial, Is.Not.Null);
+ Assert.That(initial!.Uuid, Is.EqualTo(FromStateFileUuid),
"Pre-condition: the state file should be read first.");
- if (!string.IsNullOrEmpty(configuration.AgentUuid))
- {
- agentInformation.Uuid = configuration.AgentUuid;
- }
+ var agentInformation = ResolveViaProduction(configuration);
agentInformation.Save();
Assert.That(agentInformation.Uuid, Is.EqualTo(FromConfigUuid));
@@ -157,12 +160,14 @@ public void AgentUuid_set_in_config_takes_precedence_over_state_file()
[Test]
public void AgentUuid_is_exposed_on_interface_with_camelCase_wire_name()
{
+ const string InterfaceProbe = "44444444-4444-4444-8444-444444444444";
+
IAgentApplicationConfiguration configuration = new AgentApplicationConfiguration
{
- AgentUuid = "interface-surface-test",
+ AgentUuid = InterfaceProbe,
};
- Assert.That(configuration.AgentUuid, Is.EqualTo("interface-surface-test"));
+ Assert.That(configuration.AgentUuid, Is.EqualTo(InterfaceProbe));
var property = typeof(AgentApplicationConfiguration).GetProperty(
nameof(AgentApplicationConfiguration.AgentUuid),
@@ -178,5 +183,27 @@ public void AgentUuid_is_exposed_on_interface_with_camelCase_wire_name()
"AgentUuid must carry [JsonPropertyName(...)] to match the other config fields.");
Assert.That(jsonNameAttribute!.Name, Is.EqualTo("agentUuid"));
}
+
+ ///
+ /// Replays the RunAgent UUID resolution slice via the shared
+ /// production helper so this fixture cannot silently drift from
+ /// StartAgent semantics. Returns the populated
+ /// ready for
+ /// .
+ ///
+ private static MTConnectAgentInformation ResolveViaProduction(AgentApplicationConfiguration configuration)
+ {
+ var existing = MTConnectAgentInformation.Read();
+ var freshlyConstructed = existing == null;
+ var info = existing ?? new MTConnectAgentInformation();
+
+ info.Uuid = AgentUuidResolver.Resolve(
+ operatorSuppliedUuid: configuration.AgentUuid,
+ persistedUuid: freshlyConstructed ? null : info.Uuid,
+ agentName: configuration.ServiceName,
+ hostname: Environment.MachineName);
+
+ return info;
+ }
}
}
diff --git a/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidLongitudinalInvariantsTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidLongitudinalInvariantsTests.cs
index dfc745fdf..21369e56d 100644
--- a/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidLongitudinalInvariantsTests.cs
+++ b/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidLongitudinalInvariantsTests.cs
@@ -27,16 +27,28 @@ namespace MTConnect.Tests.Common.Agents
/// this file pins the longitudinal invariant.
///
[TestFixture]
+ [NonParallelizable]
public class AgentUuidLongitudinalInvariantsTests
{
private string? _stateFilePath;
private string? _backupStateFile;
- /// Sets up the fixture before each test.
+ /// Sets up the fixture before all tests in the class.
[OneTimeSetUp]
public void OneTimeSetUp()
{
_stateFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, MTConnectAgentInformation.Filename);
+ _backupStateFile = null;
+
+ // Sweep orphan .longinv.bak.* files from a prior crashed test run.
+ var directory = Path.GetDirectoryName(_stateFilePath);
+ if (directory != null && Directory.Exists(directory))
+ {
+ foreach (var stale in Directory.EnumerateFiles(directory, MTConnectAgentInformation.Filename + ".longinv.bak.*"))
+ {
+ try { File.Delete(stale); } catch { /* best-effort */ }
+ }
+ }
// Back up any pre-existing state file so we do not perturb other
// tests or the developer environment.
@@ -81,33 +93,30 @@ public void OneTimeTearDown()
/// call to MTConnectAgentApplication.StartAgent, followed by the
/// broker's post-device-add persist.
///
- /// Production sources replayed (verify against live code if either drifts):
- ///
- /// -
- /// agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs
- /// lines 351–404 — Read, AgentUuid override, InstanceId zeroing, Save.
- ///
- /// -
- /// libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs
- /// lines 207–234 — broker ctor: _instanceId = instanceId > 0 ? instanceId : CreateInstanceId()
- /// where CreateInstanceId() returns (ulong)(UnixDateTime.Now / 1000 / 10000)
- /// (line 2351–2353), and lines 2321–2345 — UpdateAgentInformation timer-driven
- /// persist after device-add.
- ///
- ///
+ /// The UUID resolution slice routes through
+ /// — the same call production
+ /// makes — so this fixture cannot silently drift from StartAgent
+ /// semantics (branch order, guard shape, validation).
+ ///
+ /// InstanceId handling remains inline because it has no shared
+ /// helper: it depends on configuration.Durable +
+ /// durableBufferLoadSucceeds and mirrors the broker ctor's
+ /// _instanceId = instanceId > 0 ? instanceId : CreateInstanceId()
+ /// contract (libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs).
///
private static (string uuid, ulong instanceId) SimulateBoot(
AgentApplicationConfiguration configuration,
bool durableBufferLoadSucceeds)
{
- // Mirrors MTConnectAgentApplication.StartAgent lines 351–404:
- var info = MTConnectAgentInformation.Read();
- if (info == null) info = new MTConnectAgentInformation();
+ var existing = MTConnectAgentInformation.Read();
+ var freshlyConstructed = existing == null;
+ var info = existing ?? new MTConnectAgentInformation();
- if (!string.IsNullOrEmpty(configuration.AgentUuid))
- {
- info.Uuid = configuration.AgentUuid;
- }
+ info.Uuid = AgentUuidResolver.Resolve(
+ operatorSuppliedUuid: configuration.AgentUuid,
+ persistedUuid: freshlyConstructed ? null : info.Uuid,
+ agentName: configuration.ServiceName,
+ hostname: Environment.MachineName);
var initializeDataItems = !durableBufferLoadSucceeds;
if (!configuration.Durable || initializeDataItems)
@@ -117,17 +126,16 @@ private static (string uuid, ulong instanceId) SimulateBoot(
info.Save();
- // Mirrors MTConnectAgent ctor (MTConnectAgent.cs lines 207–234):
+ // Mirrors MTConnectAgent ctor:
// _instanceId = instanceId > 0 ? instanceId : CreateInstanceId();
- // CreateInstanceId() returns (ulong)(UnixDateTime.Now / 1000 / 10000) — Unix epoch seconds.
+ // CreateInstanceId() = (ulong)(UnixDateTime.Now / 1000 / 10000) — Unix epoch seconds.
var brokerInstanceId = info.InstanceId > 0
? info.InstanceId
: (ulong)(UnixDateTime.Now / 1000 / 10000);
- // Mirrors MTConnectAgent.UpdateAgentInformation (MTConnectAgent.cs lines 2321–2345):
- // The broker writes its chosen _instanceId back to the file via a timer-driven
- // persist once a device is added. Simulate that here so the next boot's Read()
- // sees the broker's resolved InstanceId, not the zeroed value from the Save() above.
+ // Broker writes _instanceId back via UpdateAgentInformation once a
+ // device is added; simulate that so the next boot's Read() sees
+ // the broker's resolved InstanceId, not the zeroed value.
info.InstanceId = brokerInstanceId;
info.Save();
@@ -143,9 +151,11 @@ private static (string uuid, ulong instanceId) SimulateBoot(
[Test]
public void Uuid_pinned_via_config_survives_two_non_durable_boots()
{
+ const string PinnedUuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
+
var configuration = new AgentApplicationConfiguration
{
- AgentUuid = "fixture-stable-uuid-A",
+ AgentUuid = PinnedUuid,
Durable = false,
};
@@ -156,9 +166,9 @@ public void Uuid_pinned_via_config_survives_two_non_durable_boots()
var (uuid2, instanceId2) = SimulateBoot(configuration, durableBufferLoadSucceeds: false);
- Assert.That(uuid1, Is.EqualTo("fixture-stable-uuid-A"),
+ Assert.That(uuid1, Is.EqualTo(PinnedUuid),
"Boot 1: config-level AgentUuid must be applied.");
- Assert.That(uuid2, Is.EqualTo("fixture-stable-uuid-A"),
+ Assert.That(uuid2, Is.EqualTo(PinnedUuid),
"Boot 2: config-level AgentUuid must survive a non-durable restart.");
Assert.That(instanceId1, Is.Not.EqualTo(instanceId2),
"Non-durable buffer means the InstanceId resets each boot — the UUIDs are equal but InstanceIds differ.");
@@ -178,9 +188,11 @@ public void Uuid_pinned_via_config_survives_two_non_durable_boots()
[Test]
public void Uuid_pinned_via_config_survives_durable_boot_with_buffer_load_success()
{
+ const string PinnedUuid = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
+
var configuration = new AgentApplicationConfiguration
{
- AgentUuid = "fixture-stable-uuid-B",
+ AgentUuid = PinnedUuid,
Durable = true,
};
@@ -192,28 +204,26 @@ public void Uuid_pinned_via_config_survives_durable_boot_with_buffer_load_succes
// Boot 2: warm restart — durable buffer loaded successfully.
var (uuid2, instanceId2) = SimulateBoot(configuration, durableBufferLoadSucceeds: true);
- Assert.That(uuid1, Is.EqualTo("fixture-stable-uuid-B"),
+ Assert.That(uuid1, Is.EqualTo(PinnedUuid),
"Boot 1: config-level AgentUuid must be applied.");
- Assert.That(uuid2, Is.EqualTo("fixture-stable-uuid-B"),
+ Assert.That(uuid2, Is.EqualTo(PinnedUuid),
"Boot 2: config-level AgentUuid must survive a durable restart.");
Assert.That(instanceId1, Is.EqualTo(instanceId2),
"Durable buffer load success means InstanceId is preserved across boots (spec requirement).");
}
///
- /// Documents the pre-fix bug from the consumer's perspective:
- /// when AgentApplicationConfiguration.AgentUuid is null
- /// and no state file persists across boots (e.g., an ephemeral container),
- /// both UUID and InstanceId regenerate on every boot.
- ///
- /// This is not a regression check on the new feature — it is a
- /// regression check on the bug itself still being a bug when the knob
- /// is absent. Deleting the state file between boots simulates the
- /// "no persistent storage" scenario that the new config knob lets
- /// consumers escape.
+ /// Post-fix longitudinal invariant: when
+ /// AgentApplicationConfiguration.AgentUuid is
+ /// and no state file persists across boots (e.g. an ephemeral container),
+ /// the meta-device UUID is nevertheless stable across boots because
+ /// Path 3 derives it
+ /// deterministically from (agentName ?? hostname, hostname, port: 0).
+ /// The InstanceId still resets each boot because the durable
+ /// buffer did not load (spec-correct behaviour for Header.instanceId).
///
[Test]
- public void Uuid_not_pinned_and_no_state_file_regenerates_per_boot()
+ public void Uuid_not_pinned_and_no_state_file_is_stable_via_deterministic_derivation()
{
var configuration = new AgentApplicationConfiguration
{
@@ -234,11 +244,14 @@ public void Uuid_not_pinned_and_no_state_file_regenerates_per_boot()
var (uuid2, instanceId2) = SimulateBoot(configuration, durableBufferLoadSucceeds: false);
- Assert.That(uuid1, Is.Not.EqualTo(uuid2),
- "No AgentUuid override + no state file = a fresh Guid is generated every boot. " +
- "This is the pre-fix problem the new knob lets consumers avoid.");
+ Assert.That(uuid1, Is.EqualTo(uuid2),
+ "No override + no state file, but Path 3 derives deterministically " +
+ "from (agentName ?? hostname, hostname, port) — the meta-device UUID is stable " +
+ "across boots. This is the whole point of the AgentUuidResolver + " +
+ "DeterministicAgentUuid.Derive stack introduced by #168.");
Assert.That(instanceId1, Is.Not.EqualTo(instanceId2),
- "No state file = InstanceId is also regenerated each boot.");
+ "No state file = InstanceId is still regenerated each boot " +
+ "(Header.instanceId is per-boot by spec; separate concern from UUID stability).");
}
}
}
diff --git a/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidValidationTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidValidationTests.cs
new file mode 100644
index 000000000..c404831df
--- /dev/null
+++ b/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidValidationTests.cs
@@ -0,0 +1,379 @@
+// Copyright (c) 2026 TrakHound Inc., All Rights Reserved.
+// TrakHound Inc. licenses this file to you under the MIT license.
+
+using System;
+using System.IO;
+using MTConnect.Agents;
+using MTConnect.Configurations;
+using NUnit.Framework;
+
+namespace MTConnect.Tests.Common.Agents
+{
+ ///
+ /// Pins the contract that
+ /// gates every source of the Agent meta-device UUID (operator-supplied
+ /// override AND persisted agent.information.json state) so that
+ /// malformed values — anything that does not parse as an RFC 4122 UUID —
+ /// are rejected on the way in and the resolution falls through to the
+ /// next path (persisted → derived).
+ ///
+ ///
+ /// Silently forwarding a non-UUID string violates MTConnect Part 1, which
+ /// types the uuid attribute as the UUID DataType (RFC 4122
+ /// enumerated string token). Any downstream XSD-validating consumer
+ /// (cppagent parity, MQTT/JSON-cppagent transport) rejects the resulting
+ /// wire content on typed enum/decimal DataItems.
+ ///
+ ///
+ ///
+ /// Fixture drives directly — the
+ /// same method MTConnectAgentApplication.StartAgent calls — so
+ /// production and tests cannot silently diverge on branch order, guard
+ /// semantics, or normalisation output.
+ ///
+ ///
+ [TestFixture]
+ [NonParallelizable]
+ public class AgentUuidValidationTests
+ {
+ private string _stateFilePath = null!;
+ private string? _backupStateFile;
+
+ /// Sets up the fixture before each test.
+ [SetUp]
+ public void SetUp()
+ {
+ _stateFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, MTConnectAgentInformation.Filename);
+ _backupStateFile = null;
+
+ // Sweep orphan .valbak.* files from a prior crashed test run so
+ // successive TearDowns cannot restore stale state.
+ var directory = Path.GetDirectoryName(_stateFilePath);
+ if (directory != null && Directory.Exists(directory))
+ {
+ foreach (var stale in Directory.EnumerateFiles(directory, MTConnectAgentInformation.Filename + ".valbak.*"))
+ {
+ try { File.Delete(stale); } catch { /* best-effort — do not fail the test */ }
+ }
+ }
+
+ // Back up any pre-existing state file so we do not perturb other
+ // tests or the developer environment.
+ if (File.Exists(_stateFilePath))
+ {
+ _backupStateFile = _stateFilePath + ".valbak." + Guid.NewGuid().ToString("N");
+ File.Move(_stateFilePath, _backupStateFile);
+ }
+ }
+
+ /// Tears down the fixture after each test.
+ [TearDown]
+ public void TearDown()
+ {
+ // Remove any state file we left behind, then restore the backup.
+ if (File.Exists(_stateFilePath))
+ {
+ File.Delete(_stateFilePath);
+ }
+ if (_backupStateFile != null && File.Exists(_backupStateFile))
+ {
+ File.Move(_backupStateFile, _stateFilePath);
+ _backupStateFile = null;
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Unit tests — DeterministicAgentUuid.TryValidate low-level contract
+ // ------------------------------------------------------------------
+
+ ///
+ /// rejects
+ /// , empty, and whitespace-only inputs.
+ ///
+ [TestCase(null)]
+ [TestCase("")]
+ [TestCase(" ")]
+ [TestCase("\t")]
+ [TestCase("\n")]
+ public void TryValidate_null_or_whitespace_returns_false(string input)
+ {
+ var ok = DeterministicAgentUuid.TryValidate(input, out var normalized);
+
+ Assert.That(ok, Is.False);
+ Assert.That(normalized, Is.Null);
+ }
+
+ ///
+ /// rejects strings
+ /// that do not parse as RFC 4122 UUIDs.
+ ///
+ [TestCase("not-a-uuid")]
+ [TestCase("fixture-stable-uuid-001")]
+ [TestCase("agent_1234567890abcdef")]
+ [TestCase("123")]
+ [TestCase("6ba7b810-9dad-11d1-80b4-00c04fd430c8-extra")]
+ public void TryValidate_unparseable_returns_false(string input)
+ {
+ var ok = DeterministicAgentUuid.TryValidate(input, out var normalized);
+
+ Assert.That(ok, Is.False);
+ Assert.That(normalized, Is.Null);
+ }
+
+ ///
+ /// accepts the
+ /// canonical hyphenated "D" form and returns it unchanged.
+ ///
+ [Test]
+ public void TryValidate_canonical_hyphenated_form_returns_true_unchanged()
+ {
+ const string Canonical = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
+
+ var ok = DeterministicAgentUuid.TryValidate(Canonical, out var normalized);
+
+ Assert.That(ok, Is.True);
+ Assert.That(normalized, Is.EqualTo(Canonical));
+ }
+
+ ///
+ /// normalizes the
+ /// braced "B", parenthesised "P", and bare-hex "N" forms to the
+ /// canonical hyphenated "D" form so the wire representation stays
+ /// stable regardless of the input format.
+ ///
+ [TestCase("{6ba7b810-9dad-11d1-80b4-00c04fd430c8}", "6ba7b810-9dad-11d1-80b4-00c04fd430c8")]
+ [TestCase("(6ba7b810-9dad-11d1-80b4-00c04fd430c8)", "6ba7b810-9dad-11d1-80b4-00c04fd430c8")]
+ [TestCase("6ba7b8109dad11d180b400c04fd430c8", "6ba7b810-9dad-11d1-80b4-00c04fd430c8")]
+ public void TryValidate_non_canonical_format_normalizes_to_hyphenated(string input, string expected)
+ {
+ var ok = DeterministicAgentUuid.TryValidate(input, out var normalized);
+
+ Assert.That(ok, Is.True);
+ Assert.That(normalized, Is.EqualTo(expected));
+ }
+
+ // ------------------------------------------------------------------
+ // Integration tests — AgentUuidResolver.Resolve three-path algorithm
+ // ------------------------------------------------------------------
+
+ ///
+ /// Boot-simulation regression — malformed
+ /// AgentApplicationConfiguration.AgentUuid on a fresh boot
+ /// (no agent.information.json) must fall through to the
+ /// derived UUID rather than being silently stored verbatim.
+ ///
+ [Test]
+ public void Malformed_AgentUuid_on_fresh_boot_falls_through_to_derived()
+ {
+ const string MalformedInput = "not-a-uuid";
+ const string ServiceName = "test-agent-malformed-fresh";
+ var hostname = Environment.MachineName;
+
+ var configuration = new AgentApplicationConfiguration
+ {
+ AgentUuid = MalformedInput,
+ ServiceName = ServiceName,
+ };
+
+ var resolved = SimulateFreshBoot(configuration, hostname);
+
+ var expectedDerived = DeterministicAgentUuid.Derive(ServiceName, hostname, port: 0);
+ Assert.That(resolved, Is.EqualTo(expectedDerived),
+ "Malformed operator override on a fresh boot must fall through to the derived UUID.");
+ Assert.That(resolved, Is.Not.EqualTo(MalformedInput),
+ "The malformed input must NOT be stored verbatim (Part-1 UuidType violation).");
+ }
+
+ ///
+ /// Boot-simulation regression — malformed
+ /// AgentApplicationConfiguration.AgentUuid when a valid
+ /// agent.information.json exists must preserve the persisted
+ /// UUID rather than being silently stored verbatim.
+ ///
+ [Test]
+ public void Malformed_AgentUuid_with_valid_persisted_state_preserves_persisted()
+ {
+ const string MalformedInput = "not-a-uuid";
+ const string PersistedUuid = "cfbff0d1-9375-5685-968a-48ce8b50a653";
+
+ // Pre-write the state file with a valid UUID.
+ var preexisting = new MTConnectAgentInformation(PersistedUuid);
+ preexisting.Save();
+
+ var configuration = new AgentApplicationConfiguration
+ {
+ AgentUuid = MalformedInput,
+ ServiceName = "test-agent-malformed-persisted",
+ };
+
+ var resolved = SimulateWarmBoot(configuration, Environment.MachineName);
+
+ Assert.That(resolved, Is.EqualTo(PersistedUuid),
+ "Malformed operator override with valid persisted state must keep the persisted UUID.");
+ Assert.That(resolved, Is.Not.EqualTo(MalformedInput),
+ "The malformed input must NOT overwrite the persisted UUID.");
+ }
+
+ ///
+ /// Path-2 hardening — malformed persisted state (e.g. a pre-hardening
+ /// agent version wrote a non-UUID string, or the file was hand-edited)
+ /// must ALSO fall through to the derived UUID rather than flowing on
+ /// to the wire. Prevents the exact XSD-validation failure the PR was
+ /// written to prevent, closed on the persisted-state axis too.
+ ///
+ [Test]
+ public void Malformed_persisted_state_with_no_override_falls_through_to_derived()
+ {
+ const string MalformedPersisted = "agent_1234567890abcdef";
+ const string ServiceName = "test-agent-malformed-persisted-path2";
+ var hostname = Environment.MachineName;
+
+ var preexisting = new MTConnectAgentInformation(MalformedPersisted);
+ preexisting.Save();
+
+ var configuration = new AgentApplicationConfiguration
+ {
+ AgentUuid = null,
+ ServiceName = ServiceName,
+ };
+
+ var resolved = SimulateWarmBoot(configuration, hostname);
+
+ var expectedDerived = DeterministicAgentUuid.Derive(ServiceName, hostname, port: 0);
+ Assert.That(resolved, Is.EqualTo(expectedDerived),
+ "Malformed persisted UUID with no override must fall through to derived — Path 2 must validate.");
+ Assert.That(resolved, Is.Not.EqualTo(MalformedPersisted),
+ "The malformed persisted value must NOT reach the wire.");
+ }
+
+ ///
+ /// Boot-simulation regression — valid non-canonical
+ /// AgentApplicationConfiguration.AgentUuid (e.g. braced form)
+ /// is accepted and normalized to the canonical hyphenated form so the
+ /// wire representation stays stable across boots.
+ ///
+ [Test]
+ public void Valid_non_canonical_AgentUuid_is_normalized_to_hyphenated()
+ {
+ const string BracedInput = "{6ba7b810-9dad-11d1-80b4-00c04fd430c8}";
+ const string ExpectedCanonical = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
+
+ var configuration = new AgentApplicationConfiguration
+ {
+ AgentUuid = BracedInput,
+ ServiceName = "test-agent-canonicalization",
+ };
+
+ var resolved = SimulateFreshBoot(configuration, Environment.MachineName);
+
+ Assert.That(resolved, Is.EqualTo(ExpectedCanonical),
+ "Non-canonical valid UUID inputs must be normalized to the hyphenated D-form.");
+ }
+
+ ///
+ /// Two-boot regression — first boot with no override and no state file
+ /// derives + persists a UUID; second boot with no override reads the
+ /// persisted UUID from agent.information.json and adopts it
+ /// verbatim (Path 2). Bit-identical across the two boots.
+ ///
+ [Test]
+ public void Persisted_UUID_is_adopted_on_second_boot_when_no_override()
+ {
+ const string ServiceName = "test-agent-persisted-second-boot";
+ var hostname = Environment.MachineName;
+
+ var configuration = new AgentApplicationConfiguration
+ {
+ AgentUuid = null,
+ ServiceName = ServiceName,
+ };
+
+ // Boot 1 — no override, no state file → derive + persist.
+ var boot1Uuid = SimulateFreshBoot(configuration, hostname);
+ Assert.That(File.Exists(_stateFilePath), Is.True,
+ "Boot 1 must persist agent.information.json with the resolved UUID.");
+ var persistedAfterBoot1 = MTConnectAgentInformation.Read();
+ Assert.That(persistedAfterBoot1, Is.Not.Null);
+ Assert.That(persistedAfterBoot1!.Uuid, Is.EqualTo(boot1Uuid),
+ "Boot 1's on-disk UUID must equal the resolved value.");
+
+ // Boot 2 — no override, but persisted state exists → adopt persisted.
+ var boot2Uuid = SimulateWarmBoot(configuration, hostname);
+ Assert.That(boot2Uuid, Is.EqualTo(boot1Uuid),
+ "Boot 2 must adopt the persisted UUID bit-identically (Path 2).");
+ }
+
+ ///
+ /// First-boot persistence regression — no override, no state file →
+ /// Path 3 derives the UUID AND
+ /// writes it to agent.information.json so subsequent boots hit
+ /// Path 2. Guards against a regression where the resolve step returns
+ /// the derived value but the persist step is dropped.
+ ///
+ [Test]
+ public void First_boot_persists_derived_UUID_to_agent_information_json()
+ {
+ const string ServiceName = "test-agent-first-boot-persist";
+ var hostname = Environment.MachineName;
+
+ var configuration = new AgentApplicationConfiguration
+ {
+ AgentUuid = null,
+ ServiceName = ServiceName,
+ };
+
+ var resolved = SimulateFreshBoot(configuration, hostname);
+
+ Assert.That(File.Exists(_stateFilePath), Is.True,
+ "agent.information.json must exist after the first boot.");
+ var persisted = MTConnectAgentInformation.Read();
+ Assert.That(persisted, Is.Not.Null);
+ Assert.That(persisted!.Uuid, Is.EqualTo(resolved),
+ "Persisted UUID on disk must equal the resolved value.");
+ var expectedDerived = DeterministicAgentUuid.Derive(ServiceName, hostname, port: 0);
+ Assert.That(persisted.Uuid, Is.EqualTo(expectedDerived),
+ "Persisted UUID must equal the deterministic derivation for the given ServiceName.");
+ }
+
+ // ------------------------------------------------------------------
+ // Boot-simulation helpers — thin wrappers around AgentUuidResolver.
+ // ------------------------------------------------------------------
+
+ ///
+ /// Replays the fresh-boot UUID resolution slice of
+ /// MTConnectAgentApplication.StartAgent — no
+ /// agent.information.json on disk — via
+ /// (the exact call production
+ /// makes) followed by so
+ /// on-disk state assertions can verify persistence.
+ ///
+ private string SimulateFreshBoot(
+ AgentApplicationConfiguration configuration,
+ string hostname)
+ {
+ var existing = MTConnectAgentInformation.Read();
+ var freshlyConstructed = existing == null;
+ var info = existing ?? new MTConnectAgentInformation();
+
+ info.Uuid = AgentUuidResolver.Resolve(
+ operatorSuppliedUuid: configuration.AgentUuid,
+ persistedUuid: freshlyConstructed ? null : info.Uuid,
+ agentName: configuration.ServiceName,
+ hostname: hostname);
+
+ info.Save();
+ return info.Uuid;
+ }
+
+ ///
+ /// Replays the warm-boot UUID resolution slice — pre-existing
+ /// agent.information.json is read first — via
+ /// . Semantically identical to
+ /// ; two named helpers make the
+ /// per-test intent (fresh vs warm) unambiguous at the call site.
+ ///
+ private string SimulateWarmBoot(
+ AgentApplicationConfiguration configuration,
+ string hostname) => SimulateFreshBoot(configuration, hostname);
+ }
+}