secretResolverAccessor) =>
+ _secretResolver = secretResolverAccessor ?? throw new ArgumentNullException(nameof(secretResolverAccessor));
+
///
public string DriverType => DriverTypeNames.Heidenhain;
@@ -42,7 +58,7 @@ public sealed class HeidenhainDriverProbe(ISecretResolver? secretResolver = null
string password;
// Resolved only through the namespaced resolver (fail-closed at resolve time): Test Connect can never be
// used to send another subsystem's secret or environment variable to an arbitrary Host.
- try { password = await new HeidenhainCredentials(secretResolver).ResolveAsync(options, device, "INSPECT", cts.Token).ConfigureAwait(false); }
+ try { password = await new HeidenhainCredentials(_secretResolver()).ResolveAsync(options, device, "INSPECT", cts.Token).ConfigureAwait(false); }
catch (HeidenhainCredentialException ex) { return new(false, ex.Message, null); }
try { _ = Lsv2ReadOnlyPolicy.LoginPayload("INSPECT", password); }
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor
index ec8829186..aa0643451 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor
@@ -38,7 +38,7 @@
- Blank = use the driver-level reference. secret:<name> resolves from the node's secret store, env:<VAR> from the environment, at each login. A literal password is never stored.
+ Blank = use the driver-level reference. secret:heidenhain/<name> resolves from the node's secret store, env:OTOPCUA_HEIDENHAIN_<NAME> from the environment, at each login. A literal password is never stored.
@if (_model.Validate() is { } error)
{
@error
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs
index fe6ae03d6..4d9b43db2 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs
@@ -47,7 +47,7 @@ public sealed class HeidenhainDeviceModel
var errors = HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = Host.Trim(), Port = Port }).ToList();
foreach (var (name, v) in new[] { ("INSPECT", InspectPassword), ("DNC", DncPassword), ("DATA", DataPassword) })
if (!HeidenhainOptionsParser.IsCredentialReference(Blank(v)))
- errors.Add($"{name} password must be blank, secret: or env: — never a literal.");
+ errors.Add($"{name} password must be blank, secret:heidenhain/ or env:OTOPCUA_HEIDENHAIN_ — never a literal.");
return errors.Count == 0 ? null : string.Join(" ", errors);
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor
index ee0e6b005..96faaa293 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor
@@ -70,7 +70,7 @@
- References only: blank, secret:<name> (node secret store) or env:<VAR>. Resolved at each login; a literal password is refused.
+ References only: blank, secret:heidenhain/<name> (node secret store) or env:OTOPCUA_HEIDENHAIN_<NAME>. Resolved at each login; a literal password is refused.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs
index 74172dfd7..148a37a39 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs
@@ -1271,6 +1271,16 @@ public sealed class RawTreeService(IDbContextFactory dbF
(dev, drv) => drv.DriverType)
.FirstOrDefaultAsync(ct);
+ ///
+ /// Driver-specific Tag-ROW rules beyond the TagConfig (lmxopcua#803): a Heidenhain tag must be Read-only
+ /// and carry the data type the driver publishes — the same rule the deploy gate applies.
+ ///
+ private static string? TagRowProblem(string? driverType, RawTagInput input, string tagConfig) =>
+ string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)
+ ? Driver.Heidenhain.HeidenhainTagRules.DescribeRowProblem(
+ input.AccessLevel == Configuration.Enums.TagAccessLevel.ReadWrite, input.DataType, tagConfig)
+ : null;
+
///
/// The driver-typed TagConfig gate, applied on the SERVER for every tag-writing path —
/// create, update and bulk import alike.
@@ -1293,16 +1303,6 @@ public sealed class RawTreeService(IDbContextFactory dbF
/// The normalised TagConfig JSON about to be persisted.
/// Cancellation token.
/// An operator-facing error, or when the config is acceptable.
- ///
- /// Driver-specific Tag-ROW rules beyond the TagConfig (lmxopcua#803): a Heidenhain tag must be Read-only
- /// and carry the data type the driver publishes — the same rule the deploy gate applies.
- ///
- private static string? TagRowProblem(string? driverType, RawTagInput input, string tagConfig) =>
- string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)
- ? Driver.Heidenhain.HeidenhainTagRules.DescribeRowProblem(
- input.AccessLevel == Configuration.Enums.TagAccessLevel.ReadWrite, input.DataType, tagConfig)
- : null;
-
private static async Task ValidateTagConfigAsync(
OtOpcUaConfigDbContext db, string deviceId, string tagConfig, CancellationToken ct)
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
index a3f0fa480..9bb0686d7 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
@@ -199,8 +199,12 @@ public static class DriverFactoryBootstrap
// Factory-built so the probe resolves secret: INSPECT password references through the node's resolver
// (registered on every node by AddZbSecrets; optional so a test graph without one still builds — a
// secret: ref then fails closed with a clear probe message rather than sending the literal).
+ // ⚠️ Captured as an accessor, NOT resolved here: the probe set is resolved inside the AdminOperations
+ // Akka starter, and ISecretResolver → AddZbSecrets → IAuditWriter → ActorAuditWriter needs the
+ // AuditWriterActorKey that is registered later in that builder — resolving it eagerly crash-looped every
+ // central node at boot (#803 live gate). See ControlPlane ServiceCollectionExtensions' WithActors note.
services.TryAddEnumerable(ServiceDescriptor.Singleton(
- sp => new HeidenhainProbe(sp.GetService())));
+ sp => new HeidenhainProbe(() => sp.GetService())));
return services;
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs
index 085f6d312..f7fec3c7b 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs
@@ -102,4 +102,27 @@ public sealed class DriverProbeRegistrationTests
probes.Count.ShouldBe(distinctTypes, "Duplicate IDriverProbe registrations — TryAddEnumerable should de-dup.");
distinctTypes.ShouldBe(DriverTypeNames.All.Count);
}
+
+ [Fact]
+ public void Resolving_the_probe_set_never_resolves_the_secret_resolver()
+ {
+ // #803 live gate: the probe set is resolved inside the AdminOperations Akka starter, where
+ // ISecretResolver -> AddZbSecrets -> IAuditWriter -> ActorAuditWriter throws (its actor key is
+ // registered later in the same builder). A probe that resolved the secret resolver eagerly crash-looped
+ // every central node at boot. Model that starter-time graph with a resolver factory that throws.
+ var resolved = 0;
+ var services = new ServiceCollection();
+ services.AddSingleton(_ =>
+ {
+ resolved++;
+ throw new InvalidOperationException("ISecretResolver must not be resolved while building the probe set");
+ });
+ services.AddOtOpcUaDriverProbes();
+
+ using var sp = services.BuildServiceProvider();
+ var probes = sp.GetServices().ToList();
+
+ probes.ShouldNotBeEmpty();
+ resolved.ShouldBe(0);
+ }
}