diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs
new file mode 100644
index 00000000..dd0a0e64
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs
@@ -0,0 +1,234 @@
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Opc.Ua;
+using Opc.Ua.Client;
+using Opc.Ua.Configuration;
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; // OpcUaClientDriverOptions + NamespaceMap
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
+
+///
+/// Opens transient OPC UA sessions from form-supplied JSON for the AdminUI address picker.
+/// Mirrors the runtime driver's connect path but with a separate PKI store so browse-time
+/// trust decisions cannot poison the runtime driver's cert store.
+///
+public sealed class OpcUaClientDriverBrowser : IDriverBrowser
+{
+ private static readonly JsonSerializerOptions JsonOpts = new()
+ {
+ UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
+ PropertyNameCaseInsensitive = true,
+ };
+
+ private readonly ILogger _logger;
+
+ /// Creates a new browser. Logger defaults to NullLogger when not supplied.
+ /// Optional logger; defaults to .
+ public OpcUaClientDriverBrowser(ILogger? logger = null)
+ {
+ _logger = logger ?? NullLogger.Instance;
+ }
+
+ /// Driver type key — matches the AdminUI's persisted "OpcUaClient" value.
+ public string DriverType => "OpcUaClient";
+
+ /// Opens a transient OPC UA session and returns a browse session over it.
+ /// Driver options serialized as JSON; same shape the runtime
+ /// driver would consume.
+ /// Cancellation for the connect phase only.
+ public async Task OpenAsync(string configJson, CancellationToken cancellationToken)
+ {
+ var opts = JsonSerializer.Deserialize(configJson, JsonOpts)
+ ?? throw new InvalidOperationException("OpcUaClient options deserialized to null.");
+
+ var endpoint = opts.EndpointUrls is { Count: > 0 } ? opts.EndpointUrls[0] : opts.EndpointUrl;
+ if (string.IsNullOrWhiteSpace(endpoint))
+ throw new InvalidOperationException("OpcUaClient browser requires EndpointUrl or EndpointUrls[0].");
+
+ if (opts.AuthType == OpcUaAuthType.Certificate)
+ throw new InvalidOperationException(
+ "Browser does not support OpcUaAuthType.Certificate in v1; use Anonymous or Username.");
+
+ if (opts.AutoAcceptCertificates)
+ _logger.LogWarning(
+ "AdminUI browse session opens against {Endpoint} with form's AutoAcceptCertificates=true — " +
+ "browse uses its own cert store and does NOT auto-accept; trust the cert via the runtime " +
+ "driver's PKI store instead.",
+ endpoint);
+
+ var appConfig = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false);
+ var identity = BuildBrowseUserIdentity(opts);
+
+ var perEndpointBudget = TimeSpan.FromSeconds(
+ Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30));
+ using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ connectCts.CancelAfter(perEndpointBudget);
+
+ var endpointDesc = await SelectEndpointAsync(
+ appConfig, endpoint, opts.SecurityPolicy, opts.SecurityMode, connectCts.Token).ConfigureAwait(false);
+ var endpointCfg = EndpointConfiguration.Create(appConfig);
+ endpointCfg.OperationTimeout = (int)opts.Timeout.TotalMilliseconds;
+ var configuredEndpoint = new ConfiguredEndpoint(null, endpointDesc, endpointCfg);
+
+ var session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
+ appConfig,
+ configuredEndpoint,
+ updateBeforeConnect: false,
+ sessionName: "OtOpcUa AdminUI Browse",
+ (uint)opts.SessionTimeout.TotalMilliseconds,
+ identity,
+ preferredLocales: null,
+ connectCts.Token).ConfigureAwait(false);
+
+ try
+ {
+ var nsMap = NamespaceMap.FromSession(session);
+ var rootNodeId = string.IsNullOrEmpty(opts.BrowseRoot)
+ ? ObjectIds.ObjectsFolder
+ : NodeId.Parse(session.MessageContext, opts.BrowseRoot);
+
+ _logger.LogInformation(
+ "AdminUI OPC UA browse session opened against {Endpoint} (policy {Policy}, mode {Mode})",
+ endpoint, opts.SecurityPolicy, opts.SecurityMode);
+
+ return new OpcUaClientBrowseSession(session, nsMap, rootNodeId);
+ }
+ catch
+ {
+ try { if (session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { /* best-effort */ }
+ try { session.Dispose(); } catch { /* best-effort */ }
+ throw;
+ }
+ }
+
+ ///
+ /// Build a minimal in-memory ApplicationConfiguration using a SEPARATE PKI root from
+ /// the runtime driver (%LocalAppData%/OtOpcUa/adminui-browse-pki/). Browse
+ /// trust decisions don't leak into the deployed driver's cert store.
+ ///
+ /// Cancellation token for the configuration build.
+ private static async Task BuildBrowseAppConfigurationAsync(CancellationToken ct)
+ {
+ // The default ctor is obsolete in favour of the ITelemetryContext overload; suppress
+ // locally rather than plumbing a telemetry context all the way through — the browser
+ // emits no per-request telemetry of its own and the SDK's internal fallback is fine
+ // for a transient picker session (mirrors the runtime driver's same suppression).
+#pragma warning disable CS0618
+ var app = new ApplicationInstance
+ {
+ ApplicationName = "OtOpcUa AdminUI Browse",
+ ApplicationType = ApplicationType.Client,
+ };
+#pragma warning restore CS0618
+
+ var pkiRoot = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "OtOpcUa", "adminui-browse-pki");
+
+ var config = new ApplicationConfiguration
+ {
+ ApplicationName = "OtOpcUa AdminUI Browse",
+ ApplicationType = ApplicationType.Client,
+ ApplicationUri = "urn:OtOpcUa:AdminUI:Browse",
+ SecurityConfiguration = new SecurityConfiguration
+ {
+ ApplicationCertificate = new CertificateIdentifier
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = Path.Combine(pkiRoot, "own"),
+ SubjectName = "CN=OtOpcUa AdminUI Browse",
+ },
+ TrustedPeerCertificates = new CertificateTrustList
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = Path.Combine(pkiRoot, "trusted"),
+ },
+ TrustedIssuerCertificates = new CertificateTrustList
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = Path.Combine(pkiRoot, "issuers"),
+ },
+ RejectedCertificateStore = new CertificateTrustList
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = Path.Combine(pkiRoot, "rejected"),
+ },
+ AutoAcceptUntrustedCertificates = false,
+ },
+ TransportQuotas = new TransportQuotas { OperationTimeout = 30_000 },
+ ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
+ DisableHiResClock = true,
+ };
+ await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
+ app.ApplicationConfiguration = config;
+ await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct)
+ .ConfigureAwait(false);
+ return config;
+ }
+
+ /// Build the OPC UA user identity from the form's auth fields.
+ /// Driver options carrying the form's auth fields.
+ private static UserIdentity BuildBrowseUserIdentity(OpcUaClientDriverOptions opts) => opts.AuthType switch
+ {
+ OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
+ OpcUaAuthType.Username => new UserIdentity(
+ opts.Username ?? string.Empty,
+ System.Text.Encoding.UTF8.GetBytes(opts.Password ?? string.Empty)),
+ _ => new UserIdentity(new AnonymousIdentityToken()),
+ };
+
+ /// Select the endpoint matching the requested SecurityPolicy + SecurityMode pair.
+ /// Application configuration used by the discovery client.
+ /// Remote endpoint URL to query.
+ /// Required security policy.
+ /// Required message security mode.
+ /// Cancellation token for the discovery call.
+ private static async Task SelectEndpointAsync(
+ ApplicationConfiguration appConfig, string url,
+ OpcUaSecurityPolicy policy, OpcUaSecurityMode mode, CancellationToken ct)
+ {
+ using var client = await DiscoveryClient.CreateAsync(
+ appConfig, new Uri(url), DiagnosticsMasks.None, ct).ConfigureAwait(false);
+ var all = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);
+
+ var wantedPolicy = MapPolicy(policy);
+ var wantedMode = mode switch
+ {
+ OpcUaSecurityMode.None => MessageSecurityMode.None,
+ OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
+ OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
+ _ => throw new ArgumentOutOfRangeException(nameof(mode)),
+ };
+ var match = all.FirstOrDefault(e =>
+ e.SecurityPolicyUri == wantedPolicy && e.SecurityMode == wantedMode);
+ if (match is null)
+ {
+ var advertised = string.Join(", ", all.Select(e =>
+ $"{ShortName(e.SecurityPolicyUri)}/{e.SecurityMode}"));
+ throw new InvalidOperationException(
+ $"No endpoint at '{url}' matches SecurityPolicy={policy} + SecurityMode={mode}. " +
+ $"Server advertises: {advertised}");
+ }
+ return match;
+ }
+
+ /// Convert the driver options enum to the OPC UA policy URI.
+ /// The driver security policy to map.
+ private static string MapPolicy(OpcUaSecurityPolicy p) => p switch
+ {
+ OpcUaSecurityPolicy.None => SecurityPolicies.None,
+ OpcUaSecurityPolicy.Basic128Rsa15 => SecurityPolicies.Basic128Rsa15,
+ OpcUaSecurityPolicy.Basic256 => SecurityPolicies.Basic256,
+ OpcUaSecurityPolicy.Basic256Sha256 => SecurityPolicies.Basic256Sha256,
+ OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep => SecurityPolicies.Aes128_Sha256_RsaOaep,
+ OpcUaSecurityPolicy.Aes256_Sha256_RsaPss => SecurityPolicies.Aes256_Sha256_RsaPss,
+ _ => throw new ArgumentOutOfRangeException(nameof(p)),
+ };
+
+ /// Render an OPC UA security-policy URI as its short suffix for diag messages.
+ /// Full policy URI to shorten.
+ private static string ShortName(string uri) =>
+ uri?.Substring(uri.LastIndexOf('#') + 1) ?? "(null)";
+}