Files
UA-.NETStandard/samples/Robotics/IntentViewerClient
f6b27025a6 Classify sensitive log parameters with Microsoft.Extensions.Compliance data classification (#4864)
## Proposed changes

Fixes #4857. The stack's log messages wrote network addresses (endpoint,
discovery and reverse connect URLs, host names, IP addresses, ports),
application and user identities, file paths and exception texts as plain
parameters. Applications had no reliable way to keep them out of
general-purpose logs.

This PR replaces the stack's own redaction mechanism with the standard
.NET [data classification and
redaction](https://learn.microsoft.com/dotnet/core/extensions/data-redaction)
packages. Each sensitive `[LoggerMessage]` parameter is classified. The
application decides per classification what reaches its logs: erase it,
replace it with an HMAC token so entries stay correlatable, or pass it
through.

### Taxonomy
`Opc.Ua.Redaction.OpcUaDataClassifications` defines the `OpcUa` taxonomy
and one attribute per classification:

| Classification | Used for | Parameters |
|---|---|---|
| `[NetworkAddress]` | URLs, host and domain names, IP/MAC addresses,
end points, ports | 120 |
| `[ApplicationIdentity]` | certificates, subject, issuer, thumbprint,
ApplicationUri | 103 |
| `[FilePath]` | file, directory and certificate store paths | 67 |
| `[SensitiveText]` | exception or remote-supplied text passed as a
parameter | 55 |
| `[UserIdentity]` | user names, token subjects | 7 |

Correlation ids (`ChannelId`, `SessionId`, ...), status codes and
NodeIds stay unclassified.

### Pipeline and defaults
- Opc.Ua.Core now references
`Microsoft.Extensions.Telemetry(.Abstractions)` 10.11.0, so every
project above Core uses the `Microsoft.Gen.Logging` generator.
Opc.Ua.Client already did, through Http.Resilience. The packages ship
net462 and netstandard2.0 builds, so net48 is supported.
- **A plain `ILoggerFactory` logs `(null)` in place of classified
values.** Classified values are only written by the redaction-aware
logger.
- The new `ILoggingBuilder.EnableOpcUaRedaction()` turns that logger on.
It is `EnableRedaction()` with the discriminator off; otherwise every
unredacted value gets `:ParamName` appended.
`AddOpcUa().AddLogging(...)` and `DefaultTelemetry.Create(...)` call it,
as do the samples, so they log the same information as before until
redactors are registered.
- `services.AddOpcUa()` alone does not change the host's logging.
Generic-host apps call `builder.Logging.EnableOpcUaRedaction()`. This is
documented as a behavior-breaking change in
`docs/migrate/2.0.x/telemetry.md`.

### Obsolete API
`Redact`, `RedactionWrapper<T>`, `IRedactionStrategy`,
`RedactionStrategies` and `SimpleRedactionStrategy` are `[Obsolete]`.
They still work, but the stack no longer uses them.

### Not classified
- The `Exception` passed to a log call. Applications can drop exception
messages with `LoggerEnrichmentOptions.IncludeExceptionMessage`.
- Exception and `ServiceResult` message texts.
- `ServiceResult`/`StatusCode` parameters and logged data values.
- The few log calls whose level is decided at run time.
- `Opc.Ua.Vision.OpenUsd`, which references only Opc.Ua.Types and cannot
see the attributes.

### Generator behavior differences
These are visible to log consumers and to tests:
- `IFormattable` values such as `NodeId` are stored in the structured
state as invariant strings, not as objects.
- The generated log state is a thread-local object that is cleared after
the call.
- Classified values are pooled redactor objects, valid only during the
`Log` call.

Tests that inspected log state after the fact now capture it during the
call. The shared `RecordingLoggerProvider` snapshots classified values.

## Related Issues
- Fixes #4857

## Types of changes
- [ ] Bugfix (non-breaking change which fixes an issue)
- [x] Enhancement (non-breaking change which adds functionality)
- [ ] Test enhancement (non-breaking change to increase test coverage)
- [x] Breaking change (fix or feature that would cause existing
functionality to not work as expected; plain logger factories drop
classified values)
- [x] Documentation Update (if none of the other choices apply)

## Checklist
- [x] I have read the CONTRIBUTING doc.
- [x] I have signed the CLA.
- [x] I ran tests locally with my changes, all passed.
- [x] I fixed all failing tests in the CI pipelines.
- [x] I fixed all introduced issues with CodeQL and LGTM.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added necessary documentation (if appropriate).
- [ ] Any dependent changes have been merged and published in downstream
modules.

## Further comments
New tests:
- `LogClassificationCoverageTests` (Tools.Tests) scans all 104 stack
assemblies and fails if an address, identity or path parameter of a
`[LoggerMessage]` method is unclassified.
- `DataClassificationLoggingTests` (Core.Tests) pins erasing and HMAC
redaction, pass-through, the plain-factory drop, and both stack
defaults.

Local results on net10.0:

| Suite | Passed | Failed |
|---|---|---|
| Core.Tests | 6,489 | 0 |
| Client.Tests | 3,231 | 0 |
| Tools.Tests | 7,210 | 0 |
| PubSub.Tests | 1,536 | 0 |
| Core.Diagnostics.Tests | 571 | 0 |
| Configuration.Tests | 241 | 0 |

- **Server.Tests:** the full run predates the final test fixes and had
12 failures. All 12 were in the fixtures fixed afterwards, which now
pass: RoleStateBinding (45/45) and the FileDirectory and
compatibility-logging fixtures (74/74 in total). The full Core.Tests and
Server.Tests runs were not repeated after those fixes.
- **Builds:** `UA.slnx` builds with 0 errors for net10.0 and net48, and
the whitespace check passes.
- **NativeAOT:** not confirmed locally, because the machine has no C++
linker. A trimmed publish of `Opc.Ua.Aot.Tests` reported no trim or AOT
warnings from the stack, the new packages or the generated logging code;
its only errors come from the TUnit test framework (`FSharp.Core` not
found). The `aot-test` CI job has to confirm the native build.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-10-21 09:47:07 +00:00
..

Robot Intent Viewer Client

This sample connects to IntentEnabledRobot, discovers its Robot Intent controller, prints the facets declared by its capabilities, obtains command authority, and maps OpenUSD target picks to Robot Intent linear moves. With --mcp on a supported target framework, it also hosts the Robot Intent MCP tools over the same OPC UA session. A submitted intent returns admission immediately; this client then waits on the returned operation and prints live ExecutionState, Progress, CurrentPose, and the terminal IntentResultDataType.

Connection and viewport trust boundaries

SampleSession checks the server certificate's hostname against the operator-supplied --server URL, including on reconnect. --insecure opts into trusting an otherwise untrusted certificate; it does not disable hostname validation or replace the requested identity with a discovery-advertised URL.

With --view, the fetched initial stage is prepared before live streaming starts. Live viewport updates forward attribute values, time samples and batches only. Server-controlled component references, payloads, instances and activation changes are not composed into the running native viewport, even when they name a local file or command bindings are enabled. This prevents a live component update from resolving an external asset with the viewer process's ambient Windows credentials. Trusted host-side composition and file-sink export remain available; preparing an initial stage still requires the operator to trust its content and asset resolver. An asset digest establishes integrity, not trust in that content's dependencies.

MCP hosting

With --mcp, the viewer client exposes the Robotics MCP profile so an LLM agent can discover controllers, read state, request authority, submit intents and missions, pause/resume/cancel/retry operations, and wait for operation completion while a human watches the same robot in the OpenUSD viewport. The MCP tools use the one SampleSession opened by the sample; the viewer, OpenUSD live bindings, and MCP tools therefore observe and command the same connected controller.

MCP hosting is compiled for net8.0, net9.0, and net10.0. It is unavailable on net48; running that leg with --mcp reports the limitation on stderr. Without --mcp, the sample continues to run on every target framework it builds for.

HTTP hosting binds to loopback and accepts only localhost, 127.0.0.1, or [::1] authorities on the selected port. A present Origin must also identify an approved local HTTP authority; native MCP clients without an Origin remain supported. These checks reject browser DNS rebinding before tool dispatch. They do not authenticate other local processes. Do not expose this sample through a remote proxy without adding caller authentication and authorization.

MCP options:

  • --mcp enables MCP hosting.
  • --transport stdio|http|sse selects the MCP transport. sse is accepted as an alias for http.
  • --port <number> selects the Streamable HTTP port. The default is 5100.

Transport selection is visible on stderr:

  • No --transport and no --view: stdio is selected by default.
  • No --transport with --view: HTTP is selected automatically and the client explains that MCP stdio uses stdout for protocol frames, which cannot safely coexist with the in-process OpenUSD viewer.
  • --transport http with or without --view: HTTP is used.
  • --transport stdio without --view: stdio is used.
  • --transport stdio --view: the explicit request is honored, but the client warns plainly that the viewer may share stdout and corrupt the MCP stdio protocol.

Example MCP client configuration for viewport mode:

{
  "servers": {
    "intent-viewer": {
      "url": "http://localhost:5100/mcp"
    }
  }
}

Run the sample with HTTP MCP and the viewport:

dotnet run --project samples\Robotics\IntentViewerClient\IntentViewerClient.csproj --framework net10.0 -- --server opc.tcp://localhost:62840/IntentEnabledRobot --insecure --view --mcp --transport http --port 5100 --fetch-assets .\artifacts\intent-viewer-stage

For stdio MCP, point the MCP client at the command instead:

{
  "servers": {
    "intent-viewer": {
      "command": "dotnet",
      "args": [
        "run",
        "--project",
        "samples\\Robotics\\IntentViewerClient\\IntentViewerClient.csproj",
        "--",
        "--server",
        "opc.tcp://localhost:62840/IntentEnabledRobot",
        "--insecure",
        "--mcp"
      ]
    }
  }
}

Agents can submit only what the server advertises and accepts. Refusals are returned to the agent as tool results and are never retried on the agent's behalf; the agent must decide whether to re-plan, ask for operator action, or stop.

Headless mode

Start the server, then run the client:

dotnet run --project samples\Robotics\IntentEnabledRobot\IntentEnabledRobot.csproj -- --insecure
'1' | dotnet run --project samples\Robotics\IntentViewerClient\IntentViewerClient.csproj -- --server opc.tcp://localhost:62840/IntentEnabledRobot --insecure

The client lists the target pucks published by the server's OpenUSD bindings. Enter the number for Bin, Fixture, Inspect, or Handoff; the same pick handler used by the viewport reads the mapped LocationType pose and submits a linear move.

Headless keyboard control is disabled when MCP stdio is active because both would read from standard input. Use --mcp --transport http if you want the headless menu and an MCP client at the same time.

--insecure is for localhost demos only. It accepts any server certificate for localhost demos; do not use it for production systems.

Viewport mode

On a machine with the optional viewer assembly and native OpenUSD renderer payload installed next to the sample output, run:

dotnet run --project samples\Robotics\IntentViewerClient\IntentViewerClient.csproj -- --server opc.tcp://localhost:62840/IntentEnabledRobot --insecure --view --fetch-assets .\artifacts\intent-viewer-stage --pick-mode Auto

Click one of the four target pucks at /World/Targets/Bin, /World/Targets/Fixture, /World/Targets/Inspect, or /World/Targets/Handoff. With --pick-mode Auto, the viewer uses renderer-backed pointer picking first: the OpenUSD viewer owns input handling, DPI scaling, physical-pixel conversion, stale-revision retry, and reports hits through the host callback. Misses do not submit intents. If renderer picking is unavailable or unsupported, Auto falls back to the command prim watcher: the host watches /World/IntentCommand and turns a written prim path into an intent. Use --pick-mode Renderer to require renderer-backed picks, or --pick-mode CommandPrim to use only the command-prim path. The renderer payload supports win-x64, linux-x64 and osx-arm64; where it is unavailable the sample falls back to headless mode and says why.

Refusals and cancellation

Refusals are normal method outputs: Accepted=false, an IntentFailureEnum, and a Message. The sample switches on the failure to show retry, re-plan, or escalate decisions. Safety failures such as SafetyStop and SafetyLimitExceeded are reported as safety-system refusals observed by the client, not overridden by it.

Press C while an intent is moving to call CancelIntent(ProcessStop). The output deliberately continues through Cancelling and waits for terminal Cancelled, demonstrating that cancel admission is not the end of robot motion.

Mission demo

Add --mission to submit a tiny two-step mission with a released base and unreleased horizon before interactive picking:

dotnet run --project samples\Robotics\IntentViewerClient\IntentViewerClient.csproj -- --server opc.tcp://localhost:62840/IntentEnabledRobot --insecure --mission

Agent workflow for pallet stacking

Use MCP when an LLM agent should drive the same robot a human watches:

dotnet run --project samples\Robotics\IntentEnabledRobot\IntentEnabledRobot.csproj -- --insecure
dotnet run --project samples\Robotics\IntentViewerClient\IntentViewerClient.csproj --framework net10.0 -- --server opc.tcp://localhost:62840/IntentEnabledRobot --insecure --view --mcp --transport http --port 5100

Configure the MCP client with http://localhost:5100/mcp, or omit --view --transport http and register the dotnet run ... -- --mcp command as a stdio MCP server.

Before commanding anything, the agent should read:

  • robotics_list_controllers
  • robotics_read_controller for SupportedIntents, SupportedFacets, Bin, Fixture, ParallelGripper, HeldPartPosition, HeldPartVisible and PayloadSlotNNFilled
  • robotics_read_state for Ready, mode, safety and command owner
  • robotics_request_control

The stacking scenario then exercises the full surface:

  1. provoke and handle a refusal, for example by submitting while another session owns control and observing IntentFailureEnum.ControlNotOwned;
  2. take command authority explicitly;
  3. submit direct pick, move and place intents;
  4. call robotics_wait_operation and robotics_list_operations while motion is active;
  5. pause, resume, cancel and retry as explicit tools, never as hidden client behaviour;
  6. compile a multi-step mission with robotics_submit_mission;
  7. inspect robotics_list_missions, update a mission horizon, and cancel a mission;
  8. verify payload outputs: HeldPartVisible, changed HeldPartPosition, and filled stack slots.

Agents must not retry a refusal blindly, assume authority, ignore SupportedIntents, or keep issuing commands after safety/mode/authority refusals without re-reading state and changing the plan.

Transcript-style sequence for one part:

agent -> robotics_list_controllers()
server -> [{ name: "UR5eIntentController", nodeId: "ns=...;s=..." }]

agent -> robotics_read_controller(controllerId)
server -> SupportedIntents includes Pick, Place, LinearMove, JointMove and Wait;
          SupportedFacets includes RI-Mission and RI-Mission-Horizon;
          locations include Bin and Fixture; outputs include PayloadSlot01Filled.

agent -> robotics_read_state(controllerId)
server -> Ready=true, OperationalMode=AutomaticExternal, ControlOwner=<other session>

agent -> robotics_submit_linear_move(controllerId, { intentId: "probe", target: ... })
server -> { accepted: false, failure: ControlNotOwned, message: "..." }

agent decision: this is an authority refusal, not a motion problem; do not retry the same call.

agent -> robotics_request_control(controllerId)
server -> { granted: true }

agent -> robotics_submit_pick(controllerId,
    { intentId: "pick-slot-01", source: "<Bin NodeId>", tool: "<ParallelGripper NodeId>" })
server -> { accepted: true, operation: "<IntentOperation NodeId>" }

agent -> robotics_wait_operation(controllerId, "pick-slot-01", operation, 2000)
server -> completed=true

agent -> robotics_submit_joint_move(controllerId,
    { intentId: "carry-slot-01", jointTargets: [0.1, -1.0, 1.5, -0.9, 0.75, 0.0] },
    axisCount: 6)
server -> { accepted: true, operation: "<IntentOperation NodeId>" }

agent -> robotics_wait_operation(controllerId, "carry-slot-01", operation, 2000)
server -> completed=true; HeldPartPosition has changed and HeldPartVisible is true.

agent -> robotics_submit_place(controllerId,
    { intentId: "place-slot-01", destination: "<Fixture NodeId>", tool: "<ParallelGripper NodeId>" })
server -> { accepted: true, operation: "<IntentOperation NodeId>" }

agent -> robotics_wait_operation(controllerId, "place-slot-01", operation, 2000)
server -> completed=true; PayloadSlot01Filled is true.