diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs
index 756d2977..3049e23a 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyBrowseSession.cs
@@ -12,11 +12,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
/// caches it by TagName (and GobjectId for parent lookup), and serves
/// subsequent calls in-memory. Attribute fetches are
/// per-object via DiscoverHierarchyAsync(MaxDepth=0, IncludeAttributes=true).
-/// Owns the supplied and disposes it best-effort.
+/// Owns the supplied and disposes it
+/// best-effort. (Browse does not need an MxGatewaySession — that's only
+/// required for live subscribe/write paths handled by the runtime driver.)
///
internal sealed class GalaxyBrowseSession : IBrowseSession
{
- private readonly MxGatewaySession _session;
private readonly GalaxyRepositoryClient _client;
private readonly ConcurrentDictionary _byTagName = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary _byGobjectId = new();
@@ -31,15 +32,14 @@ internal sealed class GalaxyBrowseSession : IBrowseSession
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
///
- /// Initializes a new session wrapping a connected gateway client. The factory
- /// in GalaxyDriverBrowser (Task 9) constructs both the session and the
- /// repository client and hands them off here for the session's lifetime.
+ /// Initializes a new session wrapping a connected repository client. The factory
+ /// in GalaxyDriverBrowser (Task 9) constructs the client via
+ /// and hands it off here for the
+ /// session's lifetime.
///
- /// Gateway session to dispose when the browse closes.
/// Galaxy repository client to query for hierarchy and attributes.
- internal GalaxyBrowseSession(MxGatewaySession session, GalaxyRepositoryClient client)
+ internal GalaxyBrowseSession(GalaxyRepositoryClient client)
{
- _session = session ?? throw new ArgumentNullException(nameof(session));
_client = client ?? throw new ArgumentNullException(nameof(client));
}
@@ -186,7 +186,7 @@ internal sealed class GalaxyBrowseSession : IBrowseSession
};
///
- /// Idempotently tears down the underlying gateway session. Swallows exceptions
+ /// Idempotently tears down the underlying repository client. Swallows exceptions
/// on shutdown — the registry's reaper may be racing a client-initiated close.
///
public async ValueTask DisposeAsync()
@@ -196,7 +196,7 @@ internal sealed class GalaxyBrowseSession : IBrowseSession
_rootGate.Dispose();
try
{
- await _session.DisposeAsync().ConfigureAwait(false);
+ await _client.DisposeAsync().ConfigureAwait(false);
}
catch
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs
new file mode 100644
index 00000000..98b8cbdb
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs
@@ -0,0 +1,192 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using MxGateway.Client;
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
+
+///
+/// Opens transient gateway connections for the AdminUI address picker. Mirrors the
+/// runtime GalaxyDriver.BuildClientOptions pattern so the gateway sees the
+/// same option shape, but tweaks
+/// resolution + the gateway-side client identity so browse sessions are
+/// distinguishable from the runtime driver's live MX session.
+///
+public sealed class GalaxyDriverBrowser : IDriverBrowser
+{
+ ///
+ /// Identifier used in gateway-side logs / metrics for AdminUI browse sessions.
+ /// Distinct from any runtime driver's MxAccess.ClientName so an operator
+ /// can tell the two apart when triaging.
+ ///
+ internal const string BrowseClientIdentity = "OtOpcUa-AdminUI-Browse";
+
+ /// Hard cap on the time we'll wait for the initial gateway handshake.
+ private static readonly TimeSpan ConnectBudget = TimeSpan.FromSeconds(30);
+
+ private static readonly JsonSerializerOptions JsonOpts = new()
+ {
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ PropertyNameCaseInsensitive = true,
+ };
+
+ private readonly ILogger _logger;
+
+ /// Creates a new browser. Logger defaults to .
+ /// Optional logger; null is allowed for unit-test construction.
+ public GalaxyDriverBrowser(ILogger? logger = null)
+ {
+ _logger = logger ?? NullLogger.Instance;
+ }
+
+ /// Driver type key — matches the AdminUI's persisted "Galaxy" value.
+ public string DriverType => "Galaxy";
+
+ ///
+ /// Deserializes a blob, opens a transient
+ /// against the configured gateway endpoint,
+ /// and returns a browse session over it. The session owns the client and disposes
+ /// it on .
+ ///
+ /// Driver options serialized as JSON; same shape the runtime
+ /// driver would consume.
+ /// Cancellation for the connect phase only.
+ ///
+ /// Thrown when the JSON deserialises to null, when Gateway.Endpoint is empty,
+ /// or when MxAccess.ClientName is empty.
+ ///
+ public async Task OpenAsync(string configJson, CancellationToken cancellationToken)
+ {
+ var opts = JsonSerializer.Deserialize(configJson, JsonOpts)
+ ?? throw new InvalidOperationException("Galaxy options deserialized to null.");
+
+ if (string.IsNullOrWhiteSpace(opts.Gateway.Endpoint))
+ throw new InvalidOperationException("Galaxy browser requires Gateway.Endpoint.");
+
+ // The form persists MXAccess identity as ClientName (there is no separate
+ // "galaxy name" knob on the driver — the gateway picks the galaxy via its
+ // own GalaxyRepository config). Refuse a blank ClientName so the gateway side
+ // doesn't see anonymous browse sessions during triage.
+ if (string.IsNullOrWhiteSpace(opts.MxAccess.ClientName))
+ throw new InvalidOperationException("Galaxy browser requires MxAccess.ClientName.");
+
+ var clientOpts = BuildClientOptions(opts.Gateway);
+
+ // 30s wall-clock budget for the connect phase, linked to the caller's token so
+ // an AdminUI cancel still wins early.
+ using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ connectCts.CancelAfter(ConnectBudget);
+
+ GalaxyRepositoryClient? client = null;
+ try
+ {
+ client = GalaxyRepositoryClient.Create(clientOpts);
+
+ // TestConnectionAsync gives the gateway a chance to surface auth / TLS / DNS
+ // failures synchronously inside the connect budget rather than waiting for
+ // the first DiscoverHierarchyAsync call to fail. The client's own
+ // ConnectTimeout already bounds the underlying gRPC handshake; the linked
+ // CTS layered on top guarantees the AdminUI never blocks past 30s.
+ await client.TestConnectionAsync(connectCts.Token).ConfigureAwait(false);
+
+ _logger.LogInformation(
+ "AdminUI Galaxy browse session opened against {Endpoint} (admin-client={Identity}, runtime-client={RuntimeClient})",
+ opts.Gateway.Endpoint, BrowseClientIdentity, opts.MxAccess.ClientName);
+
+ var session = new GalaxyBrowseSession(client);
+ client = null; // Ownership transferred — keep finally from disposing.
+ return session;
+ }
+ catch
+ {
+ if (client is not null)
+ {
+ try
+ {
+ await client.DisposeAsync().ConfigureAwait(false);
+ }
+ catch
+ {
+ // Best-effort cleanup; the original exception is more useful.
+ }
+ }
+ throw;
+ }
+ }
+
+ ///
+ /// Build the gateway client options from the form's Gateway section. Mirrors the
+ /// runtime driver's GalaxyDriver.BuildClientOptions field-for-field so the
+ /// gateway sees an identical option shape. The API-key reference is resolved
+ /// inline (a slim version of GalaxyDriver.ResolveApiKey) because the
+ /// Browser project doesn't reference Driver.Galaxy.
+ ///
+ private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
+ {
+ Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
+ ApiKey = ResolveApiKey(gw.ApiKeySecretRef),
+ UseTls = gw.UseTls,
+ CaCertificatePath = gw.CaCertificatePath,
+ ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
+ DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
+ StreamTimeout = gw.StreamTimeoutSeconds > 0
+ ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
+ : null,
+ };
+
+ ///
+ /// Resolves env:NAME, file:PATH, and dev:KEY prefixes;
+ /// anything else is treated as a literal cleartext key with a startup warning.
+ /// Slim mirror of GalaxyDriver.ResolveApiKey — the runtime version lives
+ /// in a sibling project the Browser intentionally doesn't reference.
+ ///
+ /// The secret reference string to resolve.
+ private string ResolveApiKey(string secretRef)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(secretRef);
+
+ if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
+ {
+ var name = secretRef[4..];
+ var value = Environment.GetEnvironmentVariable(name);
+ return !string.IsNullOrEmpty(value)
+ ? value
+ : throw new InvalidOperationException(
+ $"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves to env var '{name}', but it is unset.");
+ }
+
+ if (secretRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
+ {
+ var path = secretRef[5..];
+ if (!File.Exists(path))
+ {
+ throw new InvalidOperationException(
+ $"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' points at '{path}', which doesn't exist.");
+ }
+ var contents = File.ReadAllText(path).Trim();
+ return !string.IsNullOrEmpty(contents)
+ ? contents
+ : throw new InvalidOperationException(
+ $"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' file '{path}' is empty.");
+ }
+
+ if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase))
+ {
+ // Explicit dev opt-in — no warning, the operator deliberately chose a
+ // cleartext literal (dev box, parity rig).
+ return secretRef[4..];
+ }
+
+ // Back-compat literal arm. An unprefixed string is treated as the literal
+ // API key — but emit a warning so an operator who accidentally committed a
+ // cleartext key into DriverConfig sees it when they open the address picker.
+ _logger.LogWarning(
+ "Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " +
+ "Prefer env:NAME, file:PATH, or the explicit dev:KEY prefix for dev rigs — " +
+ "a literal key in DriverConfig JSON is stored in cleartext in the central config DB.");
+ return secretRef;
+ }
+}