R0.1 browse probe: StartTagQuery over gRPC takes an OData filter (live)
Probes the 2023 R2 gRPC browse path and records the finding. The front door does NOT hit the 2020 WCF metadata-server-pipe wall. - RetrievalService.StartTagQuery is cracked: the server (CMdServer::StartActiveTagnamesQuery over \.\pipe\aahMetadataServer\console) parses the filter as OData. startswith()/ contains()/eq/empty succeed and return the 8-byte (queryHandle, tagCount); SQL-LIKE "%" and glob "*" fail with "ODataFilter: bad token". Live: 220 Sys* tags counted. - QueryTag (paging) remains: every guessed btRequest returns a constant native error type 4 / code 72 (content-independent) -> framing needs a native capture, not guessing. Adds RE probe helpers Grpc/HistorianGrpcTagClient.ProbeStartTagQuery + ProbeTagQuerySequence, a gated StartTagQuery_OverGrpc_AcceptsODataFilter test, and the finding doc docs/reverse-engineering/grpc-tag-query-odata.md. Browse is not yet wired (QueryTag open). 217 unit tests pass; 5/5 live gRPC tests pass. No tag names/identities committed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
@@ -36,6 +36,14 @@ HCAL replacement, built on the **2023 R2 gRPC transport**. Derived from
|
||||
records (reuses `ParseGetTagInfoResponse`); string handle = uppercase Open2 storage GUID. The 2020
|
||||
WCF string-handle wall does **not** apply on the gRPC front door (as predicted). **LIVE-VERIFIED
|
||||
2026-06-21** — `GetTagMetadataAsync` returned the requested tag + a valid data type.
|
||||
- 🟡 **R0.1 Browse over gRPC** — PARTIAL. Probed live 2026-06-21: the 2023 R2 gRPC front door does
|
||||
**not** hit the 2020 metadata-server-pipe wall. `RetrievalService.StartTagQuery` is **cracked** — it
|
||||
parses the filter as **OData** (`startswith(TagName,'Sys')`/`contains`/`eq`/empty succeed, returning
|
||||
the 8-byte `(queryHandle, tagCount)`; SQL-LIKE `%`/glob `*` → `ODataFilter: bad token`). Live: 220
|
||||
Sys* tags counted. **Remaining:** the `QueryTag` paging request format (constant native error
|
||||
type 4 / code 72 across guessed `btRequest` shapes) — needs a native capture, not guessing. Probe
|
||||
helpers (`HistorianGrpcTagClient.ProbeStartTagQuery`/`ProbeTagQuerySequence`) + a gated StartTagQuery
|
||||
test are committed. Full finding: `docs/reverse-engineering/grpc-tag-query-odata.md`.
|
||||
|
||||
> ℹ️ **Auth note (2026-06-21, resolved):** an apparent NTLM round-1 `SEC_E_LOGON_DENIED` blocker
|
||||
> turned out to be a **test-harness credential-parsing bug**, not a server/account/SDK issue — the
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# R0.1 browse over gRPC — StartTagQuery takes an OData filter (2026-06-21)
|
||||
|
||||
Live-probed `RetrievalService.StartTagQuery` / `QueryTag` against a real **2023 R2** server over the
|
||||
gRPC front door (string-handle = uppercase Open2 storage GUID). Key result: **browse is feasible on
|
||||
2023 R2** — the 2020 WCF "metadata-server pipe" wall does **not** block here.
|
||||
|
||||
## StartTagQuery — CRACKED
|
||||
|
||||
`StartTagQuery(strHandle, btRequest)` where `btRequest` = the native
|
||||
`marker(26449) + version(1) + WriteHistorianString(filter)` buffer
|
||||
(`HistorianTagQueryProtocol.CreateStartTagQueryAttempt`). The server runs
|
||||
`CMdServer::StartTagQuery::StartActiveTagnamesQuery` over `\\.\pipe\aahMetadataServer\console` and
|
||||
**parses the filter string as OData** (not SQL-LIKE). Swept filters:
|
||||
|
||||
| filter | result |
|
||||
|---|---|
|
||||
| `startswith(TagName,'Sys')` | ✅ success, 8-byte response |
|
||||
| `contains(TagName,'Sys')` | ✅ success |
|
||||
| `TagName eq 'SysTimeSec'` | ✅ success |
|
||||
| `` (empty) | ✅ success (all tags) |
|
||||
| `Sys*` / `*` | ❌ `ODataFilter ... bad token` |
|
||||
| `TagName like 'Sys%'` / `Name like 'Sys%'` | ❌ rejected |
|
||||
|
||||
Success response `btResponse` is the 8-byte `(queryHandle:uint, tagCount:uint)` pair
|
||||
(`ParseStartTagQueryResponse`). Live: `startswith(TagName,'Sys')` → tagCount = 220.
|
||||
|
||||
**Implication for the public API:** browse must translate the SDK's glob filter to OData —
|
||||
`*` → empty, `Pre*` → `startswith(TagName,'Pre')`, `*sub*` → `contains(TagName,'sub')`,
|
||||
exact → `TagName eq '...'`. (Escaping single-quotes in names still TBD.)
|
||||
|
||||
## QueryTag — OPEN (one capture away)
|
||||
|
||||
`QueryTag(strHandle, uiQueryHandle, btRequest)` is the paging call that should return the actual
|
||||
tag-name rows. Every `btRequest` shape tried returns a constant native error **type 4 / code 72**
|
||||
(independent of content: empty, count, column-name historian-string, `$select=TagName`,
|
||||
marker+version+name all give the same `04 48000000`). The constant code regardless of input means the
|
||||
request *framing* is wrong, not the field values — this needs a **native capture** of the real 2023 R2
|
||||
client driving a browse to recover the exact `QueryTag` `btRequest` (and the row framing in
|
||||
`btResonse`). Do not ship a guessed QueryTag request (project discipline: no guessed wire bytes).
|
||||
|
||||
Probe helpers live in `Grpc/HistorianGrpcTagClient` (`ProbeStartTagQuery`, `ProbeTagQuerySequence`)
|
||||
and the gated `StartTagQuery_OverGrpc_AcceptsODataFilter` test pins the StartTagQuery+OData result.
|
||||
@@ -89,6 +89,97 @@ internal static class HistorianGrpcTagClient
|
||||
return response.BtTagInfos?.ToByteArray() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a StartTagQuery probe — whether the gRPC front door accepts the op.</summary>
|
||||
internal readonly record struct StartTagQueryProbeResult(bool Success, int ResponseLength, byte[] Response, int ErrorLength, byte[] Error);
|
||||
|
||||
/// <summary>
|
||||
/// Reverse-engineering probe for R0.1 (browse): drives <c>RetrievalService.StartTagQuery</c> with
|
||||
/// the native filter request and reports whether the 2023 R2 front door accepts it (on 2020 WCF this
|
||||
/// op fails server-side on the aahMetadataServer pipe regardless of handle format).
|
||||
/// </summary>
|
||||
internal static StartTagQueryProbeResult ProbeStartTagQuery(HistorianClientOptions options, string filter, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
||||
|
||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||
byte[] requestBuffer = HistorianTagQueryProtocol.CreateStartTagQueryAttempt(filter).RequestBuffer;
|
||||
GrpcRetrieval.StartTagQueryResponse response = retrievalClient.StartTagQuery(
|
||||
new GrpcRetrieval.StartTagQueryRequest
|
||||
{
|
||||
StrHandle = session.StringHandle,
|
||||
BtRequest = ByteString.CopyFrom(requestBuffer)
|
||||
},
|
||||
connection.Metadata,
|
||||
DateTime.UtcNow.Add(options.RequestTimeout),
|
||||
cancellationToken);
|
||||
|
||||
bool success = response.Status?.BSuccess ?? false;
|
||||
byte[] resp = response.BtResponse?.ToByteArray() ?? [];
|
||||
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
|
||||
return new StartTagQueryProbeResult(success, resp.Length, resp, error.Length, error);
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a full StartTagQuery → QueryTag → EndTagQuery probe.</summary>
|
||||
internal readonly record struct TagQuerySequenceResult(
|
||||
bool StartSuccess, uint QueryHandle, uint TagCount,
|
||||
bool QuerySuccess, byte[] QueryResponse, byte[] QueryError);
|
||||
|
||||
/// <summary>
|
||||
/// Reverse-engineering probe for R0.1 (browse): runs the full StartTagQuery (OData filter) →
|
||||
/// QueryTag (paging) → EndTagQuery sequence and returns the raw QueryTag response buffer so its
|
||||
/// tag-name framing can be discovered. <paramref name="queryRequest"/> is the candidate
|
||||
/// QueryTag <c>btRequest</c> buffer (format unknown — swept by the probe).
|
||||
/// </summary>
|
||||
internal static TagQuerySequenceResult ProbeTagQuerySequence(
|
||||
HistorianClientOptions options, string odataFilter, byte[] queryRequest, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||
DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout);
|
||||
|
||||
byte[] startRequest = HistorianTagQueryProtocol.CreateStartTagQueryAttempt(odataFilter).RequestBuffer;
|
||||
GrpcRetrieval.StartTagQueryResponse start = retrievalClient.StartTagQuery(
|
||||
new GrpcRetrieval.StartTagQueryRequest { StrHandle = session.StringHandle, BtRequest = ByteString.CopyFrom(startRequest) },
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
if (!(start.Status?.BSuccess ?? false))
|
||||
{
|
||||
return new TagQuerySequenceResult(false, 0, 0, false, [], start.Status?.BtError?.ToByteArray() ?? []);
|
||||
}
|
||||
|
||||
byte[] startResp = start.BtResponse?.ToByteArray() ?? [];
|
||||
HistorianTagQueryStartResponse parsed = HistorianTagQueryProtocol.ParseStartTagQueryResponse(startResp);
|
||||
|
||||
try
|
||||
{
|
||||
GrpcRetrieval.QueryTagResponse query = retrievalClient.QueryTag(
|
||||
new GrpcRetrieval.QueryTagRequest
|
||||
{
|
||||
StrHandle = session.StringHandle,
|
||||
UiQueryHandle = parsed.QueryHandle,
|
||||
BtRequest = ByteString.CopyFrom(queryRequest)
|
||||
},
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
|
||||
return new TagQuerySequenceResult(
|
||||
true, parsed.QueryHandle, parsed.TagCount,
|
||||
query.Status?.BSuccess ?? false,
|
||||
query.BtResonse?.ToByteArray() ?? [],
|
||||
query.Status?.BtError?.ToByteArray() ?? []);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
retrievalClient.EndTagQuery(
|
||||
new GrpcRetrieval.EndTagQueryRequest { StrHandle = session.StringHandle, UiQueryHandle = parsed.QueryHandle },
|
||||
connection.Metadata, Deadline(), CancellationToken.None);
|
||||
}
|
||||
catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Builds the native tag-names request buffer: uint count + per-name(uint charCount + UTF-16LE).</summary>
|
||||
internal static byte[] BuildTagNamesBuffer(IReadOnlyList<string> tags)
|
||||
{
|
||||
|
||||
@@ -86,6 +86,30 @@ public sealed class HistorianGrpcIntegrationTests
|
||||
Assert.True(Enum.IsDefined(metadata.DataType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartTagQuery_OverGrpc_AcceptsODataFilter()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// R0.1 finding (2026-06-21): on the 2023 R2 gRPC front door the metadata-server pipe IS
|
||||
// reachable (unlike 2020 WCF) and StartActiveTagnamesQuery parses the filter as OData —
|
||||
// startswith/contains/eq succeed; SQL-LIKE "%"/glob "*" fail with "ODataFilter: bad token".
|
||||
// StartTagQuery returns the 8-byte (queryHandle, tagCount) response. The follow-on QueryTag
|
||||
// paging request format is not yet captured (see roadmap R0.1), so browse is not yet wired.
|
||||
HistorianClientOptions options = BuildOptions(host);
|
||||
var result = await Task.Run(() =>
|
||||
AVEVA.Historian.Client.Grpc.HistorianGrpcTagClient.ProbeStartTagQuery(options, "startswith(TagName,'Sys')", CancellationToken.None));
|
||||
|
||||
Assert.True(result.Success,
|
||||
$"StartTagQuery(OData) should succeed; errLen={result.ErrorLength} " +
|
||||
$"err=\"{System.Text.Encoding.ASCII.GetString(result.Error).Replace('\0', '.')}\"");
|
||||
Assert.Equal(8, result.ResponseLength); // (queryHandle, tagCount)
|
||||
}
|
||||
|
||||
private static HistorianClientOptions BuildOptions(string host)
|
||||
{
|
||||
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
||||
|
||||
Reference in New Issue
Block a user