diff --git a/.github/workflows/Versioning.Checks.Job.yml b/.github/workflows/Versioning.Checks.Job.yml
index 125416c27..ce37b6246 100644
--- a/.github/workflows/Versioning.Checks.Job.yml
+++ b/.github/workflows/Versioning.Checks.Job.yml
@@ -59,6 +59,9 @@ jobs:
- name: Check exact contract generated artifacts and fixtures
run: node scripts/versioning/check-contract-codegen.js
+ - name: Check telemetry policy binding parity
+ run: node scripts/check-telemetry-policy-parity.js
+
- name: Check C# bindings codegen (regenerates and asserts entry points)
run: node scripts/check-dotnet-bindings-codegen.js
diff --git a/docs/telemetry/telemetry.md b/docs/telemetry/telemetry.md
index b9d0262cd..6d73cc00c 100644
--- a/docs/telemetry/telemetry.md
+++ b/docs/telemetry/telemetry.md
@@ -167,7 +167,7 @@ the one-shot path, a clean non-zero sandbox exit is not treated as an MXC error.
| Field | Type | Description |
|-------|------|-------------|
| `mxc.sandbox_kind` | string | Containment kind requested by the caller (`process`, `vm`, or a concrete backend name) |
-| `mxc.backend` | string | Containment backend name |
+| `mxc.backend` | string | Backend identifier; ProcessContainer reports `processcontainer`, not its runtime-selected tier |
| `mxc.exit_code` | int32 | Process exit code |
| `mxc.outcome` | string | `"success"` or `"failure"` |
| `mxc.duration_ms` | uint64 | Total execution time |
@@ -182,7 +182,7 @@ Emitted on execution errors.
| Field | Type | Description |
|-------|------|-------------|
| `mxc.sandbox_kind` | string | Containment kind requested by the caller (`process`, `vm`, or a concrete backend name) |
-| `mxc.backend` | string | Containment backend name |
+| `mxc.backend` | string | Backend identifier; ProcessContainer reports `processcontainer`, not its runtime-selected tier |
| `mxc.error_type` | string | Error category (`config_error`, `policy_error`, `process_error`, `timeout`, `init_error`, `internal_error`, `cancelled`, `unknown`) |
| `mxc.exit_code` | int32 | Process exit code |
| `mxc.phase` | string | State-aware lifecycle phase; empty for one-shot executions |
diff --git a/scripts/check-telemetry-policy-parity.js b/scripts/check-telemetry-policy-parity.js
new file mode 100644
index 000000000..1bf077a6b
--- /dev/null
+++ b/scripts/check-telemetry-policy-parity.js
@@ -0,0 +1,240 @@
+#!/usr/bin/env node
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+const { readFileSync } = require("fs");
+const { join } = require("path");
+
+const root = join(__dirname, "..");
+const protocol = readFileSync(
+ join(root, "src", "core", "wxc_common", "src", "telemetry", "consent_protocol.rs"),
+ "utf8"
+);
+const policy = readFileSync(
+ join(root, "src", "core", "wxc_common", "src", "telemetry", "policy.rs"),
+ "utf8"
+);
+const csharp = readFileSync(
+ join(root, "sdk", "dotnet", "Microsoft.Mxc.Sdk", "MxcTelemetry.cs"),
+ "utf8"
+);
+const rustSdk = readFileSync(
+ join(root, "src", "core", "mxc-sdk", "src", "telemetry.rs"),
+ "utf8"
+);
+const typescript = readFileSync(
+ join(root, "sdk", "node", "src", "telemetry.ts"),
+ "utf8"
+);
+const errors = [];
+
+function requiredMatch(source, expression, label) {
+ const match = source.match(expression);
+ if (!match) {
+ console.error(`ERROR: could not parse ${label}`);
+ process.exit(1);
+ }
+ return match;
+}
+
+function rustWireName(variant, rename) {
+ const kebab = variant.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
+ return rename === "camelCase"
+ ? kebab.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
+ : kebab;
+}
+
+function rustEnum(name) {
+ const match = requiredMatch(
+ protocol,
+ new RegExp(
+ `#\\[serde\\(rename_all = "([^"]+)"(?:,[^\\]]*)?\\)\\]\\s*` +
+ `pub\\(super\\)\\s+enum\\s+${name}\\s*\\{([\\s\\S]*?)\\n\\}`
+ ),
+ `Rust enum ${name}`
+ );
+ if (!["camelCase", "kebab-case"].includes(match[1])) {
+ console.error(`ERROR: unsupported serde rename '${match[1]}' on ${name}`);
+ process.exit(1);
+ }
+ return new Set(
+ [...match[2].matchAll(/^\s*([A-Z][A-Za-z0-9_]*)\s*,/gm)]
+ .map((item) => rustWireName(item[1], match[1]))
+ );
+}
+
+// The C# binding consumes the FFI, whose results come from the Rust SDK enum,
+// not from the executor's private CLI protocol.
+function rustSdkConsentResults() {
+ const body = requiredMatch(
+ rustSdk,
+ /impl ConsentActionResult \{[\s\S]*?match self \{([\s\S]*?)\n\s*\}/,
+ "Rust SDK ConsentActionResult::as_str"
+ )[1];
+ return new Set([...body.matchAll(/Self::\w+\s*=>\s*"([^"]+)"/g)].map((item) => item[1]));
+}
+
+function policyStates() {
+ const body = requiredMatch(
+ policy,
+ /fn as_str\(&self\)\s*->\s*&'static str\s*\{[\s\S]*?match self \{([\s\S]*?)\n\s*\}/,
+ "PolicyState::as_str"
+ )[1];
+ return new Map(
+ [...body.matchAll(/PolicyState::(\w+)\s*=>\s*"([^"]+)"/g)]
+ .map((item) => [item[2], item[1]])
+ );
+}
+
+function typescriptValues(name) {
+ const body = requiredMatch(
+ typescript,
+ new RegExp(`const ${name}\\s*=\\s*\\[([\\s\\S]*?)\\]\\s*as const`),
+ `TypeScript literal set ${name}`
+ )[1];
+ return new Set([...body.matchAll(/["']([^"']+)["']/g)].map((item) => item[1]));
+}
+
+function csharpSwitch(functionName, enumName) {
+ const body = requiredMatch(
+ csharp,
+ new RegExp(
+ `private\\s+static\\s+[\\w?<>]+\\s+${functionName}\\s*\\([^)]*\\)[\\s\\S]*?` +
+ `(?:=>\\s*value|return\\s+value\\.GetString\\(\\))\\s+switch\\s*\\{([\\s\\S]*?)\\};`
+ ),
+ `C# ${functionName}`
+ )[1];
+ return new Map(
+ [...body.matchAll(new RegExp(`"([^"]+)"\\s*=>\\s*${enumName}\\.(\\w+)`, "g"))]
+ .map((item) => [item[1], item[2]])
+ );
+}
+
+function csharpStatusReasons() {
+ const body = requiredMatch(
+ csharp,
+ /private\s+static\s+bool\s+IsKnownConsentStatusReason\s*\([^)]*\)[\s\S]*?return\s+value\.GetString\(\)\s+switch\s*\{([\s\S]*?)\};/,
+ "C# IsKnownConsentStatusReason"
+ )[1];
+ return new Set([...body.matchAll(/"([^"]+)"/g)].map((item) => item[1]));
+}
+
+function functionBody(name) {
+ const start = requiredMatch(
+ csharp,
+ new RegExp(`private\\s+static\\s+[\\w?<>]+\\s+${name}\\s*\\(`),
+ `C# ${name}`
+ ).index;
+ const end = csharp.indexOf("\n private static ", start + 1);
+ return csharp.slice(start, end < 0 ? undefined : end);
+}
+
+function compareSets(label, expected, actual) {
+ for (const value of expected) {
+ if (!actual.has(value)) errors.push(`${label} is missing '${value}'`);
+ }
+ for (const value of actual) {
+ if (!expected.has(value)) errors.push(`${label} has unexpected '${value}'`);
+ }
+}
+
+function expectedVariant(wire) {
+ return wire
+ .replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
+ .replace(/^./, (letter) => letter.toUpperCase());
+}
+
+function compareMappings(label, expected, actual) {
+ for (const wire of expected) {
+ const variant = actual.get(wire);
+ if (variant === undefined) {
+ errors.push(`${label} does not handle '${wire}'`);
+ } else if (variant !== expectedVariant(wire)) {
+ errors.push(`${label} maps '${wire}' to ${variant}`);
+ }
+ }
+ for (const wire of actual.keys()) {
+ if (!expected.has(wire)) errors.push(`${label} handles unexpected '${wire}'`);
+ }
+}
+
+const runtimePolicy = policyStates();
+const protocolPolicy = rustEnum("PolicyState");
+compareMappings("Rust PolicyState::as_str", protocolPolicy, runtimePolicy);
+compareSets("TypeScript policy", protocolPolicy, typescriptValues("TELEMETRY_POLICY_STATES"));
+compareMappings(
+ "C# ParsePolicyState",
+ protocolPolicy,
+ csharpSwitch("ParseKnownPolicyState", "TelemetryPolicyState")
+);
+
+const consentStates = rustEnum("ConsentState");
+compareSets("TypeScript consent state", consentStates, typescriptValues("TELEMETRY_CONSENT_STATES"));
+compareMappings(
+ "C# ParseConsentState",
+ consentStates,
+ csharpSwitch("ParseKnownConsentState", "TelemetryConsentState")
+);
+
+const bindingResults = rustSdkConsentResults();
+const protocolResults = rustEnum("ConsentResult");
+for (const value of bindingResults) {
+ if (!protocolResults.has(value)) {
+ errors.push(`Rust SDK consent result '${value}' is missing from the CLI protocol`);
+ }
+}
+const protocolOnlyResults = new Set(
+ [...protocolResults].filter((value) =>
+ value === "status" || value === "presentationRequired"
+ )
+);
+const terminalResults = new Set(
+ [...protocolResults].filter((value) => !protocolOnlyResults.has(value))
+);
+compareSets(
+ "TypeScript private protocol result",
+ protocolOnlyResults,
+ typescriptValues("CONSENT_PROTOCOL_ONLY_RESULTS")
+);
+compareSets(
+ "TypeScript consent result",
+ terminalResults,
+ typescriptValues("TELEMETRY_CONSENT_RESULTS")
+);
+compareMappings(
+ "C# ParseConsentActionResult",
+ bindingResults,
+ csharpSwitch("ParseConsentActionResult", "TelemetryConsentActionResult")
+);
+
+const statusReasons = rustEnum("StatusReason");
+compareSets(
+ "TypeScript private status reason",
+ statusReasons,
+ typescriptValues("CONSENT_STATUS_REASONS")
+);
+compareSets("C# private status reason", statusReasons, csharpStatusReasons());
+
+for (const [name, sentinel] of [
+ ["UnrecognizedConsentState", "return TelemetryConsentState.Undetermined;"],
+ ["UnrecognizedConsentActionResult", "return TelemetryConsentActionResult.Unknown;"],
+ ["ReportUnrecognizedConsentStatusReason", "return false;"],
+ ["UnrecognizedPolicyState", "return TelemetryPolicyState.Blocked;"],
+]) {
+ if (!functionBody(name).includes(sentinel)) {
+ errors.push(`C# ${name} must contain '${sentinel}'`);
+ }
+}
+
+if (errors.length > 0) {
+ console.error("ERROR: telemetry wire parity check failed:");
+ for (const error of errors) console.error(` - ${error}`);
+ process.exit(1);
+}
+
+console.log(
+ `Telemetry wire parity OK: ${protocolPolicy.size} policy states, ` +
+ `${consentStates.size} consent states, ${terminalResults.size} terminal results, ` +
+ `${protocolOnlyResults.size} private protocol results, ` +
+ `and ${statusReasons.size} private status reasons match`
+);
diff --git a/scripts/versioning/check-schema-versions.js b/scripts/versioning/check-schema-versions.js
index e78a1411b..dccd8cc32 100644
--- a/scripts/versioning/check-schema-versions.js
+++ b/scripts/versioning/check-schema-versions.js
@@ -115,6 +115,13 @@ expectConst(
/const WSLC_STATE_AWARE_VERSION\s*=\s*'([^']+)'/,
stateAwareWslc
);
+expectConst(
+ "state-aware-helper.ts",
+ stateAwareTs,
+ "TELEMETRY_STATE_AWARE_VERSION",
+ /const TELEMETRY_STATE_AWARE_VERSION\s*=\s*'([^']+)'/,
+ maxSupported
+);
// -- C# SDK (sdk/dotnet/Microsoft.Mxc.Sdk/SchemaVersions.cs) --
const schemaVersionsCs = read(
diff --git a/sdk/dotnet/CHANGELOG.md b/sdk/dotnet/CHANGELOG.md
index 23ad5c4d6..1312186c8 100644
--- a/sdk/dotnet/CHANGELOG.md
+++ b/sdk/dotnet/CHANGELOG.md
@@ -45,6 +45,8 @@ a 0.8.x patch**. Package versions are bumped in a dedicated release PR (see
call sites keep compiling. Because adding a parameter changes the emitted
method signature, assemblies compiled against 0.8.0 must be recompiled
rather than dropped in place.
+- Non-exec phases reject `StateAwareExecOptions` instead of ignoring exec-only
+ fields. Use `StateAwarePhaseOptions`.
### Deprecated
diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj
index 00aebe79a..aa63ff71a 100644
--- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj
+++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj
@@ -14,9 +14,9 @@
$(DefineConstants);MXC_WITH_ISOLATION_SESSION
$(MSBuildProjectDirectory)/../../../src
$(MxcSrcDir)/target/dotnet-test-support
- --release
- release
- debug
+
+ debug
test-support
$(MxcTestCargoFeatures) isolation_session
$(MxcTestCargoFeatures) wslc
@@ -52,7 +52,7 @@
$(MxcTestCargoTargetDir)/$(MxcTestRustHostTriple)/$(MxcTestCargoProfileDir)/$(MxcTestNativeFileName)
-
+
());
+ Assert.Equal(SchemaVersions.MaximumSupported, root["version"]?.GetValue());
+ Assert.Null(root["experimental"]);
+ }
+
+ [Fact]
+ public void BuildExecEnvelope_RelaysStableTelemetryWithoutCorrelationVector()
+ {
+ var options = new StateAwareExecOptions
+ {
+ Telemetry = new TelemetrySettings { Enabled = true },
+ };
+ var root = MxcLifecycle.BuildExecEnvelope(
+ new SandboxId("iso:abc"),
+ "echo hi",
+ options);
+
+ Assert.False(root.ContainsKey("correlationVector"));
+ Assert.True(root["telemetry"]?["enabled"]?.GetValue());
+ Assert.Equal(SchemaVersions.MaximumSupported, root["version"]?.GetValue());
+ Assert.Null(root["experimental"]);
+ }
+
+ [Fact]
+ public void ExecOptions_RemainAssignableButAreRejectedByNonExecPhases()
+ {
+ Assert.True(
+ typeof(StateAwarePhaseOptions).IsAssignableFrom(
+ typeof(StateAwareExecOptions)));
+ Assert.True(
+ typeof(StateAwarePhaseOptions).IsAssignableFrom(
+ typeof(WslcExecOptions)));
+
+ var id = new SandboxId("iso:abc");
+ var options = new StateAwareExecOptions { WorkingDirectory = "C:\\" };
+
+ foreach (var build in new Action[]
+ {
+ () => _ = MxcLifecycle.BuildStartEnvelope(id, options),
+ () => _ = MxcLifecycle.BuildStopEnvelope(id, options),
+ () => _ = MxcLifecycle.BuildDeprovisionEnvelope(id, options),
+ })
+ {
+ var ex = Assert.Throws(build);
+ Assert.Contains(nameof(StateAwarePhaseOptions), ex.Message, StringComparison.Ordinal);
+ }
+ }
+
+ [Fact]
+ public void BuildStartEnvelope_LeavesExplicitTelemetryVersionForNativeValidation()
+ {
+ var options = new StateAwarePhaseOptions
+ {
+ Version = SchemaVersions.LatestStable,
+ Telemetry = new TelemetrySettings { Enabled = false },
+ };
+
+ var root = MxcLifecycle.BuildStartEnvelope(new SandboxId("iso:abc"), options);
+ Assert.Equal(SchemaVersions.LatestStable, root["version"]?.GetValue());
+ Assert.False(root["telemetry"]?["enabled"]?.GetValue());
+ }
+
+ [Fact]
+ public void ExecInSandboxAsync_PreservesCancellationTokenAsThirdParameter()
+ {
+ static Task InvokeWithDefaultLiteral(SandboxId id, string command) =>
+ MxcLifecycle.ExecInSandboxAsync(id, command, default);
+
+ Assert.NotNull((Func>)InvokeWithDefaultLiteral);
+ }
+
+ [Fact]
+ public async Task RunBlockingOperationAsync_CancellationCleansUpLateResult()
+ {
+ using var releaseOperation = new ManualResetEventSlim();
+ var operationStarted = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var cleanedUp = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ using var cancellation = new CancellationTokenSource();
+
+ var task = MxcLifecycle.RunBlockingOperationAsync(
+ () =>
+ {
+ operationStarted.SetResult();
+ releaseOperation.Wait();
+ return 42;
+ },
+ cleanedUp.SetResult,
+ cancellation.Token);
+
+ await operationStarted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ cancellation.Cancel();
+ await Assert.ThrowsAnyAsync(() => task);
+
+ releaseOperation.Set();
+ Assert.Equal(42, await cleanedUp.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task RunBlockingOperationAsync_PreCancelledTokenDoesNotStartOperation()
+ {
+ var started = false;
+ using var cancellation = new CancellationTokenSource();
+ cancellation.Cancel();
+
+ await Assert.ThrowsAnyAsync(
+ () => MxcLifecycle.RunBlockingOperationAsync(
+ () =>
+ {
+ started = true;
+ return 42;
+ },
+ _ => { },
+ cancellation.Token));
+
+ Assert.False(started);
+ }
}
diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs
index c3c01cad9..29d1e79a0 100644
--- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs
+++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs
@@ -627,6 +627,78 @@ public void SandboxPolicy_OmitsCaptureDenialsWhenNotConfigured()
Assert.False(doc.RootElement.TryGetProperty("captureDenials", out _));
}
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void SandboxPolicy_TelemetrySerializesCanonicalNestedShape(bool enabled)
+ {
+ var policy = new SandboxPolicy
+ {
+ Version = "0.9.0-alpha",
+ Telemetry = new TelemetrySettings { Enabled = enabled },
+ };
+
+ var json = MxcSandbox.SerializePolicy(policy);
+
+ using var document = JsonDocument.Parse(json);
+ var root = document.RootElement;
+ Assert.Equal(enabled, root.GetProperty("telemetry").GetProperty("enabled").GetBoolean());
+ Assert.False(root.TryGetProperty("telemetryEnabled", out _));
+
+ var roundTrip = JsonSerializer.Deserialize(json);
+ Assert.NotNull(roundTrip);
+ Assert.Equal(enabled, roundTrip.Telemetry?.Enabled);
+ }
+
+ [Fact]
+ public void SandboxPolicy_LeavesTelemetryVersionForNativeValidation()
+ {
+ var policy = new SandboxPolicy
+ {
+ Version = "0.8.0-alpha",
+ Telemetry = new TelemetrySettings { Enabled = true },
+ };
+
+ using var policyDocument = JsonDocument.Parse(MxcSandbox.SerializePolicy(policy));
+ Assert.Equal("0.8.0-alpha", policyDocument.RootElement.GetProperty("version").GetString());
+ Assert.True(policyDocument.RootElement.GetProperty("telemetry").GetProperty("enabled").GetBoolean());
+
+ using var requestDocument = JsonDocument.Parse(
+ MxcSandbox.SerializeRequest(new SandboxRequest(policy, "echo hi")));
+ var requestPolicy = requestDocument.RootElement.GetProperty("policy");
+ Assert.Equal("0.8.0-alpha", requestPolicy.GetProperty("version").GetString());
+ Assert.True(requestPolicy.GetProperty("telemetry").GetProperty("enabled").GetBoolean());
+ }
+
+ [Fact]
+ public void SandboxPolicy_OmittedTelemetrySerializesNoTelemetryField()
+ {
+ var policy = new SandboxPolicy { Version = "0.8.0-alpha" };
+
+ using var document = JsonDocument.Parse(MxcSandbox.SerializePolicy(policy));
+ var root = document.RootElement;
+ Assert.False(root.TryGetProperty("telemetry", out _));
+ Assert.False(root.TryGetProperty("telemetryEnabled", out _));
+ }
+
+ [Fact]
+ public void SandboxPolicy_DefaultTelemetrySettingsSerializeDisabled()
+ {
+ var policy = new SandboxPolicy
+ {
+ Version = "0.9.0-alpha",
+ Telemetry = new TelemetrySettings(),
+ };
+
+ using var document = JsonDocument.Parse(MxcSandbox.SerializePolicy(policy));
+
+ Assert.False(
+ document.RootElement
+ .GetProperty("telemetry")
+ .GetProperty("enabled")
+ .GetBoolean());
+ }
+
[Fact]
public void SandboxPolicy_RoundTripsLegacyCaptureDenials()
{
diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs
index cfdb5aa48..121abc44a 100644
--- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs
+++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs
@@ -6,9 +6,7 @@
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Nodes;
-using System.Text.Json.Serialization;
-using System.Runtime.Versioning;
-using Microsoft.Win32;
+using Microsoft.Mxc.Sdk;
using Xunit;
namespace Microsoft.Mxc.Sdk.Tests;
@@ -19,32 +17,75 @@ public sealed class MxcTelemetryCollectionDefinition
{
}
+///
+/// Redirects the debug-build-only MXC_TEST_LOCALAPPDATA_OVERRIDE
+/// environment variable (read by wxc_common::telemetry::consent in
+/// place of the real LOCALAPPDATA — see that module for the security
+/// rationale; a release-profile native build compiles this override out
+/// entirely and always resolves the real per-user known-folder path) to a
+/// fresh temp directory for the lifetime of the fixture, so these tests
+/// never touch the real developer/CI-machine telemetry consent file, and
+/// restores the original value on dispose. xUnit runs the [Fact]s within a
+/// single class sequentially, while the MxcTelemetry collection keeps
+/// this class and MxcTelemetryTestsReleaseSafe from running in parallel.
+/// The constructor verifies the redirect took effect and fails
+/// the fixture loudly if it did not (see AssertStoreIsRedirected),
+/// rather than silently operating on the real per-user store when the native
+/// library under test happens to be a release build.
+///
[Collection("MxcTelemetry")]
-public sealed class MxcTelemetryTests
+public sealed class MxcTelemetryTests : IDisposable
{
- private static readonly JsonSerializerOptions PolicyJsonOptions = new()
- {
- DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
- Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
- };
+ private const string OverrideEnvVar = "MXC_TEST_LOCALAPPDATA_OVERRIDE";
+ private const string OverrideOwnerEnvVar = "MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID";
+ private const string PolicyOverrideEnvVar = "MXC_TEST_POLICY_KEY_OVERRIDE";
+ private const string PolicyOverrideOwnerEnvVar = "MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID";
+ private readonly string? _originalOverride;
+ private readonly string? _originalOverrideOwner;
+ private readonly string? _originalPolicyOverride;
+ private readonly string? _originalPolicyOverrideOwner;
+ private readonly string _tempDir;
+ private readonly string _policySubkey;
+
+ public MxcTelemetryTests()
+ {
+ _originalOverride = Environment.GetEnvironmentVariable(OverrideEnvVar);
+ _originalOverrideOwner = Environment.GetEnvironmentVariable(OverrideOwnerEnvVar);
+ _tempDir = Path.Combine(Path.GetTempPath(), $"mxc_dotnet_consent_test_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_tempDir);
+ Environment.SetEnvironmentVariable(OverrideEnvVar, _tempDir);
+ if (OperatingSystem.IsWindows())
+ {
+ Environment.SetEnvironmentVariable(
+ OverrideOwnerEnvVar,
+ GetParentProcessId().ToString(System.Globalization.CultureInfo.InvariantCulture));
+ }
+
+ // Redirect the administrative policy read to a throwaway HKCU key too,
+ // so these tests are unaffected by a real MXC telemetry policy on the
+ // build machine and can exercise the policy path without elevation.
+ _originalPolicyOverride = Environment.GetEnvironmentVariable(PolicyOverrideEnvVar);
+ _originalPolicyOverrideOwner = Environment.GetEnvironmentVariable(PolicyOverrideOwnerEnvVar);
+ _policySubkey = $@"Software\MxcTelemetryPolicyDotnetTest\{Guid.NewGuid():N}";
+ if (OperatingSystem.IsWindows())
+ {
+ Microsoft.Win32.Registry.CurrentUser.CreateSubKey(_policySubkey)?.Dispose();
+ }
+ Environment.SetEnvironmentVariable(PolicyOverrideEnvVar, _policySubkey);
+ Environment.SetEnvironmentVariable(
+ PolicyOverrideOwnerEnvVar,
+ Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ AssertStoreIsRedirected();
+ AssertPolicyKeyIsRedirected();
+ }
[Fact]
public void DefaultTelemetryValuesFailClosed()
{
- Assert.Equal(TelemetryConsentState.Undetermined, default);
- Assert.Equal(TelemetryPolicyState.Blocked, default);
- Assert.Equal(TelemetryConsentResult.Unknown, default);
-
- var status = new TelemetryConsentStatus();
- Assert.Equal(TelemetryConsentState.Undetermined, status.StoredState);
- Assert.Equal(TelemetryConsentState.Undetermined, status.EffectiveState);
- Assert.Equal(TelemetryPolicyState.Blocked, status.Policy);
-
- var outcome = new TelemetryConsentOutcome();
- Assert.Equal(TelemetryConsentResult.Unknown, outcome.Result);
- Assert.Equal(TelemetryConsentState.Undetermined, outcome.StoredState);
- Assert.Equal(TelemetryConsentState.Undetermined, outcome.EffectiveState);
- Assert.Equal(TelemetryPolicyState.Blocked, outcome.Policy);
+ Assert.Equal(TelemetryConsentState.Undetermined, default(TelemetryConsentState));
+ Assert.Equal(TelemetryPolicyState.Blocked, default(TelemetryPolicyState));
+ Assert.Equal(TelemetryConsentActionResult.Unknown, default(TelemetryConsentActionResult));
}
[Fact]
@@ -56,336 +97,261 @@ public void SandboxPolicy_TelemetrySerializesCanonically()
Telemetry = new TelemetrySettings { Enabled = true },
};
- var json = JsonSerializer.Serialize(policy, PolicyJsonOptions);
- using var doc = JsonDocument.Parse(json);
- var root = doc.RootElement;
+ var json = System.Text.Json.JsonSerializer.Serialize(policy);
+ using var doc = System.Text.Json.JsonDocument.Parse(json);
- Assert.True(root.GetProperty("telemetry").GetProperty("enabled").GetBoolean());
+ Assert.True(doc.RootElement.GetProperty("telemetry").GetProperty("enabled").GetBoolean());
}
- [Fact]
- public void GetPolicy_NeverThrowsAndFailsClosed()
- {
- var policy = MxcTelemetry.GetPolicy();
- Assert.True(Enum.IsDefined(policy));
- }
-
- [Fact]
- public void FailClosedDiagnostics_AreDeduplicatedAndDoNotExposeExceptionText()
+ ///
+ /// The policy counterpart to : prove
+ /// the native library honours MXC_TEST_POLICY_KEY_OVERRIDE before
+ /// any policy test runs. A release-profile mxc_ffi compiles the
+ /// override out and would silently read the machine's real
+ /// HKLM\SOFTWARE\Policies\Mxc key instead, making every
+ /// policy assertion below meaningless — passing or failing on the build
+ /// agent's administrative state rather than on the code under test.
+ ///
+ /// Two probes are used for the same reason as the consent check: a single
+ /// one could coincidentally match the real machine policy, but the real
+ /// policy cannot be both Blocked and Allowed.
+ ///
+ private void AssertPolicyKeyIsRedirected()
{
- var operation = $"TestGetPolicy{Guid.NewGuid():N}";
- using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting(
- new MxcTelemetry.FailureCategoryTracker(capacity: 64));
- using var output = new StringWriter();
- using var listener = new TextWriterTraceListener(output);
- var failure = new InvalidOperationException("sensitive\r\n\u001b[31mmessage");
- Trace.Listeners.Add(listener);
- try
- {
- MxcTelemetry.ReportFailClosed(operation, "Blocked", failure);
- MxcTelemetry.ReportFailClosed(operation, "Blocked", failure);
- MxcTelemetry.ReportFailClosed(operation, "false", ErrorCode.BackendError);
- Trace.Flush();
-
- var messages = output
- .ToString()
- .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)
- .Where(line => line.Contains(operation, StringComparison.Ordinal))
- .ToArray();
- Assert.Equal(2, messages.Length);
- Assert.Contains(typeof(InvalidOperationException).FullName!, messages[0]);
- Assert.Contains("HRESULT 0x", messages[0]);
- Assert.DoesNotContain("sensitive", messages[0]);
- Assert.DoesNotContain('\u001b', messages[0]);
- Assert.Contains("BackendError", messages[1]);
- }
- finally
+ if (!OperatingSystem.IsWindows())
{
- Trace.Listeners.Remove(listener);
+ // Off Windows GetPolicy short-circuits to NotApplicable without
+ // reading any registry, so there is nothing to redirect.
+ return;
}
- }
-
- [Fact]
- public void FailClosedDiagnosticTracker_DeduplicatesAndEnforcesCapacity()
- {
- var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 2);
- Assert.True(tracker.TryAdd("GetPolicy", "Blocked", "ExceptionA", 1));
- Assert.False(tracker.TryAdd("GetPolicy", "Blocked", "ExceptionA", 1));
- Assert.True(tracker.TryAdd("NeedsConsentPrompt", "false", "ErrorCode", 2));
- Assert.False(tracker.TryAdd("GetConsent", "Undetermined", "ExceptionB", 3));
- }
-
- [Fact]
- public void FailClosedDiagnosticTracker_DeduplicatesConcurrentReports()
- {
- var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 64);
- var accepted = 0;
-
- Parallel.For(0, 1_000, _ =>
+ foreach (var (value, expected) in new[]
+ {
+ (0, TelemetryPolicyState.Blocked),
+ (3, TelemetryPolicyState.Allowed),
+ })
{
- if (tracker.TryAdd("GetPolicy", "Blocked", "ExceptionA", 1))
+ SetPolicyValue(value);
+ var observed = MxcTelemetry.GetPolicy();
+ if (observed != expected)
{
- Interlocked.Increment(ref accepted);
+ throw new InvalidOperationException(
+ $"telemetry policy key is NOT redirected to 'HKCU\\{_policySubkey}': wrote " +
+ $"AllowTelemetry={value} there but GetPolicy() returned {observed}. These tests " +
+ "refuse to run against the real machine policy. The native mxc_ffi library under " +
+ "test is most likely a release build, which compiles out the " +
+ PolicyOverrideEnvVar + " override. Rebuild it with " +
+ "`cargo build -p mxc_ffi --features dotnetsdk,test-support` (debug) and re-run.");
}
- });
+ }
- Assert.Equal(1, accepted);
+ // Leave the fixture in the unmanaged default state.
+ SetPolicyValue(null);
}
- [Fact]
- public void FailClosedDiagnosticTracker_EnforcesCapacityUnderConcurrentLoad()
+ ///
+ /// Writes the administrative AllowTelemetry policy value into the
+ /// redirected key, or removes it when is null.
+ ///
+ private void SetPolicyValue(int? value)
{
- var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 64);
- var accepted = 0;
-
- Parallel.For(0, 1_000, index =>
+ if (!OperatingSystem.IsWindows())
{
- if (tracker.TryAdd("GetPolicy", "Blocked", "Exception", index))
- {
- Interlocked.Increment(ref accepted);
- }
- });
-
- Assert.Equal(64, accepted);
- }
+ return;
+ }
- [Fact]
- public void FailClosedDiagnostics_DoNotPropagateTraceListenerFailures()
- {
- var operation = $"ThrowingTrace{Guid.NewGuid():N}";
- using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting(
- new MxcTelemetry.FailureCategoryTracker(capacity: 64));
- using var listener = new ThrowingTraceListener();
- Trace.Listeners.Add(listener);
- try
+ using var key = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(_policySubkey)!;
+ if (value is null)
{
- var exception = Record.Exception(() =>
- MxcTelemetry.ReportFailClosed(
- operation,
- "Blocked",
- ErrorCode.BackendError));
- Assert.Null(exception);
+ key.DeleteValue("AllowTelemetry", throwOnMissingValue: false);
}
- finally
+ else
{
- Trace.Listeners.Remove(listener);
+ key.SetValue("AllowTelemetry", value.Value, Microsoft.Win32.RegistryValueKind.DWord);
}
+ }
- using var output = new StringWriter();
- using var capture = new TextWriterTraceListener(output);
- Trace.Listeners.Add(capture);
- try
- {
- MxcTelemetry.ReportFailClosed(operation, "Blocked", ErrorCode.BackendError);
- Trace.Flush();
- Assert.Contains(operation, output.ToString());
- }
- finally
+ ///
+ /// Writes AllowTelemetry as a REG_SZ rather than a
+ /// REG_DWORD — the mistake an administrator makes by typing the
+ /// value in by hand.
+ ///
+ private void SetPolicyStringValue(string value)
+ {
+ if (!OperatingSystem.IsWindows())
{
- Trace.Listeners.Remove(capture);
+ return;
}
+
+ using var key = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(_policySubkey)!;
+ key.SetValue("AllowTelemetry", value, Microsoft.Win32.RegistryValueKind.String);
}
- [Fact]
- public void ReadOnlyQueryFailures_ReportAndReturnFailClosedValues()
+ ///
+ /// Prove — without writing anything through the SDK — that the native
+ /// library under test actually honours MXC_TEST_LOCALAPPDATA_OVERRIDE.
+ /// A release-profile mxc_ffi compiles the override out, in which
+ /// case every test below would silently read and (worse) overwrite the
+ /// real per-user consent file. Two probes are used because a single one
+ /// could coincidentally match the real store's state; the real store
+ /// cannot be both granted and denied, so two matching reads prove the
+ /// redirect. The test seeds records directly and exercises only the SDK
+ /// read path.
+ ///
+ private void AssertStoreIsRedirected()
{
- var native = new FakeTelemetryQueryApi
- {
- GetConsentImpl = () => throw new DllNotFoundException("sensitive path"),
- NeedsConsentPromptImpl = () => new((int)ErrorCode.BackendError, true),
- GetPolicyImpl = () => throw new InvalidOperationException("sensitive policy"),
- };
- using var nativeScope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native);
- using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting(
- new MxcTelemetry.FailureCategoryTracker(capacity: 64));
- using var output = new StringWriter();
- using var listener = new TextWriterTraceListener(output);
- Trace.Listeners.Add(listener);
- try
+ if (!OperatingSystem.IsWindows())
{
- Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent());
- Assert.False(MxcTelemetry.NeedsConsentPrompt());
- Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy());
- Trace.Flush();
-
- var message = output.ToString();
- Assert.Contains("GetConsent", message);
- Assert.Contains("NeedsConsentPrompt", message);
- Assert.Contains("GetPolicy", message);
- Assert.DoesNotContain("sensitive", message);
+ // Off Windows GetConsent short-circuits to NotApplicable without
+ // touching any store, so there is nothing to redirect.
+ return;
}
- finally
+
+ foreach (var (value, expected) in new[]
+ {
+ ("granted", TelemetryConsentState.Granted),
+ ("denied", TelemetryConsentState.Denied),
+ })
{
- Trace.Listeners.Remove(listener);
+ WriteConsentRecord(value);
+ var observed = MxcTelemetry.GetConsent();
+ if (observed != expected)
+ {
+ throw new InvalidOperationException(
+ $"telemetry consent store is NOT redirected to '{_tempDir}': wrote '{value}' " +
+ $"there but GetConsent() returned {observed}. These tests refuse to run against " +
+ "the real per-user consent file. The native mxc_ffi library under test is most " +
+ "likely a release build, which compiles out the " + OverrideEnvVar + " override. " +
+ "Rebuild it with `cargo build -p mxc_ffi --features dotnetsdk,test-support` (debug) and re-run.");
+ }
}
+
+ File.Delete(ConsentFilePath());
}
- [Theory]
- [InlineData(true)]
- [InlineData(false)]
- public void ReadOnlyQueryFailures_CoverStatusAndExceptionPaths(bool throwException)
- {
- var native = new FakeTelemetryQueryApi
- {
- GetConsentImpl = () => throwException
- ? throw new InvalidOperationException("consent")
- : new((int)ErrorCode.BackendError, null),
- NeedsConsentPromptImpl = () => throwException
- ? throw new InvalidOperationException("prompt")
- : new((int)ErrorCode.BackendError, true),
- GetPolicyImpl = () => throwException
- ? throw new InvalidOperationException("policy")
- : new((int)ErrorCode.BackendError, null),
- };
- using var nativeScope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native);
- using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting(
- new MxcTelemetry.FailureCategoryTracker(capacity: 64));
- using var output = new StringWriter();
- using var listener = new TextWriterTraceListener(output);
- Trace.Listeners.Add(listener);
- try
+ private string ConsentFilePath() => Path.Combine(_tempDir, "mxc", "telemetry-consent.json");
+
+ private void WriteConsentRecord(string consent)
+ {
+ var path = ConsentFilePath();
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(
+ path,
+ $$"""{"schemaVersion":2,"consent":"{{consent}}","source":"test","promptedMxcVersion":"0.0.0","promptResourceVersion":3,"promptLocale":"en-US","updatedAtEpoch":0}""");
+ }
+
+ public void Dispose()
+ {
+ Environment.SetEnvironmentVariable(OverrideEnvVar, _originalOverride);
+ Environment.SetEnvironmentVariable(OverrideOwnerEnvVar, _originalOverrideOwner);
+ Environment.SetEnvironmentVariable(PolicyOverrideEnvVar, _originalPolicyOverride);
+ Environment.SetEnvironmentVariable(PolicyOverrideOwnerEnvVar, _originalPolicyOverrideOwner);
+ if (OperatingSystem.IsWindows())
{
- if (throwException)
+ try
{
- Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent());
+ Microsoft.Win32.Registry.CurrentUser.DeleteSubKeyTree(_policySubkey, throwOnMissingSubKey: false);
}
- else
+ catch (UnauthorizedAccessException)
{
- var exception = Assert.Throws(() => MxcTelemetry.GetConsent());
- Assert.Equal(ErrorCode.BackendError, exception.Code);
+ // Best-effort cleanup; not worth failing the test run over.
}
- Assert.False(MxcTelemetry.NeedsConsentPrompt());
- Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy());
- Trace.Flush();
-
- var messages = output
- .ToString()
- .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)
- .Where(line => line.Contains("mxc:", StringComparison.Ordinal))
- .ToArray();
- Assert.Equal(throwException ? 3 : 2, messages.Length);
- Assert.Contains(messages, line => line.Contains("NeedsConsentPrompt", StringComparison.Ordinal));
- Assert.Contains(messages, line => line.Contains("GetPolicy", StringComparison.Ordinal));
- Assert.All(
- messages,
- line => Assert.Contains(
- throwException
- ? typeof(InvalidOperationException).FullName!
- : nameof(ErrorCode.BackendError),
- line));
}
- finally
+ try
{
- Trace.Listeners.Remove(listener);
+ Directory.Delete(_tempDir, recursive: true);
+ }
+ catch (IOException)
+ {
+ // Best-effort cleanup; not worth failing the test run over.
}
}
[Fact]
- public void ReadOnlyQueryNullPayloads_ReportAndReturnFailClosedValues()
+ public void GetConsent_FreshStore_ReportsUndeterminedOnWindowsOrNotApplicableElsewhere()
{
- var native = new FakeTelemetryQueryApi
- {
- GetConsentImpl = () => new((int)ErrorCode.Success, null),
- NeedsConsentPromptImpl = () => new((int)ErrorCode.Success, false),
- GetPolicyImpl = () => new((int)ErrorCode.Success, null),
- };
- using var nativeScope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native);
- using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting(
- new MxcTelemetry.FailureCategoryTracker(capacity: 64));
- using var output = new StringWriter();
- using var listener = new TextWriterTraceListener(output);
- Trace.Listeners.Add(listener);
- try
- {
- Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent());
- Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy());
- Trace.Flush();
-
- var messages = output
- .ToString()
- .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)
- .Where(line => line.Contains("mxc:", StringComparison.Ordinal))
- .ToArray();
- Assert.Equal(2, messages.Length);
- Assert.Contains(messages, line => line.Contains("GetConsent", StringComparison.Ordinal));
- Assert.Contains(messages, line => line.Contains("GetPolicy", StringComparison.Ordinal));
- Assert.All(
- messages,
- line => Assert.Contains(typeof(InvalidOperationException).FullName!, line));
- Assert.DoesNotContain("payload was missing", output.ToString());
- }
- finally
- {
- Trace.Listeners.Remove(listener);
- }
+ var state = MxcTelemetry.GetConsent();
+ var expected = OperatingSystem.IsWindows()
+ ? TelemetryConsentState.Undetermined
+ : TelemetryConsentState.NotApplicable;
+ Assert.Equal(expected, state);
}
[Fact]
- public void TelemetryQueryApiOverride_AllowsConcurrentDispose()
+ public void RequestConsent_ThenWithdraw_RoundTrips_OnWindows()
{
- var calls = 0;
- var native = new FakeTelemetryQueryApi
+ if (!OperatingSystem.IsWindows())
{
- GetConsentImpl = () => new((int)ErrorCode.Success, "undetermined"),
- NeedsConsentPromptImpl = () => new((int)ErrorCode.Success, false),
- GetPolicyImpl = () =>
- {
- Interlocked.Increment(ref calls);
- return new((int)ErrorCode.Success, "blocked");
- },
- };
- var scope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native);
+ return;
+ }
- Parallel.Invoke(scope.Dispose, scope.Dispose);
+ var outcome = MxcTelemetry.RequestConsent(prompt =>
+ {
+ Assert.Equal(3u, prompt.ResourceVersion);
+ Assert.Equal("en-US", prompt.Locale);
+ Assert.Equal("Help improve Microsoft Products", prompt.Title.Text);
+ Assert.Equal(
+ """
+ Help improve MXC and other Microsoft product including Windows by sharing optional diagnostic data with Microsoft.
+
+ If enabled, MXC sends diagnostic information about product usage, performance, and reliability. MXC does not send your commands, file paths, credentials, or other customer content.
+ """.ReplaceLineEndings("\n"),
+ prompt.Body.Text);
+ Assert.Equal("Yes", prompt.AffirmativeLabel.Text);
+ Assert.Equal("No", prompt.NegativeLabel.Text);
+ Assert.Equal("Privacy Statement", prompt.LearnMoreLabel.Text);
+ Assert.Equal("https://go.microsoft.com/fwlink/?linkid=521839", prompt.LearnMoreUrl);
+ return TelemetryConsentDecision.Yes;
+ });
+ Assert.Equal(TelemetryConsentActionResult.Granted, outcome.Result);
+ Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsent());
+
+ var withdrawal = MxcTelemetry.WithdrawConsent();
+ Assert.Equal(TelemetryConsentActionResult.Withdrawn, withdrawal.Result);
+ Assert.Equal(TelemetryConsentState.Denied, MxcTelemetry.GetConsent());
+ }
- _ = MxcTelemetry.GetPolicy();
- Assert.Equal(0, calls);
+ [Fact]
+ public void NeedsConsentPrompt_FreshStore_IsTrueOnWindowsAndFalseElsewhere()
+ {
+ // Off Windows MXC collects nothing, so a host must never be told to
+ // ask — prompting there would be a privacy defect, not just noise.
+ Assert.Equal(OperatingSystem.IsWindows(), MxcTelemetry.NeedsConsentPrompt());
}
[Fact]
- public void TestOverrides_RejectOverlapAndRestoreAfterConcurrentDispose()
+ public void NeedsConsentPrompt_AfterAnyDecision_IsFalse_OnWindows()
{
- var native = new FakeTelemetryQueryApi
+ if (!OperatingSystem.IsWindows())
{
- GetConsentImpl = () => new((int)ErrorCode.Success, "undetermined"),
- NeedsConsentPromptImpl = () => new((int)ErrorCode.Success, false),
- GetPolicyImpl = () => new((int)ErrorCode.Success, "blocked"),
- };
- using var nativeScope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native);
- Assert.Throws(
- () => MxcTelemetry.OverrideTelemetryReadApiForTesting(native));
- Parallel.Invoke(nativeScope.Dispose, nativeScope.Dispose);
+ return;
+ }
- var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 64);
- using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting(tracker);
- Assert.Throws(
- () => MxcTelemetry.OverrideFailureCategoryTrackerForTesting(tracker));
- Parallel.Invoke(trackerScope.Dispose, trackerScope.Dispose);
+ var denied = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.No);
+ Assert.Equal(TelemetryConsentActionResult.Denied, denied.Result);
+ Assert.False(MxcTelemetry.NeedsConsentPrompt());
- using var restoredNativeScope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native);
- using var restoredTrackerScope =
- MxcTelemetry.OverrideFailureCategoryTrackerForTesting(tracker);
+ var granted = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes);
+ Assert.Equal(TelemetryConsentActionResult.Granted, granted.Result);
+ Assert.False(MxcTelemetry.NeedsConsentPrompt());
}
[Fact]
- public void RequestConsent_IsNotApplicableWithoutInvokingPresenter_OffWindows()
+ public void RequestConsent_NonWindows_IsNotApplicableAndDoesNotPresent()
{
if (OperatingSystem.IsWindows())
{
return;
}
- var called = false;
+ var presented = false;
var outcome = MxcTelemetry.RequestConsent(_ =>
{
- called = true;
+ presented = true;
return TelemetryConsentDecision.Yes;
});
-
- Assert.False(called);
- Assert.Equal(TelemetryConsentResult.NotApplicable, outcome.Result);
- Assert.Equal(TelemetryPolicyState.NotApplicable, outcome.Policy);
+ Assert.False(presented);
+ Assert.Equal(TelemetryConsentActionResult.NotApplicable, outcome.Result);
}
[Fact]
@@ -406,7 +372,7 @@ public void RequestConsentAsync_RejectsLocaleWithEmbeddedNulSynchronously()
() =>
{
_ = MxcTelemetry.RequestConsentAsync(
- _ => Task.FromResult(TelemetryConsentDecision.Yes),
+ _ => ValueTask.FromResult(TelemetryConsentDecision.Yes),
"en-US\0ignored",
TestContext.Current.CancellationToken);
});
@@ -456,19 +422,135 @@ public void ParseConsentPrompt_RejectsNullRequiredStrings(string path)
Assert.Contains(path, error.Message, StringComparison.Ordinal);
}
-#if DEBUG
[Fact]
- public void RequestConsent_PresenterExceptionReturnsBackendError_OnWindows()
+ public void GetPolicy_NoPolicyConfigured_IsUnrestrictedOnWindowsAndNotApplicableElsewhere()
+ {
+ SetPolicyValue(null);
+ var expected = OperatingSystem.IsWindows()
+ ? TelemetryPolicyState.Unrestricted
+ : TelemetryPolicyState.NotApplicable;
+ Assert.Equal(expected, MxcTelemetry.GetPolicy());
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(42)]
+ [InlineData(-1)]
+ public void GetPolicy_AnyValueOtherThanOptional_IsBlocked_OnWindows(int value)
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ // MXC's data is product-and-service-usage (optional) diagnostic data,
+ // so only level 3 permits it. Unrecognised values fail closed rather
+ // than being treated as "no policy".
+ SetPolicyValue(value);
+ Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy());
+ }
+
+ [Fact]
+ public void GetPolicy_Optional_IsAllowed_OnWindows()
{
if (!OperatingSystem.IsWindows())
{
return;
}
- using var _ = new TelemetryTestEnv();
- var ex = Assert.Throws(() =>
- MxcTelemetry.RequestConsent(_ => throw new InvalidOperationException("boom")));
- Assert.Equal(ErrorCode.BackendError, ex.Code);
+ SetPolicyValue(3);
+ Assert.Equal(TelemetryPolicyState.Allowed, MxcTelemetry.GetPolicy());
+ }
+
+ ///
+ /// An administrator who sets AllowTelemetry as a string instead of a
+ /// DWORD has still expressed an intent to manage this machine. The value
+ /// cannot be evaluated, so it must fail closed to Blocked — never be read
+ /// as an unmanaged machine, which would let a prior consent grant
+ /// re-enable the collection the administrator meant to stop.
+ ///
+ [Theory]
+ [InlineData("0")]
+ [InlineData("3")]
+ [InlineData("")]
+ [InlineData("not-a-number")]
+ public void GetPolicy_WrongValueType_IsBlockedNotUnrestricted_OnWindows(string value)
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ SetPolicyStringValue(value);
+ Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy());
+ }
+
+ [Fact]
+ public void GetPolicy_IsNeverAGrant_ConsentIsStillRequired_OnWindows()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ // An administrator permitting telemetry must not stand in for the
+ // user's own decision: the prompt is still owed.
+ SetPolicyValue(3);
+ Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent());
+ Assert.True(MxcTelemetry.NeedsConsentPrompt());
+ }
+
+ [Fact]
+ public void GetPolicy_BlockedSuppressesTheConsentPrompt_OnWindows()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ // There is no point asking a user for permission an administrator has
+ // already refused, but the recorded consent state is left untouched so
+ // relaxing the policy later restores the user's real choice.
+ SetPolicyValue(0);
+ Assert.False(MxcTelemetry.NeedsConsentPrompt());
+ Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent());
+
+ var presented = false;
+ var blocked = MxcTelemetry.RequestConsent(_ =>
+ {
+ presented = true;
+ return TelemetryConsentDecision.Yes;
+ });
+ Assert.False(presented);
+ Assert.Equal(TelemetryConsentActionResult.PolicyBlocked, blocked.Result);
+ Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent());
+ Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy());
+
+ SetPolicyValue(null);
+ Assert.Equal(TelemetryPolicyState.Unrestricted, MxcTelemetry.GetPolicy());
+ var granted = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes);
+ Assert.Equal(TelemetryConsentActionResult.Granted, granted.Result);
+ Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsent());
+ }
+
+ [Fact]
+ public async Task RequestConsentAsync_PersistsPresenterDecision_OnWindows()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ var outcome = await MxcTelemetry.RequestConsentAsync(
+ prompt => ValueTask.FromResult(
+ prompt.Locale == "en-US"
+ ? TelemetryConsentDecision.Yes
+ : TelemetryConsentDecision.Dismissed),
+ cancellationToken: TestContext.Current.CancellationToken);
+ Assert.Equal(TelemetryConsentActionResult.Granted, outcome.Result);
+ Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsent());
}
[Fact]
@@ -479,7 +561,6 @@ public async Task RequestConsentAsync_PreservesCallerSynchronizationContext_OnWi
return;
}
- using var _ = new TelemetryTestEnv();
using var context = new PumpSynchronizationContext();
var previousContext = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(context);
@@ -497,7 +578,7 @@ public async Task RequestConsentAsync_PreservesCallerSynchronizationContext_OnWi
}, cancellationToken: TestContext.Current.CancellationToken);
context.RunUntilCompleted(request);
- Assert.Equal(TelemetryConsentResult.Granted, (await request).Result);
+ Assert.Equal(TelemetryConsentActionResult.Granted, (await request).Result);
}
finally
{
@@ -513,7 +594,6 @@ public async Task RequestConsentAsync_CancellationAfterPresentationStartsDoesNot
return;
}
- using var _ = new TelemetryTestEnv();
using var cancellation = new CancellationTokenSource();
var presenterStarted = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
@@ -523,7 +603,7 @@ public async Task RequestConsentAsync_CancellationAfterPresentationStartsDoesNot
var request = MxcTelemetry.RequestConsentAsync(_ =>
{
presenterStarted.SetResult();
- return stalledPresenter.Task;
+ return new ValueTask(stalledPresenter.Task);
}, cancellationToken: cancellation.Token);
await presenterStarted.Task.WaitAsync(TestContext.Current.CancellationToken);
@@ -534,6 +614,7 @@ public async Task RequestConsentAsync_CancellationAfterPresentationStartsDoesNot
Task.Delay(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken));
Assert.Same(request, finished);
await Assert.ThrowsAnyAsync(() => request);
+ Assert.True(request.IsCanceled);
Assert.Equal(
TelemetryConsentState.Undetermined,
MxcTelemetry.GetConsentStatus().StoredState);
@@ -547,7 +628,6 @@ public async Task RequestConsentAsync_ReportsPresenterFailureAfterCancellation_O
return;
}
- using var _ = new TelemetryTestEnv();
using var cancellation = new CancellationTokenSource();
using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting(
new MxcTelemetry.FailureCategoryTracker(capacity: 64));
@@ -564,12 +644,13 @@ public async Task RequestConsentAsync_ReportsPresenterFailureAfterCancellation_O
var request = MxcTelemetry.RequestConsentAsync(_ =>
{
presenterStarted.SetResult();
- return presenter.Task;
+ return new ValueTask(presenter.Task);
}, cancellationToken: cancellation.Token);
await presenterStarted.Task.WaitAsync(TestContext.Current.CancellationToken);
cancellation.Cancel();
await Assert.ThrowsAnyAsync(() => request);
+ Assert.True(request.IsCanceled);
presenter.SetException(new InvalidOperationException("late presenter failure"));
var diagnostic = await listener.Message.WaitAsync(TestContext.Current.CancellationToken);
@@ -594,12 +675,11 @@ public async Task RequestConsentAsync_ExplicitDismissalCompletesWithoutCancellat
return;
}
- using var _ = new TelemetryTestEnv();
var outcome = await MxcTelemetry.RequestConsentAsync(
- _ => Task.FromResult(TelemetryConsentDecision.Dismissed),
+ _ => ValueTask.FromResult(TelemetryConsentDecision.Dismissed),
cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(TelemetryConsentResult.Dismissed, outcome.Result);
+ Assert.Equal(TelemetryConsentActionResult.Dismissed, outcome.Result);
Assert.Equal(TelemetryConsentState.Undetermined, outcome.StoredState);
}
@@ -611,7 +691,6 @@ public async Task RequestConsentAsync_SynchronousPresenterCancellationDoesNotPer
return;
}
- using var _ = new TelemetryTestEnv();
using var cancellation = new CancellationTokenSource();
var previousContext = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(null);
@@ -621,10 +700,11 @@ public async Task RequestConsentAsync_SynchronousPresenterCancellationDoesNotPer
{
cancellation.Cancel();
cancellation.Token.ThrowIfCancellationRequested();
- return Task.FromResult(TelemetryConsentDecision.Yes);
+ return ValueTask.FromResult(TelemetryConsentDecision.Yes);
}, cancellationToken: cancellation.Token);
await Assert.ThrowsAnyAsync(() => request);
+ Assert.True(request.IsCanceled);
Assert.Equal(
TelemetryConsentState.Undetermined,
MxcTelemetry.GetConsentStatus().StoredState);
@@ -640,12 +720,11 @@ public void FinalizeAsyncOutcome_PersistedDecisionWinsConcurrentCancellation()
{
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
- var outcome = new TelemetryConsentOutcome
- {
- Result = TelemetryConsentResult.Granted,
- StoredState = TelemetryConsentState.Granted,
- EffectiveState = TelemetryConsentState.Granted,
- };
+ var outcome = new TelemetryConsentOutcome(
+ TelemetryConsentActionResult.Granted,
+ TelemetryConsentState.Granted,
+ TelemetryConsentState.Granted,
+ TelemetryPolicyState.Unrestricted);
Assert.Same(outcome, MxcTelemetry.FinalizeAsyncOutcome(outcome, cancellation.Token));
}
@@ -655,155 +734,108 @@ public void FinalizeAsyncOutcome_CanceledDismissalThrows()
{
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
- var outcome = new TelemetryConsentOutcome
- {
- Result = TelemetryConsentResult.Dismissed,
- StoredState = TelemetryConsentState.Undetermined,
- EffectiveState = TelemetryConsentState.Undetermined,
- };
+ var outcome = new TelemetryConsentOutcome(
+ TelemetryConsentActionResult.Dismissed,
+ TelemetryConsentState.Undetermined,
+ TelemetryConsentState.Undetermined,
+ TelemetryPolicyState.Unrestricted);
Assert.ThrowsAny(
() => MxcTelemetry.FinalizeAsyncOutcome(outcome, cancellation.Token));
}
+ [Theory]
+ [InlineData(typeof(DllNotFoundException))]
+ [InlineData(typeof(EntryPointNotFoundException))]
+ [InlineData(typeof(TypeInitializationException))]
+ [InlineData(typeof(BadImageFormatException))]
+ public void NativeLoadFailures_AreTreatedAsFailClosed(Type exceptionType)
+ {
+ // These are the failures GetConsent must swallow into Undetermined
+ // rather than throwing out of a read-only status query.
+ // EntryPointNotFoundException in particular covers an mxc_ffi that
+ // loads but predates the consent entry points.
+ var ex = exceptionType == typeof(TypeInitializationException)
+ ? new TypeInitializationException("Microsoft.Mxc.Sdk.Native.NativeMethods", null)
+ : (Exception)Activator.CreateInstance(exceptionType)!;
+ Assert.True(MxcTelemetry.IsNativeLoadFailure(ex));
+ }
+
[Fact]
- public void ConsentLifecycle_RoundTripsThroughNativeApi_OnWindows()
+ public void GenuineNativeFailures_AreNotSwallowed()
{
- if (!OperatingSystem.IsWindows())
- {
- return;
- }
-
- using var _ = new TelemetryTestEnv();
- Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent());
- Assert.True(MxcTelemetry.NeedsConsentPrompt());
-
- var dismissed = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Dismissed);
- Assert.Equal(TelemetryConsentResult.Dismissed, dismissed.Result);
- Assert.Equal(TelemetryConsentState.Undetermined, dismissed.StoredState);
-
- var granted = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes);
- Assert.Equal(TelemetryConsentResult.Granted, granted.Result);
- Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsent());
- Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsentStatus().EffectiveState);
- Assert.False(MxcTelemetry.NeedsConsentPrompt());
-
- var withdrawn = MxcTelemetry.WithdrawConsent();
- Assert.Equal(TelemetryConsentResult.Withdrawn, withdrawn.Result);
- Assert.Equal(TelemetryConsentState.Denied, MxcTelemetry.GetConsent());
- Assert.False(MxcTelemetry.NeedsConsentPrompt());
+ // A real failure reported by the native layer must not be
+ // misclassified as "library missing" and silently downgraded.
+ Assert.False(MxcTelemetry.IsNativeLoadFailure(
+ new MxcException(ErrorCode.ConsentWriteFailed, "boom")));
+ Assert.False(MxcTelemetry.IsNativeLoadFailure(new InvalidOperationException()));
}
- [SupportedOSPlatform("windows")]
- private sealed class TelemetryTestEnv : IDisposable
+ [Fact]
+ public void ReadOnlyQueries_NeverThrow()
{
- private static readonly SemaphoreSlim Gate = new(1, 1);
+ // Read-only status queries must fail closed without throwing.
+ var prompt = Record.Exception(() => MxcTelemetry.NeedsConsentPrompt());
+ Assert.Null(prompt);
- private readonly string? _originalLocalAppData;
- private readonly string? _originalLocalAppDataOwnerPid;
- private readonly string? _originalPolicyKey;
- private readonly string? _originalPolicyOwnerPid;
- private readonly string _storeDir;
- private readonly string _policySubkey;
-
- public TelemetryTestEnv()
- {
- Gate.Wait();
- try
- {
- _originalLocalAppData = Environment.GetEnvironmentVariable("MXC_TEST_LOCALAPPDATA_OVERRIDE");
- _originalLocalAppDataOwnerPid = Environment.GetEnvironmentVariable(
- "MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID");
- _originalPolicyKey = Environment.GetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE");
- _originalPolicyOwnerPid = Environment.GetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID");
-
- _storeDir = Directory.CreateTempSubdirectory("mxc-dotnet-telemetry-").FullName;
- _policySubkey = $@"Software\MxcTelemetryDotNetTests\{Guid.NewGuid():N}";
-
- Registry.CurrentUser.CreateSubKey(_policySubkey)?.Dispose();
- Environment.SetEnvironmentVariable("MXC_TEST_LOCALAPPDATA_OVERRIDE", _storeDir);
- Environment.SetEnvironmentVariable(
- "MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID",
- GetParentProcessId().ToString());
- Environment.SetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE", _policySubkey);
- Environment.SetEnvironmentVariable(
- "MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID",
- Environment.ProcessId.ToString());
- }
- catch
- {
- Gate.Release();
- throw;
- }
- }
-
- public void Dispose()
- {
- Environment.SetEnvironmentVariable("MXC_TEST_LOCALAPPDATA_OVERRIDE", _originalLocalAppData);
- Environment.SetEnvironmentVariable(
- "MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID",
- _originalLocalAppDataOwnerPid);
- Environment.SetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE", _originalPolicyKey);
- Environment.SetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID", _originalPolicyOwnerPid);
+ var policy = Record.Exception(() => MxcTelemetry.GetPolicy());
+ Assert.Null(policy);
- try
- {
- if (Directory.Exists(_storeDir))
- {
- Directory.Delete(_storeDir, recursive: true);
- }
- }
- catch
- {
- }
-
- try
- {
- Registry.CurrentUser.DeleteSubKeyTree(_policySubkey, throwOnMissingSubKey: false);
- }
- catch
- {
- }
+ var status = Record.Exception(() => MxcTelemetry.GetConsentStatus());
+ Assert.Null(status);
+ }
- Gate.Release();
- }
+ [Fact]
+ public void MxcException_PreservesTheUnderlyingCause()
+ {
+ // The read/write paths convert unexpected exceptions to MxcException so
+ // a raw type cannot escape a documented contract; that conversion must
+ // not lose the original, which is the only thing that explains why a
+ // broken install failed.
+ var cause = new DllNotFoundException("mxc_ffi not found");
+ var ex = new MxcException(ErrorCode.ConsentWriteFailed, "could not persist", cause);
+
+ Assert.Same(cause, ex.InnerException);
+ Assert.Equal(ErrorCode.ConsentWriteFailed, ex.Code);
+ Assert.Contains("could not persist", ex.ToString(), StringComparison.Ordinal);
+ Assert.Contains("mxc_ffi not found", ex.ToString(), StringComparison.Ordinal);
+ }
- private static int GetParentProcessId()
+ private static int GetParentProcessId()
+ {
+ using var process = Process.GetCurrentProcess();
+ var status = NtQueryInformationProcess(
+ process.Handle,
+ processInformationClass: 0,
+ out var processInformation,
+ Marshal.SizeOf(),
+ out _);
+ if (status != 0)
{
- using var process = Process.GetCurrentProcess();
- var status = NtQueryInformationProcess(
- process.Handle,
- processInformationClass: 0,
- out var processInformation,
- Marshal.SizeOf(),
- out _);
- if (status != 0)
- {
- throw new InvalidOperationException(
- $"NtQueryInformationProcess failed with NTSTATUS 0x{status:X8}");
- }
-
- return checked((int)processInformation.InheritedFromUniqueProcessId);
+ throw new InvalidOperationException(
+ $"NtQueryInformationProcess failed with NTSTATUS 0x{status:X8}");
}
- [DllImport("ntdll.dll", ExactSpelling = true)]
- private static extern int NtQueryInformationProcess(
- IntPtr processHandle,
- int processInformationClass,
- out ProcessBasicInformation processInformation,
- int processInformationLength,
- out int returnLength);
+ return checked((int)processInformation.InheritedFromUniqueProcessId);
+ }
- [StructLayout(LayoutKind.Sequential)]
- private struct ProcessBasicInformation
- {
- internal IntPtr Reserved1;
- internal IntPtr PebBaseAddress;
- internal IntPtr Reserved2_0;
- internal IntPtr Reserved2_1;
- internal IntPtr UniqueProcessId;
- internal IntPtr InheritedFromUniqueProcessId;
- }
+ [DllImport("ntdll.dll", ExactSpelling = true)]
+ private static extern int NtQueryInformationProcess(
+ IntPtr processHandle,
+ int processInformationClass,
+ out ProcessBasicInformation processInformation,
+ int processInformationLength,
+ out int returnLength);
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct ProcessBasicInformation
+ {
+ internal IntPtr Reserved1;
+ internal IntPtr PebBaseAddress;
+ internal IntPtr Reserved2_0;
+ internal IntPtr Reserved2_1;
+ internal IntPtr UniqueProcessId;
+ internal IntPtr InheritedFromUniqueProcessId;
}
private sealed class PumpSynchronizationContext : SynchronizationContext, IDisposable
@@ -862,17 +894,7 @@ public override void TraceEvent(
{
Capture(
eventType,
- args is null || args.Length == 0
- ? format
- : string.Format(format ?? string.Empty, args));
- }
-
- public override void Write(string? message)
- {
- }
-
- public override void WriteLine(string? message)
- {
+ args is null ? format : string.Format(format ?? string.Empty, args));
}
private void Capture(TraceEventType eventType, string? message)
@@ -883,38 +905,13 @@ private void Capture(TraceEventType eventType, string? message)
_message.TrySetResult(message);
}
}
- }
-#endif
-
- private sealed class FakeTelemetryQueryApi : MxcTelemetry.ITelemetryReadApi
- {
- internal required Func GetConsentImpl { get; init; }
- internal required Func NeedsConsentPromptImpl { get; init; }
- internal required Func GetPolicyImpl { get; init; }
- public MxcTelemetry.NativePayloadResult GetConsent() => GetConsentImpl();
-
- public MxcTelemetry.NativeBooleanResult NeedsConsentPrompt() =>
- NeedsConsentPromptImpl();
-
- public MxcTelemetry.NativePayloadResult GetPolicy() => GetPolicyImpl();
- }
-
- private sealed class ThrowingTraceListener : TraceListener
- {
- public override void TraceEvent(
- TraceEventCache? eventCache,
- string? source,
- TraceEventType eventType,
- int id,
- string? format,
- params object?[]? args) =>
- throw new InvalidOperationException("listener failure");
-
- public override void Write(string? message) =>
- throw new InvalidOperationException("listener failure");
+ public override void Write(string? message)
+ {
+ }
- public override void WriteLine(string? message) =>
- throw new InvalidOperationException("listener failure");
+ public override void WriteLine(string? message)
+ {
+ }
}
}
diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTestsReleaseSafe.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTestsReleaseSafe.cs
new file mode 100644
index 000000000..e873e2fc4
--- /dev/null
+++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTestsReleaseSafe.cs
@@ -0,0 +1,897 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using Microsoft.Mxc.Sdk;
+using Xunit;
+
+namespace Microsoft.Mxc.Sdk.Tests;
+
+[Collection("MxcTelemetry")]
+public sealed class MxcTelemetryTestsReleaseSafe
+{
+ private static string ConsentPromptJson() =>
+ """
+ {"resourceVersion":1,"locale":"en-US","title":{"id":"title","text":"Help improve MXC"},"body":{"id":"body","text":"body"},"affirmativeLabel":{"id":"yes","text":"Yes"},"negativeLabel":{"id":"no","text":"No"},"learnMoreLabel":{"id":"learn","text":"Learn more"},"learnMoreUrl":"https://example.microsoft.com/privacy"}
+ """;
+
+ private static string ConsentOutcomeJson(string result, string storedState, string effectiveState, string policy = "unrestricted") =>
+ $$"""{"result":"{{result}}","storedState":"{{storedState}}","effectiveState":"{{effectiveState}}","reason":null,"policy":"{{policy}}"}""";
+
+ private static string ConsentStatusJson(string storedState, string effectiveState, string policy, string? reason = null)
+ {
+ var reasonJson = reason is null ? "null" : $"\"{reason}\"";
+ return $$"""{"storedState":"{{storedState}}","effectiveState":"{{effectiveState}}","reason":{{reasonJson}},"policy":"{{policy}}"}""";
+ }
+
+ [Fact]
+ public void MockedTelemetrySeam_ExercisesThePublicStatusApisWithoutNativeBits()
+ {
+ var native = new FakeTelemetryNativeApi
+ {
+ GetConsentImpl = () => new((int)ErrorCode.Success, "granted"),
+ NeedsConsentPromptImpl = () => new((int)ErrorCode.Success, true),
+ GetPolicyImpl = () => new((int)ErrorCode.Success, "allowed"),
+ GetConsentStatusImpl = () => new((int)ErrorCode.Success, ConsentStatusJson("granted", "granted", "allowed")),
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+
+ Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsent());
+ Assert.True(MxcTelemetry.NeedsConsentPrompt());
+ Assert.Equal(TelemetryPolicyState.Allowed, MxcTelemetry.GetPolicy());
+
+ var status = MxcTelemetry.GetConsentStatus();
+ Assert.Equal(TelemetryConsentState.Granted, status.StoredState);
+ Assert.Equal(TelemetryConsentState.Granted, status.EffectiveState);
+ Assert.Equal(TelemetryPolicyState.Allowed, status.Policy);
+ }
+
+ [Theory]
+ [InlineData(typeof(DllNotFoundException), TelemetryConsentState.Undetermined)]
+ [InlineData(typeof(EntryPointNotFoundException), TelemetryConsentState.Undetermined)]
+ [InlineData(typeof(BadImageFormatException), TelemetryConsentState.Undetermined)]
+ public void GetConsent_NativeLoadFailuresFailClosedThroughThePublicApi(Type exceptionType, TelemetryConsentState expected)
+ {
+ var native = new FakeTelemetryNativeApi
+ {
+ GetConsentImpl = () => throw (Exception)Activator.CreateInstance(exceptionType)!,
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+
+ Assert.Equal(expected, MxcTelemetry.GetConsent());
+ }
+
+ [Fact]
+ public void ReadOnlyQueries_NeverThrow_WhenTheNativeLayerFails()
+ {
+ var native = new FakeTelemetryNativeApi
+ {
+ GetConsentImpl = () => new((int)ErrorCode.Panic, null),
+ NeedsConsentPromptImpl = () => new((int)ErrorCode.Panic, false),
+ GetPolicyImpl = () => throw new DllNotFoundException("missing mxc_ffi"),
+ GetConsentStatusImpl = () => new((int)ErrorCode.Success, "{"),
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+
+ Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent());
+ Assert.False(MxcTelemetry.NeedsConsentPrompt());
+ Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy());
+ var status = MxcTelemetry.GetConsentStatus();
+ Assert.Equal(TelemetryConsentState.Undetermined, status.StoredState);
+ Assert.Equal(TelemetryConsentState.Undetermined, status.EffectiveState);
+ Assert.Equal(TelemetryPolicyState.Blocked, status.Policy);
+ }
+
+ [Fact]
+ public void FailClosedDiagnostics_AreDeduplicatedAndRedacted()
+ {
+ var operation = $"TestGetPolicy{Guid.NewGuid():N}";
+ using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting(
+ new MxcTelemetry.FailureCategoryTracker(capacity: 64));
+ using var output = new StringWriter();
+ using var listener = new TextWriterTraceListener(output);
+ var failure = new InvalidOperationException("sensitive\r\n\u001b[31mmessage");
+ Trace.Listeners.Add(listener);
+ try
+ {
+ MxcTelemetry.ReportFailClosed(operation, "Blocked", failure);
+ MxcTelemetry.ReportFailClosed(operation, "Blocked", failure);
+ MxcTelemetry.ReportFailClosed(operation, "false", ErrorCode.BackendError);
+ Trace.Flush();
+
+ var messages = output
+ .ToString()
+ .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)
+ .Where(line => line.Contains(operation, StringComparison.Ordinal))
+ .ToArray();
+ Assert.Equal(2, messages.Length);
+ Assert.Contains(typeof(InvalidOperationException).FullName!, messages[0]);
+ Assert.Contains("HRESULT 0x", messages[0]);
+ Assert.DoesNotContain("sensitive", messages[0]);
+ Assert.DoesNotContain('\u001b', messages[0]);
+ Assert.Contains("BackendError", messages[1]);
+ }
+ finally
+ {
+ Trace.Listeners.Remove(listener);
+ }
+ }
+
+ [Fact]
+ public void FailClosedDiagnosticTracker_IsBoundedAndConcurrent()
+ {
+ var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 64);
+ var duplicateAccepted = 0;
+ Parallel.For(0, 1_000, _ =>
+ {
+ if (tracker.TryAdd("GetPolicy", "Blocked", "ExceptionA", 1))
+ {
+ Interlocked.Increment(ref duplicateAccepted);
+ }
+ });
+ Assert.Equal(1, duplicateAccepted);
+
+ var capacityTracker = new MxcTelemetry.FailureCategoryTracker(capacity: 64);
+ var capacityAccepted = 0;
+ Parallel.For(0, 1_000, index =>
+ {
+ if (capacityTracker.TryAdd("GetPolicy", "Blocked", "Exception", index))
+ {
+ Interlocked.Increment(ref capacityAccepted);
+ }
+ });
+ Assert.Equal(64, capacityAccepted);
+ }
+
+ [Fact]
+ public void FailClosedDiagnostics_RetryAfterTraceListenerFailure()
+ {
+ var operation = $"ThrowingTrace{Guid.NewGuid():N}";
+ using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting(
+ new MxcTelemetry.FailureCategoryTracker(capacity: 64));
+ using var throwingListener = new ThrowingTraceListener();
+ Trace.Listeners.Add(throwingListener);
+ try
+ {
+ var exception = Record.Exception(() =>
+ MxcTelemetry.ReportFailClosed(
+ operation,
+ "Blocked",
+ ErrorCode.BackendError));
+ Assert.Null(exception);
+ }
+ finally
+ {
+ Trace.Listeners.Remove(throwingListener);
+ }
+
+ using var output = new StringWriter();
+ using var capture = new TextWriterTraceListener(output);
+ Trace.Listeners.Add(capture);
+ try
+ {
+ MxcTelemetry.ReportFailClosed(operation, "Blocked", ErrorCode.BackendError);
+ Trace.Flush();
+ Assert.Contains(operation, output.ToString());
+ }
+ finally
+ {
+ Trace.Listeners.Remove(capture);
+ }
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void ReadOnlyQueryFailures_ReportDistinctFailureKinds(bool throwException)
+ {
+ var native = new FakeTelemetryNativeApi
+ {
+ GetConsentImpl = () => throwException
+ ? throw new InvalidOperationException("consent")
+ : new((int)ErrorCode.BackendError, null),
+ NeedsConsentPromptImpl = () => throwException
+ ? throw new InvalidOperationException("prompt")
+ : new((int)ErrorCode.BackendError, true),
+ GetPolicyImpl = () => throwException
+ ? throw new InvalidOperationException("policy")
+ : new((int)ErrorCode.BackendError, null),
+ };
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+ using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting(
+ new MxcTelemetry.FailureCategoryTracker(capacity: 64));
+ using var output = new StringWriter();
+ using var listener = new TextWriterTraceListener(output);
+ Trace.Listeners.Add(listener);
+ try
+ {
+ Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent());
+ Assert.False(MxcTelemetry.NeedsConsentPrompt());
+ Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy());
+ Trace.Flush();
+
+ var messages = output
+ .ToString()
+ .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)
+ .Where(line => line.Contains("mxc:", StringComparison.Ordinal))
+ .ToArray();
+ Assert.Equal(3, messages.Length);
+ Assert.Contains(messages, line => line.Contains("GetConsent", StringComparison.Ordinal));
+ Assert.Contains(messages, line => line.Contains("NeedsConsentPrompt", StringComparison.Ordinal));
+ Assert.Contains(messages, line => line.Contains("GetPolicy", StringComparison.Ordinal));
+ if (throwException)
+ {
+ Assert.All(
+ messages,
+ line => Assert.Contains(
+ typeof(InvalidOperationException).FullName!,
+ line));
+ }
+ else
+ {
+ Assert.Contains(
+ messages,
+ line => line.Contains(typeof(MxcException).FullName!, StringComparison.Ordinal));
+ Assert.Equal(
+ 2,
+ messages.Count(line =>
+ line.Contains(nameof(ErrorCode.BackendError), StringComparison.Ordinal)));
+ }
+ }
+ finally
+ {
+ Trace.Listeners.Remove(listener);
+ }
+ }
+
+ [Fact]
+ public void TestOverrides_RejectOverlapAndRestoreAfterConcurrentDispose()
+ {
+ var native = new FakeTelemetryNativeApi();
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ Assert.Throws(
+ () => MxcTelemetry.OverrideNativeApiForTesting(native));
+ Parallel.Invoke(nativeScope.Dispose, nativeScope.Dispose);
+
+ var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 64);
+ using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting(tracker);
+ Assert.Throws(
+ () => MxcTelemetry.OverrideFailureCategoryTrackerForTesting(tracker));
+ Parallel.Invoke(trackerScope.Dispose, trackerScope.Dispose);
+
+ using var restoredNativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var restoredTrackerScope =
+ MxcTelemetry.OverrideFailureCategoryTrackerForTesting(tracker);
+ }
+
+ [Fact]
+ public void GetConsent_UnexpectedReadFailureFailsClosed()
+ {
+ var native = new FakeTelemetryNativeApi
+ {
+ GetConsentImpl = () => throw new InvalidOperationException("unexpected read failure"),
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+
+ Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent());
+ }
+
+ [Fact]
+ public void GetConsentStatus_NativeFailureDoesNotClaimTheStoreWasUnreadable()
+ {
+ var native = new FakeTelemetryNativeApi
+ {
+ GetConsentStatusImpl = () => new((int)ErrorCode.Panic, null),
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+
+ var status = MxcTelemetry.GetConsentStatus();
+ Assert.Equal(TelemetryConsentState.Undetermined, status.EffectiveState);
+ Assert.Equal(TelemetryPolicyState.Blocked, status.Policy);
+ }
+
+ [Fact]
+ public void RequestConsent_PresenterExceptionsPropagateWithTheOriginalCause()
+ {
+ var native = new FakeTelemetryNativeApi
+ {
+ RequestConsentImpl = (_locale, presenter) =>
+ {
+ Assert.Equal(-1, presenter(ConsentPromptJson()));
+ return new((int)ErrorCode.BackendError, null);
+ },
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+
+ var ex = Assert.Throws(() => MxcTelemetry.RequestConsent(_ => throw new InvalidOperationException("UI unavailable")));
+ Assert.Equal(ErrorCode.BackendError, ex.Code);
+ Assert.IsType(ex.InnerException);
+ Assert.Equal("UI unavailable", ex.InnerException!.Message);
+ }
+
+ [Fact]
+ public async Task RequestConsentAsync_CancellationStopsWaitingForThePresenter()
+ {
+ var presenterEntered = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var presenterCompletion = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var native = new FakeTelemetryNativeApi
+ {
+ RequestConsentImpl = (_locale, presenter) =>
+ {
+ Assert.Equal(2, presenter(ConsentPromptJson()));
+ return new(
+ (int)ErrorCode.Success,
+ ConsentOutcomeJson("dismissed", "undetermined", "undetermined", "unrestricted"));
+ },
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+ using var cancellation = new CancellationTokenSource();
+
+ var request = MxcTelemetry.RequestConsentAsync(
+ _ =>
+ {
+ presenterEntered.SetResult();
+ return new ValueTask(presenterCompletion.Task);
+ },
+ cancellationToken: cancellation.Token);
+ await presenterEntered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ cancellation.Cancel();
+
+ await Assert.ThrowsAnyAsync(() => request);
+ Assert.True(request.IsCanceled);
+ }
+
+ [Fact]
+ public async Task RequestConsentAsync_CancellationBeforeUiDispatchSkipsPresenter()
+ {
+ var synchronizationContext = new QueuedSynchronizationContext();
+ var presenterCalls = 0;
+ var native = new FakeTelemetryNativeApi
+ {
+ RequestConsentImpl = (_locale, presenter) =>
+ {
+ Assert.Equal(2, presenter(ConsentPromptJson()));
+ return new(
+ (int)ErrorCode.Success,
+ ConsentOutcomeJson("dismissed", "undetermined", "undetermined", "unrestricted"));
+ },
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+ using var cancellation = new CancellationTokenSource();
+
+ var previousContext = SynchronizationContext.Current;
+ Task request;
+ try
+ {
+ SynchronizationContext.SetSynchronizationContext(synchronizationContext);
+ request = MxcTelemetry.RequestConsentAsync(
+ _ =>
+ {
+ presenterCalls += 1;
+ return ValueTask.FromResult(TelemetryConsentDecision.Yes);
+ },
+ cancellationToken: cancellation.Token);
+ }
+ finally
+ {
+ SynchronizationContext.SetSynchronizationContext(previousContext);
+ }
+
+ await synchronizationContext.WaitForPostAsync(TestContext.Current.CancellationToken);
+ cancellation.Cancel();
+ await Assert.ThrowsAnyAsync(() => request);
+ Assert.True(request.IsCanceled);
+
+ synchronizationContext.RunAll();
+ Assert.Equal(0, presenterCalls);
+ }
+
+ [Fact]
+ public async Task RequestConsentAsync_PostSuccessCancellationDoesNotHidePersistence()
+ {
+ using var cancellation = new CancellationTokenSource();
+ var native = new FakeTelemetryNativeApi
+ {
+ RequestConsentImpl = (_locale, presenter) =>
+ {
+ Assert.Equal(1, presenter(ConsentPromptJson()));
+ cancellation.Cancel();
+ return new(
+ (int)ErrorCode.Success,
+ ConsentOutcomeJson("granted", "granted", "granted", "unrestricted"));
+ },
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+
+ var outcome = await MxcTelemetry.RequestConsentAsync(
+ _ => ValueTask.FromResult(TelemetryConsentDecision.Yes),
+ cancellationToken: cancellation.Token);
+
+ Assert.Equal(TelemetryConsentActionResult.Granted, outcome.Result);
+ }
+
+ [Theory]
+ [InlineData("{")]
+ [InlineData("""{"storedState":"granted"}""")]
+ [InlineData("""{"storedState":"granted","effectiveState":"granted","reason":null}""")]
+ public void GetConsentStatus_MalformedOrTruncatedJsonFailsClosed(string payload)
+ {
+ var native = new FakeTelemetryNativeApi
+ {
+ GetConsentStatusImpl = () => new((int)ErrorCode.Success, payload),
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+
+ var status = MxcTelemetry.GetConsentStatus();
+ Assert.Equal(TelemetryConsentState.Undetermined, status.StoredState);
+ Assert.Equal(TelemetryConsentState.Undetermined, status.EffectiveState);
+ Assert.Equal(TelemetryPolicyState.Blocked, status.Policy);
+ }
+
+ [Theory]
+ [InlineData("alreadyGranted", TelemetryConsentActionResult.AlreadyGranted)]
+ [InlineData("policyBlocked", TelemetryConsentActionResult.PolicyBlocked)]
+ [InlineData("notApplicable", TelemetryConsentActionResult.NotApplicable)]
+ public void RequestConsent_ParsesCamelCaseWireResults(string result, TelemetryConsentActionResult expected)
+ {
+ var native = new FakeTelemetryNativeApi
+ {
+ RequestConsentImpl = (_locale, presenter) =>
+ {
+ Assert.Equal(1, presenter(ConsentPromptJson()));
+ return new((int)ErrorCode.Success, ConsentOutcomeJson(result, "undetermined", "undetermined", "blocked"));
+ },
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+
+ var outcome = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes);
+ Assert.Equal(expected, outcome.Result);
+ }
+
+ [Fact]
+ public void RequestConsent_UnknownWireResultReturnsFailClosedSentinel()
+ {
+ var native = new FakeTelemetryNativeApi
+ {
+ RequestConsentImpl = (_locale, presenter) =>
+ {
+ Assert.Equal(1, presenter(ConsentPromptJson()));
+ return new(
+ (int)ErrorCode.Success,
+ ConsentOutcomeJson("futureResult", "granted", "granted", "allowed"));
+ },
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+
+ var outcome = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes);
+
+ Assert.Equal(TelemetryConsentActionResult.Unknown, outcome.Result);
+ Assert.Equal(TelemetryConsentState.Undetermined, outcome.StoredState);
+ Assert.Equal(TelemetryConsentState.Undetermined, outcome.EffectiveState);
+ Assert.Equal(TelemetryPolicyState.Blocked, outcome.Policy);
+ }
+
+ [Fact]
+ public void WithdrawConsent_UnknownWireResultReturnsFailClosedSentinel()
+ {
+ var native = new FakeTelemetryNativeApi
+ {
+ WithdrawConsentImpl = () => new(
+ (int)ErrorCode.Success,
+ ConsentOutcomeJson("futureResult", "granted", "granted", "allowed")),
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+
+ var outcome = MxcTelemetry.WithdrawConsent();
+
+ Assert.Equal(TelemetryConsentActionResult.Unknown, outcome.Result);
+ Assert.Equal(TelemetryConsentState.Undetermined, outcome.StoredState);
+ Assert.Equal(TelemetryConsentState.Undetermined, outcome.EffectiveState);
+ Assert.Equal(TelemetryPolicyState.Blocked, outcome.Policy);
+ }
+
+ [Fact]
+ public void RequestConsent_WithdrawnResultReturnsFailClosedSentinel()
+ {
+ var native = new FakeTelemetryNativeApi
+ {
+ RequestConsentImpl = (_locale, presenter) =>
+ {
+ Assert.Equal(1, presenter(ConsentPromptJson()));
+ return new(
+ (int)ErrorCode.Success,
+ ConsentOutcomeJson("withdrawn", "granted", "granted", "allowed"));
+ },
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+
+ var outcome = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes);
+
+ Assert.Equal(TelemetryConsentActionResult.Unknown, outcome.Result);
+ Assert.Equal(TelemetryConsentState.Undetermined, outcome.StoredState);
+ Assert.Equal(TelemetryConsentState.Undetermined, outcome.EffectiveState);
+ Assert.Equal(TelemetryPolicyState.Blocked, outcome.Policy);
+ }
+
+ [Theory]
+ [InlineData("granted")]
+ [InlineData("denied")]
+ [InlineData("dismissed")]
+ [InlineData("alreadyGranted")]
+ [InlineData("policyBlocked")]
+ [InlineData("presentationUnavailable")]
+ public void WithdrawConsent_RequestResultReturnsFailClosedSentinel(string result)
+ {
+ var native = new FakeTelemetryNativeApi
+ {
+ WithdrawConsentImpl = () => new(
+ (int)ErrorCode.Success,
+ ConsentOutcomeJson(result, "granted", "granted", "allowed")),
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+
+ var outcome = MxcTelemetry.WithdrawConsent();
+
+ Assert.Equal(TelemetryConsentActionResult.Unknown, outcome.Result);
+ Assert.Equal(TelemetryConsentState.Undetermined, outcome.StoredState);
+ Assert.Equal(TelemetryConsentState.Undetermined, outcome.EffectiveState);
+ Assert.Equal(TelemetryPolicyState.Blocked, outcome.Policy);
+ }
+
+ [Fact]
+ public void RequestConsent_UnknownWireReasonReturnsFailClosedSentinel()
+ {
+ var native = new FakeTelemetryNativeApi
+ {
+ RequestConsentImpl = (_locale, presenter) =>
+ {
+ Assert.Equal(1, presenter(ConsentPromptJson()));
+ return new(
+ (int)ErrorCode.Success,
+ """{"result":"granted","storedState":"granted","effectiveState":"granted","reason":"future-reason","policy":"allowed"}""");
+ },
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+
+ var outcome = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes);
+
+ Assert.Equal(TelemetryConsentActionResult.Unknown, outcome.Result);
+ Assert.Equal(TelemetryConsentState.Undetermined, outcome.StoredState);
+ Assert.Equal(TelemetryConsentState.Undetermined, outcome.EffectiveState);
+ Assert.Equal(TelemetryPolicyState.Blocked, outcome.Policy);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("{")]
+ [InlineData("""{"result":"granted"}""")]
+ public void RequestConsent_MalformedSuccessPayloadIsBackendError(string? payload)
+ {
+ var native = new FakeTelemetryNativeApi
+ {
+ RequestConsentImpl = (_locale, presenter) =>
+ {
+ Assert.Equal(1, presenter(ConsentPromptJson()));
+ return new((int)ErrorCode.Success, payload);
+ },
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+
+ var ex = Assert.Throws(
+ () => MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes));
+ Assert.Equal(ErrorCode.BackendError, ex.Code);
+ Assert.NotNull(ex.InnerException);
+ }
+
+ [Theory]
+ [InlineData("policy-blocked")]
+ [InlineData("presentation-unavailable")]
+ public void GetConsentStatus_AcceptsPrivateStatusReasons(string reason)
+ {
+ var native = new FakeTelemetryNativeApi
+ {
+ GetConsentStatusImpl = () => new(
+ (int)ErrorCode.Success,
+ ConsentStatusJson("undetermined", "undetermined", "blocked", reason)),
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+
+ var status = MxcTelemetry.GetConsentStatus();
+ Assert.Equal(TelemetryConsentState.Undetermined, status.EffectiveState);
+ Assert.Equal(TelemetryPolicyState.Blocked, status.Policy);
+ }
+
+ [Fact]
+ public void GetConsentStatus_UnknownWireReasonReturnsFailClosedSentinel()
+ {
+ var native = new FakeTelemetryNativeApi
+ {
+ GetConsentStatusImpl = () => new(
+ (int)ErrorCode.Success,
+ ConsentStatusJson("granted", "granted", "allowed", "future-reason")),
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+
+ var status = MxcTelemetry.GetConsentStatus();
+
+ Assert.Equal(TelemetryConsentState.Undetermined, status.StoredState);
+ Assert.Equal(TelemetryConsentState.Undetermined, status.EffectiveState);
+ Assert.Equal(TelemetryPolicyState.Blocked, status.Policy);
+ }
+
+ [Theory]
+ [InlineData("future-state", "granted", "allowed")]
+ [InlineData("granted", "future-state", "allowed")]
+ [InlineData("granted", "granted", "future-policy")]
+ public void GetConsentStatus_UnknownFieldReturnsWholeFailClosedSentinel(
+ string storedState,
+ string effectiveState,
+ string policy)
+ {
+ var native = new FakeTelemetryNativeApi
+ {
+ GetConsentStatusImpl = () => new(
+ (int)ErrorCode.Success,
+ ConsentStatusJson(storedState, effectiveState, policy)),
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+
+ var status = MxcTelemetry.GetConsentStatus();
+
+ Assert.Equal(TelemetryConsentState.Undetermined, status.StoredState);
+ Assert.Equal(TelemetryConsentState.Undetermined, status.EffectiveState);
+ Assert.Equal(TelemetryPolicyState.Blocked, status.Policy);
+ }
+
+ [Theory]
+ [InlineData("future-state", "granted", "allowed")]
+ [InlineData("granted", "future-state", "allowed")]
+ [InlineData("granted", "granted", "future-policy")]
+ public void RequestConsent_UnknownFieldReturnsWholeFailClosedSentinel(
+ string storedState,
+ string effectiveState,
+ string policy)
+ {
+ var native = new FakeTelemetryNativeApi
+ {
+ RequestConsentImpl = (_locale, presenter) =>
+ {
+ Assert.Equal(1, presenter(ConsentPromptJson()));
+ return new(
+ (int)ErrorCode.Success,
+ ConsentOutcomeJson("granted", storedState, effectiveState, policy));
+ },
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+
+ var outcome = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes);
+
+ Assert.Equal(TelemetryConsentActionResult.Unknown, outcome.Result);
+ Assert.Equal(TelemetryConsentState.Undetermined, outcome.StoredState);
+ Assert.Equal(TelemetryConsentState.Undetermined, outcome.EffectiveState);
+ Assert.Equal(TelemetryPolicyState.Blocked, outcome.Policy);
+ }
+
+ [Fact]
+ public void RequestAndWithdrawConsent_ReturnNotApplicableOffWindowsWithoutTouchingNative()
+ {
+ var nativeCalls = 0;
+ var native = new FakeTelemetryNativeApi
+ {
+ RequestConsentImpl = (_locale, _presenter) =>
+ {
+ nativeCalls += 1;
+ return new((int)ErrorCode.Success, ConsentOutcomeJson("granted", "granted", "granted"));
+ },
+ WithdrawConsentImpl = () =>
+ {
+ nativeCalls += 1;
+ return new((int)ErrorCode.Success, ConsentOutcomeJson("withdrawn", "denied", "denied"));
+ },
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(false);
+
+ var presented = false;
+ var request = MxcTelemetry.RequestConsent(_ =>
+ {
+ presented = true;
+ return TelemetryConsentDecision.Yes;
+ });
+ var withdraw = MxcTelemetry.WithdrawConsent();
+
+ Assert.False(presented);
+ Assert.Equal(0, nativeCalls);
+ Assert.Equal(TelemetryConsentActionResult.NotApplicable, request.Result);
+ Assert.Equal(TelemetryConsentState.NotApplicable, request.StoredState);
+ Assert.Equal(TelemetryPolicyState.NotApplicable, request.Policy);
+ Assert.Equal(TelemetryConsentActionResult.NotApplicable, withdraw.Result);
+ Assert.Equal(TelemetryConsentState.NotApplicable, withdraw.StoredState);
+ Assert.Equal(TelemetryPolicyState.NotApplicable, withdraw.Policy);
+ }
+
+ [Fact]
+ public void WithdrawConsent_UnexpectedNativeStatusSurfacesAsMxcException()
+ {
+ var native = new FakeTelemetryNativeApi
+ {
+ WithdrawConsentImpl = () => new(777, null),
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+
+ var ex = Assert.Throws(() => MxcTelemetry.WithdrawConsent());
+ Assert.Equal(777, (int)ex.Code);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("{")]
+ [InlineData("""{"result":"withdrawn"}""")]
+ public void WithdrawConsent_MalformedSuccessPayloadIsBackendError(string? payload)
+ {
+ var native = new FakeTelemetryNativeApi
+ {
+ WithdrawConsentImpl = () => new((int)ErrorCode.Success, payload),
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+
+ var ex = Assert.Throws(() => MxcTelemetry.WithdrawConsent());
+ Assert.Equal(ErrorCode.BackendError, ex.Code);
+ Assert.NotNull(ex.InnerException);
+ }
+
+ [Fact]
+ public void ConsentWriteFailureStatusIsPreservedAcrossRequestAndWithdrawal()
+ {
+ var native = new FakeTelemetryNativeApi
+ {
+ RequestConsentImpl = (_locale, presenter) =>
+ {
+ Assert.Equal(1, presenter(ConsentPromptJson()));
+ return new((int)ErrorCode.ConsentWriteFailed, null);
+ },
+ WithdrawConsentImpl = () => new((int)ErrorCode.ConsentWriteFailed, null),
+ };
+
+ using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native);
+ using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true);
+
+ var request = Assert.Throws(
+ () => MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes));
+ Assert.Equal(ErrorCode.ConsentWriteFailed, request.Code);
+
+ var withdraw = Assert.Throws(() => MxcTelemetry.WithdrawConsent());
+ Assert.Equal(ErrorCode.ConsentWriteFailed, withdraw.Code);
+ }
+
+ private sealed class FakeTelemetryNativeApi : MxcTelemetry.ITelemetryNativeApi
+ {
+ public Func GetConsentImpl { get; init; } =
+ () => new((int)ErrorCode.Success, "undetermined");
+
+ public Func NeedsConsentPromptImpl { get; init; } =
+ () => new((int)ErrorCode.Success, false);
+
+ public Func GetPolicyImpl { get; init; } =
+ () => new((int)ErrorCode.Success, "unrestricted");
+
+ public Func GetConsentStatusImpl { get; init; } =
+ () => new((int)ErrorCode.Success, ConsentStatusJson("undetermined", "undetermined", "blocked", "store-unreadable"));
+
+ public Func WithdrawConsentImpl { get; init; } =
+ () => new((int)ErrorCode.Success, ConsentOutcomeJson("withdrawn", "denied", "denied", "unrestricted"));
+
+ public Func, MxcTelemetry.NativePayloadResult> RequestConsentImpl { get; init; } =
+ (_locale, presenter) =>
+ {
+ Assert.Equal(1, presenter(ConsentPromptJson()));
+ return new((int)ErrorCode.Success, ConsentOutcomeJson("granted", "granted", "granted", "unrestricted"));
+ };
+
+ public MxcTelemetry.NativePayloadResult GetConsent() => GetConsentImpl();
+
+ public MxcTelemetry.NativeBooleanResult NeedsConsentPrompt() => NeedsConsentPromptImpl();
+
+ public MxcTelemetry.NativePayloadResult GetPolicy() => GetPolicyImpl();
+
+ public MxcTelemetry.NativePayloadResult GetConsentStatus() => GetConsentStatusImpl();
+
+ public MxcTelemetry.NativePayloadResult WithdrawConsent() => WithdrawConsentImpl();
+
+ public MxcTelemetry.NativePayloadResult RequestConsent(string? locale, Func presenter) =>
+ RequestConsentImpl(locale, presenter);
+ }
+
+ private sealed class ThrowingTraceListener : TraceListener
+ {
+ public override void TraceEvent(
+ TraceEventCache? eventCache,
+ string? source,
+ TraceEventType eventType,
+ int id,
+ string? format,
+ params object?[]? args) =>
+ throw new InvalidOperationException("listener failure");
+
+ public override void Write(string? message) =>
+ throw new InvalidOperationException("listener failure");
+
+ public override void WriteLine(string? message) =>
+ throw new InvalidOperationException("listener failure");
+ }
+
+ private sealed class QueuedSynchronizationContext : SynchronizationContext
+ {
+ private readonly ConcurrentQueue<(SendOrPostCallback Callback, object? State)> callbacks = new();
+ private readonly TaskCompletionSource posted = new(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public override void Post(SendOrPostCallback callback, object? state)
+ {
+ callbacks.Enqueue((callback, state));
+ posted.TrySetResult();
+ }
+
+ public Task WaitForPostAsync(CancellationToken cancellationToken) =>
+ posted.Task.WaitAsync(cancellationToken);
+
+ public void RunAll()
+ {
+ while (callbacks.TryDequeue(out var item))
+ {
+ item.Callback(item.State);
+ }
+ }
+ }
+}
diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcException.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcException.cs
index 8483096de..215634eab 100644
--- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcException.cs
+++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcException.cs
@@ -62,20 +62,30 @@ internal MxcException(
Remediation = remediation;
}
+ ///
+ /// Create an exception with the given code and message while preserving
+ /// the underlying cause for diagnosis.
+ ///
+ internal MxcException(ErrorCode code, string message, Exception innerException)
+ : base(message, innerException)
+ {
+ Code = code;
+ }
+
///
///
/// Appends the operation and status when present, so a caller that only
- /// logs the exception keeps the diagnosis rather than losing it.
+ /// logs the exception keeps the diagnosis rather than losing it. An
+ /// underlying cause is appended when present.
///
public override string ToString()
{
- if (Operation is null)
- {
- return $"{Code}: {Message}";
- }
+ string detail = Operation is null
+ ? $"{Code}: {Message}"
+ : NativeCode is null
+ ? $"{Code}: {Message} [{Operation}]"
+ : $"{Code}: {Message} [{Operation} {NativeCode}]";
- return NativeCode is null
- ? $"{Code}: {Message} [{Operation}]"
- : $"{Code}: {Message} [{Operation} {NativeCode}]";
+ return InnerException is null ? detail : $"{detail} ---> {InnerException}";
}
}
diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs
index f58f94f6d..cd01655db 100644
--- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs
+++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs
@@ -142,6 +142,8 @@ internal static JsonObject BuildProvisionEnvelope(
break;
}
+ ApplyTelemetry(envelope, options?.Telemetry, options?.Version);
+
return envelope;
}
@@ -159,8 +161,13 @@ public static void DryRunStartSandbox(SandboxId id, StateAwarePhaseOptions? opti
internal static JsonObject BuildStartEnvelope(
SandboxId id,
- StateAwarePhaseOptions? options = null) =>
- BuildIdEnvelope("start", id, options?.Version);
+ StateAwarePhaseOptions? options = null)
+ {
+ ValidateNonExecOptions("start", options);
+ var envelope = BuildIdEnvelope("start", id, options?.Version);
+ ApplyTelemetry(envelope, options?.Telemetry, options?.Version);
+ return envelope;
+ }
///
/// Run a command in a started sandbox and return live stdio streams.
@@ -280,6 +287,7 @@ internal static JsonObject BuildExecEnvelope(
{
envelope["network"] = SerializeToNode(network);
}
+ ApplyTelemetry(envelope, options?.Telemetry, options?.Version);
return envelope;
}
@@ -299,8 +307,9 @@ public static async Task ExecInSandboxAsync(
StateAwareExecOptions? options,
CancellationToken cancellationToken = default)
{
- var proc = await Task.Run(
+ var proc = await RunBlockingOperationAsync(
() => ExecInSandbox(id, command, options),
+ lateProc => lateProc.Dispose(),
cancellationToken)
.ConfigureAwait(false);
try
@@ -324,6 +333,45 @@ public static async Task ExecInSandboxAsync(
}
}
+ // Runs a synchronous blocking call on a background thread so it can be
+ // awaited with cancellation. If the caller cancels while the operation is
+ // still running the returned Task faults with an OperationCanceledException
+ // immediately, but the background call is *not* aborted — it continues to
+ // completion and, if it produced a resource the caller would otherwise own,
+ // the late-result cleanup callback is invoked so the resource isn't leaked.
+ internal static async Task RunBlockingOperationAsync(
+ Func operation,
+ Action disposeLateResult,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var task = Task.Run(operation, cancellationToken);
+ try
+ {
+ return await task.WaitAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ _ = task.ContinueWith(
+ t =>
+ {
+ if (t.Status == TaskStatus.RanToCompletion)
+ {
+ try { disposeLateResult(t.Result); }
+ catch { /* best-effort cleanup */ }
+ }
+ else if (t.IsFaulted)
+ {
+ _ = t.Exception;
+ }
+ },
+ CancellationToken.None,
+ TaskContinuationOptions.ExecuteSynchronously,
+ TaskScheduler.Default);
+ throw;
+ }
+ }
+
/// Stop a running sandbox.
public static void StopSandbox(SandboxId id, StateAwarePhaseOptions? options = null)
{
@@ -338,8 +386,13 @@ public static void DryRunStopSandbox(SandboxId id, StateAwarePhaseOptions? optio
internal static JsonObject BuildStopEnvelope(
SandboxId id,
- StateAwarePhaseOptions? options = null) =>
- BuildIdEnvelope("stop", id, options?.Version);
+ StateAwarePhaseOptions? options = null)
+ {
+ ValidateNonExecOptions("stop", options);
+ var envelope = BuildIdEnvelope("stop", id, options?.Version);
+ ApplyTelemetry(envelope, options?.Telemetry, options?.Version);
+ return envelope;
+ }
/// Destroy a sandbox and release its resources.
public static void DeprovisionSandbox(
@@ -359,8 +412,26 @@ public static void DryRunDeprovisionSandbox(
internal static JsonObject BuildDeprovisionEnvelope(
SandboxId id,
- StateAwarePhaseOptions? options = null) =>
- BuildIdEnvelope("deprovision", id, options?.Version);
+ StateAwarePhaseOptions? options = null)
+ {
+ ValidateNonExecOptions("deprovision", options);
+ var envelope = BuildIdEnvelope("deprovision", id, options?.Version);
+ ApplyTelemetry(envelope, options?.Telemetry, options?.Version);
+ return envelope;
+ }
+
+ private static void ValidateNonExecOptions(
+ string phase,
+ StateAwarePhaseOptions? options)
+ {
+ if (options is StateAwareExecOptions)
+ {
+ throw new ArgumentException(
+ $"{options.GetType().Name} cannot configure the {phase} phase; "
+ + $"use {nameof(StateAwarePhaseOptions)}.",
+ nameof(options));
+ }
+ }
private static JsonObject BuildIdEnvelope(
string phase,
@@ -381,6 +452,24 @@ private static JsonObject BuildIdEnvelope(
["phase"] = phase,
};
+ // Stable, top-level telemetry request for this phase. Consent and
+ // administrative policy still gate emission independently. Never carries a
+ // caller-supplied correlationVector — that identifier is internal-only.
+ private static void ApplyTelemetry(
+ JsonObject envelope,
+ TelemetrySettings? telemetry,
+ string? suppliedVersion)
+ {
+ if (telemetry is not null)
+ {
+ if (suppliedVersion is null)
+ {
+ envelope["version"] = SchemaVersions.MaximumSupported;
+ }
+ envelope["telemetry"] = SerializeToNode(telemetry);
+ }
+ }
+
private static string ContainmentKey(StateAwareContainment containment) => containment switch
{
StateAwareContainment.IsolationSession => IsolationSessionContainment,
diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs
index 79c5524b4..5b3fc3bee 100644
--- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs
+++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs
@@ -2,181 +2,144 @@
// Licensed under the MIT License.
using System.Diagnostics;
-using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
-using System.Text;
+using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Mxc.Sdk.Native;
namespace Microsoft.Mxc.Sdk;
-/// A stored or effective telemetry consent state.
-public enum TelemetryConsentState
-{
- Undetermined = 0,
- Granted = 1,
- Denied = 2,
- NotApplicable = 3,
-}
-
-/// The administrative telemetry policy for this machine.
-public enum TelemetryPolicyState
-{
- Blocked = 0,
- Unrestricted = 1,
- Allowed = 2,
- NotApplicable = 3,
-}
-
-/// The presenter decision returned to MXC.
-public enum TelemetryConsentDecision
-{
- No,
- Yes,
- Dismissed,
-}
-
-/// Why stored consent is not currently effective.
-public enum TelemetryConsentStatusReason
-{
- NoRecord,
- StoreUnreadable,
- StoreMalformed,
- ConsentSchemaUnsupported,
- PromptVersionMissing,
- PromptVersionUnsupported,
- NotApplicable,
-}
-
-/// Result of a consent request or withdrawal.
-public enum TelemetryConsentResult
-{
- Unknown = 0,
- Granted = 1,
- Denied = 2,
- Dismissed = 3,
- Withdrawn = 4,
- AlreadyGranted = 5,
- PolicyBlocked = 6,
- NotApplicable = 7,
-}
-
-/// One canonical consent message.
-public sealed class TelemetryConsentMessage
-{
- public string Id { get; init; } = string.Empty;
- public string Text { get; init; } = string.Empty;
-}
-
-/// The canonical consent prompt resource passed to a host presenter.
-public sealed class TelemetryConsentPrompt
-{
- public uint ResourceVersion { get; init; }
- public string Locale { get; init; } = string.Empty;
- public TelemetryConsentMessage Title { get; init; } = new();
- public TelemetryConsentMessage Body { get; init; } = new();
- public TelemetryConsentMessage AffirmativeLabel { get; init; } = new();
- public TelemetryConsentMessage NegativeLabel { get; init; } = new();
- public TelemetryConsentMessage LearnMoreLabel { get; init; } = new();
- public string LearnMoreUrl { get; init; } = string.Empty;
-}
-
-/// Stored and effective consent plus the current administrative policy.
-public sealed class TelemetryConsentStatus
-{
- public TelemetryConsentState StoredState { get; init; }
- public TelemetryConsentState EffectiveState { get; init; }
- public TelemetryConsentStatusReason? Reason { get; init; }
- public TelemetryPolicyState Policy { get; init; }
-}
-
-/// Telemetry consent action result with the resulting status snapshot.
-public sealed class TelemetryConsentOutcome
-{
- public TelemetryConsentResult Result { get; init; }
- public TelemetryConsentState StoredState { get; init; }
- public TelemetryConsentState EffectiveState { get; init; }
- public TelemetryConsentStatusReason? Reason { get; init; }
- public TelemetryPolicyState Policy { get; init; }
-}
-
-/// Telemetry consent helpers over the native mxc_ffi surface.
+///
+/// Administers MXC telemetry consent. See
+/// docs/telemetry/telemetry-consent-design.md for the contract.
+///
public static class MxcTelemetry
{
- private const int NativeConsentDecisionNo = 0;
- private const int NativeConsentDecisionYes = 1;
- private const int NativeConsentDecisionDismissed = 2;
- private const int NativeConsentPresenterError = -1;
- private static readonly FailureCategoryTracker DefaultFailureCategoryTracker = new(capacity: 64);
- private static readonly ITelemetryReadApi DefaultTelemetryReadApi = new PInvokeTelemetryReadApi();
- private static FailureCategoryTracker reportedFailures = DefaultFailureCategoryTracker;
- private static ITelemetryReadApi telemetryReadApi = DefaultTelemetryReadApi;
-
internal readonly record struct NativePayloadResult(int Status, string? Payload);
internal readonly record struct NativeBooleanResult(int Status, bool Value);
- // The public wrappers interpret these native status results according to
- // their individual throw-or-fail-closed contracts.
- internal interface ITelemetryReadApi
+ internal interface ITelemetryNativeApi
{
NativePayloadResult GetConsent();
NativeBooleanResult NeedsConsentPrompt();
NativePayloadResult GetPolicy();
+ NativePayloadResult GetConsentStatus();
+ NativePayloadResult WithdrawConsent();
+ NativePayloadResult RequestConsent(string? locale, Func presenter);
}
- private sealed class PInvokeTelemetryReadApi : ITelemetryReadApi
+ private sealed class PInvokeTelemetryNativeApi : ITelemetryNativeApi
{
public unsafe NativePayloadResult GetConsent()
{
byte* value = null;
var status = NativeMethods.mxc_telemetry_get_consent(&value);
+ return Payload(status, value);
+ }
+
+ public unsafe NativeBooleanResult NeedsConsentPrompt()
+ {
+ int value = 0;
+ var status = NativeMethods.mxc_telemetry_needs_consent_prompt(&value);
+ return new(status, value != 0);
+ }
+
+ public unsafe NativePayloadResult GetPolicy()
+ {
+ byte* value = null;
+ var status = NativeMethods.mxc_telemetry_get_policy(&value);
+ return Payload(status, value);
+ }
+
+ public unsafe NativePayloadResult GetConsentStatus()
+ {
+ byte* value = null;
+ var status = NativeMethods.mxc_telemetry_get_consent_status(&value);
+ return Payload(status, value);
+ }
+
+ public unsafe NativePayloadResult WithdrawConsent()
+ {
+ byte* value = null;
+ var status = NativeMethods.mxc_telemetry_withdraw_consent(&value);
+ return Payload(status, value);
+ }
+
+ public unsafe NativePayloadResult RequestConsent(string? locale, Func presenter)
+ {
+ var localeBuffer = locale is null ? null : ToNullTerminatedUtf8(locale);
+ using var callback = new PresenterCallback(presenter);
+ fixed (byte* localePtr = localeBuffer)
+ {
+ byte* value = null;
+ var status = NativeMethods.mxc_telemetry_request_consent(
+ localePtr,
+ &PresentConsent,
+ (void*)GCHandle.ToIntPtr(callback.Handle),
+ &value);
+ return Payload(status, value);
+ }
+ }
+
+ private static unsafe NativePayloadResult Payload(int status, byte* value)
+ {
try
{
- return new(status, ReadNativeUtf8(value));
+ return new(status, value is null ? null : Marshal.PtrToStringUTF8((IntPtr)value));
}
finally
{
- FreeNativeString(value);
+ if (value is not null)
+ {
+ NativeMethods.mxc_string_free(value);
+ }
}
}
+ }
- public unsafe NativeBooleanResult NeedsConsentPrompt()
+ private sealed class PresenterCallback : IDisposable
+ {
+ private readonly Func presenter;
+ internal GCHandle Handle { get; }
+
+ internal PresenterCallback(Func presenter)
{
- int needsPrompt = 0;
- var status = NativeMethods.mxc_telemetry_needs_consent_prompt(&needsPrompt);
- return new(status, needsPrompt != 0);
+ this.presenter = presenter;
+ Handle = GCHandle.Alloc(this);
}
- public unsafe NativePayloadResult GetPolicy()
+ internal int Invoke(string prompt)
{
- byte* value = null;
- var status = NativeMethods.mxc_telemetry_get_policy(&value);
try
{
- return new(status, ReadNativeUtf8(value));
+ return presenter(prompt);
}
- finally
+ catch
{
- FreeNativeString(value);
+ return -1;
}
}
+
+ public void Dispose() => Handle.Free();
}
- internal static IDisposable OverrideTelemetryReadApiForTesting(
- ITelemetryReadApi replacement)
+ private static readonly ITelemetryNativeApi DefaultNativeApi = new PInvokeTelemetryNativeApi();
+ private static readonly FailureCategoryTracker DefaultFailureCategoryTracker = new(capacity: 64);
+ private static ITelemetryNativeApi nativeApi = DefaultNativeApi;
+ private static FailureCategoryTracker reportedFailures = DefaultFailureCategoryTracker;
+ private static bool? windowsHostOverride;
+
+ internal static IDisposable OverrideNativeApiForTesting(ITelemetryNativeApi replacement)
{
ArgumentNullException.ThrowIfNull(replacement);
if (!ReferenceEquals(
- Interlocked.CompareExchange(
- ref telemetryReadApi,
- replacement,
- DefaultTelemetryReadApi),
- DefaultTelemetryReadApi))
+ Interlocked.CompareExchange(ref nativeApi, replacement, DefaultNativeApi),
+ DefaultNativeApi))
{
throw new InvalidOperationException(
- "A telemetry read API test override is already active.");
+ "A telemetry native API test override is already active.");
}
- return new TelemetryReadApiScope(replacement);
+ return new NativeApiScope(replacement);
}
internal static IDisposable OverrideFailureCategoryTrackerForTesting(
@@ -196,7 +159,16 @@ internal static IDisposable OverrideFailureCategoryTrackerForTesting(
return new FailureCategoryTrackerScope(replacement);
}
- private sealed class TelemetryReadApiScope(ITelemetryReadApi replacement) : IDisposable
+ internal static IDisposable OverrideWindowsHostForTesting(bool isWindows)
+ {
+ var previous = windowsHostOverride;
+ windowsHostOverride = isWindows;
+ return new Scope(() => windowsHostOverride = previous);
+ }
+
+ private static bool IsWindowsHost => windowsHostOverride ?? OperatingSystem.IsWindows();
+
+ private sealed class NativeApiScope(ITelemetryNativeApi replacement) : IDisposable
{
private int disposed;
@@ -206,10 +178,7 @@ public void Dispose()
{
return;
}
- Interlocked.CompareExchange(
- ref telemetryReadApi,
- DefaultTelemetryReadApi,
- replacement);
+ Interlocked.CompareExchange(ref nativeApi, DefaultNativeApi, replacement);
}
}
@@ -231,6 +200,58 @@ public void Dispose()
}
}
+ private sealed class Scope(Action release) : IDisposable
+ {
+ private bool released;
+ public void Dispose()
+ {
+ if (!released)
+ {
+ released = true;
+ release();
+ }
+ }
+ }
+
+ static MxcTelemetry()
+ {
+ NativeLibraryResolver.Initialize();
+ }
+
+ ///
+ /// Read effective telemetry consent. Returns
+ /// off Windows and when consent cannot be
+ /// determined.
+ ///
+ public static TelemetryConsentState GetConsent()
+ {
+ if (!IsWindowsHost)
+ {
+ return TelemetryConsentState.NotApplicable;
+ }
+
+ try
+ {
+ var result = nativeApi.GetConsent();
+ EnsureSuccess(result.Status, "failed to read telemetry consent state");
+ return ParseConsentState(result.Payload, "GetConsent");
+ }
+ catch (Exception ex)
+ {
+ ReportFailClosed("GetConsent", "Undetermined", ex);
+ return TelemetryConsentState.Undetermined;
+ }
+ }
+
+ ///
+ /// Whether an exception indicates that the native library is unavailable.
+ ///
+ internal static bool IsNativeLoadFailure(Exception ex) =>
+ ex is DllNotFoundException
+ or EntryPointNotFoundException
+ or TypeInitializationException
+ or BadImageFormatException;
+
internal sealed class FailureCategoryTracker(int capacity)
{
private readonly HashSet categories = [];
@@ -264,353 +285,356 @@ internal void Remove(string operation, string safeResult, string kind, int code)
}
}
- private sealed class PresenterContext
+ internal static void ReportFailClosed(
+ string operation,
+ string safeResult,
+ Exception exception)
{
- public required Func Presenter { get; init; }
- public CancellationToken CancellationToken { get; init; }
+ var exceptionType = exception.GetType().FullName ?? exception.GetType().Name;
+ ReportFailClosedCore(
+ operation,
+ safeResult,
+ exceptionType,
+ exception.HResult,
+ errorCode: null,
+ safeDescription: null);
}
- ///
- /// Return the consent state currently effective for telemetry authorization.
- /// Use to read the persisted decision.
- /// Fail-closed native-load failures return .
- ///
- public static TelemetryConsentState GetConsent()
+ internal static void ReportFailClosed(
+ string operation,
+ string safeResult,
+ ErrorCode errorCode)
+ {
+ ReportFailClosedCore(
+ operation,
+ safeResult,
+ nameof(ErrorCode),
+ (int)errorCode,
+ errorCode,
+ safeDescription: null);
+ }
+
+ private static void ReportFailClosedCategory(
+ string operation,
+ string safeResult,
+ string category)
{
+ ReportFailClosedCore(
+ operation,
+ safeResult,
+ category,
+ code: 0,
+ errorCode: null,
+ safeDescription: category);
+ }
+
+ private static void ReportFailClosedCore(
+ string operation,
+ string safeResult,
+ string kind,
+ int code,
+ ErrorCode? errorCode,
+ string? safeDescription)
+ {
+ var registered = false;
try
{
- EnsureNativeInitialized();
- var result = telemetryReadApi.GetConsent();
- if (result.Status != (int)ErrorCode.Success)
+ if (!reportedFailures.TryAdd(operation, safeResult, kind, code))
{
- throw new MxcException(
- (ErrorCode)result.Status,
- "retrieving telemetry consent failed");
+ return;
}
+ registered = true;
- return ParseConsentState(RequirePayload(result.Payload, "telemetry consent"));
- }
- catch (MxcException)
- {
- throw;
+ var description = safeDescription ?? (errorCode is { } nativeError
+ ? $"{nativeError} ({code})"
+ : $"{kind} (HRESULT 0x{code:X8})");
+ Trace.TraceError(
+ "mxc: {0} failed and is reporting '{1}' to stay fail-closed: {2}",
+ operation,
+ safeResult,
+ description);
}
- catch (Exception ex)
+ catch
{
- ReportFailClosed("GetConsent", "Undetermined", ex);
- return TelemetryConsentState.Undetermined;
+ if (registered)
+ {
+ reportedFailures.Remove(operation, safeResult, kind, code);
+ }
+ // Diagnostics must not affect the fail-closed result.
}
}
///
- /// Read stored and effective consent, plus the current administrative policy.
+ /// Whether the host should show the first-run consent prompt.
+ ///
+ /// Returns on non-Windows hosts or any failure.
///
- public static TelemetryConsentStatus GetConsentStatus()
+ public static bool NeedsConsentPrompt()
{
- EnsureNativeInitialized();
- unsafe
+ if (!IsWindowsHost)
{
- byte* value = null;
- var status = NativeMethods.mxc_telemetry_get_consent_status(&value);
- try
- {
- EnsureSuccess(
- status,
- "retrieving telemetry consent status failed",
- ReadNativeUtf8(value));
- return ParseConsentStatus(ReadRequiredJson(value, "telemetry consent status"));
- }
- finally
+ return false;
+ }
+
+ try
+ {
+ var result = nativeApi.NeedsConsentPrompt();
+ if (result.Status != (int)ErrorCode.Success)
{
- FreeNativeString(value);
+ ReportFailClosed("NeedsConsentPrompt", "false", (ErrorCode)result.Status);
+ return false;
}
+ return result.Value;
+ }
+ catch (Exception ex)
+ {
+ ReportFailClosed("NeedsConsentPrompt", "false", ex);
+ return false;
}
}
///
- /// Invoke a host presenter, then persist its decision.
+ /// Read the administrative telemetry policy. Failures return
+ /// .
///
- public static TelemetryConsentOutcome RequestConsent(
- Func presenter,
- string? locale = null)
- {
- ArgumentNullException.ThrowIfNull(presenter);
- ValidateLocale(locale);
- return RequestConsentCore(presenter, locale, CancellationToken.None);
- }
-
- private static TelemetryConsentOutcome RequestConsentCore(
- Func presenter,
- string? locale,
- CancellationToken cancellationToken)
+ public static TelemetryPolicyState GetPolicy()
{
- EnsureNativeInitialized();
-
- var localeBuf = locale is null ? null : ToNullTerminatedUtf8(locale);
- var presenterContext = GCHandle.Alloc(new PresenterContext
+ if (!IsWindowsHost)
{
- Presenter = presenter,
- CancellationToken = cancellationToken,
- });
+ return TelemetryPolicyState.NotApplicable;
+ }
+
try
{
- unsafe
+ var result = nativeApi.GetPolicy();
+ if (result.Status != (int)ErrorCode.Success)
{
- fixed (byte* localePtr = localeBuf)
- {
- byte* value = null;
- var status = NativeMethods.mxc_telemetry_request_consent(
- localePtr,
- &PresentConsentBridge,
- (void*)GCHandle.ToIntPtr(presenterContext),
- &value);
- try
- {
- EnsureSuccess(
- status,
- "requesting telemetry consent failed",
- ReadNativeUtf8(value));
- return ParseConsentOutcome(ReadRequiredJson(value, "telemetry consent outcome"));
- }
- finally
- {
- FreeNativeString(value);
- }
- }
+ ReportFailClosed("GetPolicy", "Blocked", (ErrorCode)result.Status);
+ return TelemetryPolicyState.Blocked;
}
+ return ParsePolicyState(result.Payload, "GetPolicy");
+ }
+ catch (Exception ex)
+ {
+ ReportFailClosed("GetPolicy", "Blocked", ex);
+ return TelemetryPolicyState.Blocked;
}
- finally
+ }
+
+ private sealed class PresenterContext
+ {
+ internal required Func> Presenter { get; init; }
+ internal required CancellationToken CancellationToken { get; init; }
+ internal SynchronizationContext? SynchronizationContext { get; init; }
+ internal Exception? Error;
+ }
+
+ /// Request consent through a synchronous host presenter.
+ public static TelemetryConsentOutcome RequestConsent(
+ Func presenter,
+ string? locale = null)
+ {
+ ArgumentNullException.ThrowIfNull(presenter);
+ ValidateLocale(locale);
+ if (!IsWindowsHost)
{
- presenterContext.Free();
+ return NotApplicableOutcome();
}
+ return RequestConsentCore(
+ prompt => ValueTask.FromResult(presenter(prompt)),
+ locale,
+ CancellationToken.None,
+ synchronizationContext: null);
}
///
- /// Asynchronous wrapper over .
- /// The native API is synchronous and blocking, so the native work runs on the thread pool while
- /// the presenter is dispatched to the caller's synchronization context when one is available.
+ /// Request consent through an asynchronous host presenter.
///
///
- /// Cancellation is best-effort relative to persistence. It stops waiting for an unfinished
- /// presenter and returns a canceled task when native code observes the dismissal first. Once a
- /// completed presenter decision wins the race and native persistence begins, the persisted
- /// outcome is authoritative and is returned even if the token is canceled concurrently.
- /// Cancellation does not cancel the presenter's underlying task.
+ /// The native request runs on a worker thread. Await this method rather than
+ /// blocking a UI thread that the presenter needs to resume on. Cancellation is
+ /// best-effort relative to persistence: it stops waiting for an unfinished
+ /// presenter and returns a canceled task when native code observes the
+ /// dismissal first, but once a completed presenter decision wins the race and
+ /// native persistence begins, the persisted outcome is authoritative and is
+ /// returned even if the token is canceled concurrently. Cancellation does not
+ /// cancel the presenter's underlying operation.
///
public static Task RequestConsentAsync(
- Func> presenter,
+ Func> presenter,
string? locale = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(presenter);
ValidateLocale(locale);
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return Task.FromCanceled(cancellationToken);
+ }
+ if (!IsWindowsHost)
+ {
+ return Task.FromResult(NotApplicableOutcome());
+ }
var synchronizationContext = SynchronizationContext.Current;
- return Task.Run(
+ return Task.Factory.StartNew(
() =>
{
var outcome = RequestConsentCore(
- prompt => InvokePresenterWithCancellation(
- presenter,
- prompt,
- synchronizationContext,
- cancellationToken),
+ presenter,
locale,
- cancellationToken);
+ cancellationToken,
+ synchronizationContext);
return FinalizeAsyncOutcome(outcome, cancellationToken);
},
- cancellationToken);
+ cancellationToken,
+ TaskCreationOptions.LongRunning,
+ TaskScheduler.Default);
}
internal static TelemetryConsentOutcome FinalizeAsyncOutcome(
TelemetryConsentOutcome outcome,
CancellationToken cancellationToken)
{
- if (outcome.Result == TelemetryConsentResult.Dismissed)
+ if (outcome.Result == TelemetryConsentActionResult.Dismissed)
{
cancellationToken.ThrowIfCancellationRequested();
}
return outcome;
}
- /// Persist an idempotent telemetry-consent withdrawal.
- public static TelemetryConsentOutcome WithdrawConsent()
+ /// Read stored/effective consent and the administrative ceiling.
+ public static TelemetryConsentStatus GetConsentStatus()
{
- EnsureNativeInitialized();
- unsafe
+ if (!IsWindowsHost)
{
- byte* value = null;
- var status = NativeMethods.mxc_telemetry_withdraw_consent(&value);
- try
- {
- EnsureSuccess(
- status,
- "withdrawing telemetry consent failed",
- ReadNativeUtf8(value));
- return ParseConsentOutcome(ReadRequiredJson(value, "telemetry consent outcome"));
- }
- finally
- {
- FreeNativeString(value);
- }
+ return new(
+ TelemetryConsentState.NotApplicable,
+ TelemetryConsentState.NotApplicable,
+ TelemetryPolicyState.NotApplicable);
}
- }
- ///
- /// Whether a host should show its first-run consent prompt.
- /// Fails closed to and never throws.
- ///
- public static bool NeedsConsentPrompt()
- {
try
{
- EnsureNativeInitialized();
- var result = telemetryReadApi.NeedsConsentPrompt();
- if (result.Status != (int)ErrorCode.Success)
- {
- ReportFailClosed(
- "NeedsConsentPrompt",
- "false",
- (ErrorCode)result.Status);
- return false;
- }
-
- return result.Value;
+ var result = nativeApi.GetConsentStatus();
+ EnsureSuccess(result.Status, "failed to read telemetry consent status");
+ return ParseConsentStatus(result.Payload);
}
- catch (Exception ex)
+ catch (Exception ex) when (
+ ex is JsonException or KeyNotFoundException or InvalidOperationException)
{
- ReportFailClosed("NeedsConsentPrompt", "false", ex);
- return false;
+ ReportFailClosed("GetConsentStatus", "Undetermined/Blocked", ex);
+ return new(
+ TelemetryConsentState.Undetermined,
+ TelemetryConsentState.Undetermined,
+ TelemetryPolicyState.Blocked);
}
- }
-
- ///
- /// Read the administrative telemetry policy.
- /// Fails closed to and never throws.
- ///
- public static TelemetryPolicyState GetPolicy()
- {
- try
+ catch (MxcException ex)
{
- EnsureNativeInitialized();
- var result = telemetryReadApi.GetPolicy();
- if (result.Status != (int)ErrorCode.Success)
- {
- ReportFailClosed("GetPolicy", "Blocked", (ErrorCode)result.Status);
- return TelemetryPolicyState.Blocked;
- }
-
- return ParsePolicyState(RequirePayload(result.Payload, "telemetry policy"));
+ ReportFailClosed("GetConsentStatus", "Undetermined/Blocked", ex);
+ return new(
+ TelemetryConsentState.Undetermined,
+ TelemetryConsentState.Undetermined,
+ TelemetryPolicyState.Blocked);
}
catch (Exception ex)
{
- ReportFailClosed("GetPolicy", "Blocked", ex);
- return TelemetryPolicyState.Blocked;
+ ReportFailClosed("GetConsentStatus", "Undetermined/Blocked", ex);
+ return new(
+ TelemetryConsentState.Undetermined,
+ TelemetryConsentState.Undetermined,
+ TelemetryPolicyState.Blocked);
}
}
- internal static void ReportFailClosed(
- string operation,
- string safeResult,
- Exception exception)
- {
- var exceptionType = exception.GetType().FullName ?? exception.GetType().Name;
- ReportFailClosedCore(
- operation,
- safeResult,
- exceptionType,
- exception.HResult,
- errorCode: null);
- }
-
- internal static void ReportFailClosed(
- string operation,
- string safeResult,
- ErrorCode errorCode)
- {
- ReportFailClosedCore(
- operation,
- safeResult,
- nameof(ErrorCode),
- (int)errorCode,
- errorCode);
- }
-
- private static void ReportFailClosedCore(
- string operation,
- string safeResult,
- string kind,
- int code,
- ErrorCode? errorCode)
+ /// Idempotently withdraw telemetry consent.
+ public static TelemetryConsentOutcome WithdrawConsent()
{
- var registered = false;
+ if (!IsWindowsHost)
+ {
+ return NotApplicableOutcome();
+ }
try
{
- if (!reportedFailures.TryAdd(
- operation,
- safeResult,
- kind,
- code))
- {
- return;
- }
- registered = true;
-
- var description = errorCode is { } nativeError
- ? $"{nativeError} ({code})"
- : $"{kind} (HRESULT 0x{code:X8})";
- Trace.TraceError(
- "mxc: {0} failed and is reporting '{1}' to stay fail-closed: {2}",
- operation,
- safeResult,
- description);
+ var result = nativeApi.WithdrawConsent();
+ EnsureSuccess(result.Status, "failed to withdraw telemetry consent");
+ return ParseConsentOutcome(result.Payload, ConsentOutcomeOperation.Withdraw);
}
- catch
+ catch (Exception ex) when (ex is not MxcException)
{
- if (registered)
- {
- reportedFailures.Remove(
- operation,
- safeResult,
- kind,
- code);
- }
- // Diagnostics must not affect the fail-closed result.
+ throw new MxcException(
+ ErrorCode.BackendError,
+ "failed to withdraw telemetry consent",
+ ex);
}
}
- [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
- private static unsafe int PresentConsentBridge(byte* promptJsonUtf8, void* context)
+ private static TelemetryConsentOutcome RequestConsentCore(
+ Func> presenter,
+ string? locale,
+ CancellationToken cancellationToken,
+ SynchronizationContext? synchronizationContext)
{
try
{
- var handle = GCHandle.FromIntPtr((IntPtr)context);
- if (handle.Target is not PresenterContext presenterContext)
+ cancellationToken.ThrowIfCancellationRequested();
+ var context = new PresenterContext
{
- return NativeConsentPresenterError;
- }
-
- var prompt = ParseConsentPrompt(ReadRequiredJson(promptJsonUtf8, "telemetry consent prompt"));
- var decision = presenterContext.Presenter(prompt);
- if (presenterContext.CancellationToken.IsCancellationRequested)
+ Presenter = presenter,
+ CancellationToken = cancellationToken,
+ SynchronizationContext = synchronizationContext,
+ };
+ var result = nativeApi.RequestConsent(
+ locale,
+ promptJson =>
+ {
+ try
+ {
+ var prompt = ParseConsentPrompt(promptJson);
+ return InvokePresenterWithCancellation(
+ context.Presenter,
+ prompt,
+ context.SynchronizationContext,
+ context.CancellationToken) switch
+ {
+ TelemetryConsentDecision.No => 0,
+ TelemetryConsentDecision.Yes => 1,
+ TelemetryConsentDecision.Dismissed => 2,
+ _ => -1,
+ };
+ }
+ catch (Exception ex)
+ {
+ Interlocked.CompareExchange(ref context.Error, ex, null);
+ return -1;
+ }
+ });
+ if (context.Error is OperationCanceledException &&
+ cancellationToken.IsCancellationRequested)
{
- return NativeConsentDecisionDismissed;
+ cancellationToken.ThrowIfCancellationRequested();
}
-
- return decision switch
+ if (context.Error is not null)
{
- TelemetryConsentDecision.Yes => NativeConsentDecisionYes,
- TelemetryConsentDecision.No => NativeConsentDecisionNo,
- TelemetryConsentDecision.Dismissed => NativeConsentDecisionDismissed,
- _ => NativeConsentPresenterError,
- };
+ throw new MxcException(
+ ErrorCode.BackendError,
+ "telemetry consent presenter failed",
+ context.Error);
+ }
+ EnsureSuccess(result.Status, "failed to request telemetry consent");
+ return ParseConsentOutcome(result.Payload, ConsentOutcomeOperation.Request);
}
- catch
+ catch (Exception ex) when (
+ ex is not MxcException and not OperationCanceledException)
{
- return NativeConsentPresenterError;
+ throw new MxcException(
+ ErrorCode.BackendError,
+ "telemetry consent request failed",
+ ex);
}
}
private static TelemetryConsentDecision InvokePresenterWithCancellation(
- Func> presenter,
+ Func> presenter,
TelemetryConsentPrompt prompt,
SynchronizationContext? synchronizationContext,
CancellationToken cancellationToken)
@@ -618,7 +642,11 @@ private static TelemetryConsentDecision InvokePresenterWithCancellation(
Task? presenterTask = null;
try
{
- presenterTask = InvokePresenterAsync(presenter, prompt, synchronizationContext);
+ presenterTask = InvokePresenterAsync(
+ presenter,
+ prompt,
+ synchronizationContext,
+ cancellationToken);
return presenterTask
.WaitAsync(cancellationToken)
.GetAwaiter()
@@ -657,13 +685,15 @@ private static void ObserveAbandonedPresenterFailure(
}
private static Task InvokePresenterAsync(
- Func> presenter,
+ Func> presenter,
TelemetryConsentPrompt prompt,
- SynchronizationContext? synchronizationContext)
+ SynchronizationContext? synchronizationContext,
+ CancellationToken cancellationToken)
{
if (synchronizationContext is null)
{
- return presenter(prompt);
+ cancellationToken.ThrowIfCancellationRequested();
+ return presenter(prompt).AsTask();
}
var completion = new TaskCompletionSource(
@@ -673,6 +703,11 @@ private static Task InvokePresenterAsync(
synchronizationContext.Post(
async _ =>
{
+ if (cancellationToken.IsCancellationRequested)
+ {
+ completion.TrySetCanceled(cancellationToken);
+ return;
+ }
try
{
completion.SetResult(await presenter(prompt));
@@ -692,170 +727,316 @@ private static Task InvokePresenterAsync(
return completion.Task;
}
- private static void EnsureNativeInitialized() => NativeLibraryResolver.Initialize();
+ [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
+ private static unsafe int PresentConsent(byte* promptJsonUtf8, void* contextPointer)
+ {
+ try
+ {
+ var callback = (PresenterCallback)GCHandle.FromIntPtr((IntPtr)contextPointer).Target!;
+ return callback.Invoke(Marshal.PtrToStringUTF8((IntPtr)promptJsonUtf8) ?? string.Empty);
+ }
+ catch
+ {
+ return -1;
+ }
+ }
- private static void EnsureSuccess(int status, string fallbackMessage, string? nativeMessage)
+ private static void EnsureSuccess(int status, string message)
{
- if (status == (int)ErrorCode.Success)
+ if (status != (int)ErrorCode.Success)
{
- return;
+ throw new MxcException((ErrorCode)status, message);
}
+ }
+
+ private static TelemetryConsentOutcome NotApplicableOutcome() =>
+ new(
+ TelemetryConsentActionResult.NotApplicable,
+ TelemetryConsentState.NotApplicable,
+ TelemetryConsentState.NotApplicable,
+ TelemetryPolicyState.NotApplicable);
- throw new MxcException(
- (ErrorCode)status,
- string.IsNullOrWhiteSpace(nativeMessage) ? fallbackMessage : nativeMessage);
+ internal static TelemetryConsentPrompt ParseConsentPrompt(string? json)
+ {
+ using var document = JsonDocument.Parse(json ?? throw new JsonException("missing consent prompt"));
+ var root = document.RootElement;
+ return new(
+ root.GetProperty("resourceVersion").GetUInt32(),
+ ParseRequiredString(root, "locale", "locale"),
+ ParseConsentMessage(root.GetProperty("title"), "title"),
+ ParseConsentMessage(root.GetProperty("body"), "body"),
+ ParseConsentMessage(root.GetProperty("affirmativeLabel"), "affirmativeLabel"),
+ ParseConsentMessage(root.GetProperty("negativeLabel"), "negativeLabel"),
+ ParseConsentMessage(root.GetProperty("learnMoreLabel"), "learnMoreLabel"),
+ ParseRequiredString(root, "learnMoreUrl", "learnMoreUrl"));
}
- private static unsafe string ReadRequiredJson(byte* value, string description) =>
- ReadNativeUtf8(value) ?? throw new MxcException(ErrorCode.BackendError, $"{description} was missing");
+ private static TelemetryConsentMessage ParseConsentMessage(JsonElement value, string path) =>
+ new(
+ ParseRequiredString(value, "id", $"{path}.id"),
+ ParseRequiredString(value, "text", $"{path}.text"));
- private static string RequirePayload(string? value, string description) =>
- value ?? throw new InvalidOperationException($"{description} payload was missing");
+ private static string ParseRequiredString(
+ JsonElement parent,
+ string propertyName,
+ string path)
+ {
+ var value = parent.GetProperty(propertyName);
+ if (value.ValueKind != JsonValueKind.String)
+ {
+ throw new JsonException(
+ $"Telemetry consent prompt field '{path}' must be a string.");
+ }
- private static unsafe string? ReadNativeUtf8(byte* value) =>
- value is null ? null : Marshal.PtrToStringUTF8((IntPtr)value);
+ return value.GetString()!;
+ }
- private static unsafe void FreeNativeString(byte* value)
+ private static TelemetryConsentStatus ParseConsentStatus(string? json)
{
- if (value is not null)
+ using var document = JsonDocument.Parse(json ?? throw new JsonException("missing consent status"));
+ var root = document.RootElement;
+ if (!IsKnownConsentStatusReason(root.GetProperty("reason"), "GetConsentStatus"))
{
- NativeMethods.mxc_string_free(value);
+ return FailClosedStatus();
}
+ if (!TryParseConsentFields(
+ root,
+ "GetConsentStatus",
+ out var storedState,
+ out var effectiveState,
+ out var policy))
+ {
+ return FailClosedStatus();
+ }
+ return new(storedState, effectiveState, policy);
}
- private static byte[] ToNullTerminatedUtf8(string value)
+ private enum ConsentOutcomeOperation
{
- var byteCount = Encoding.UTF8.GetByteCount(value);
- var buffer = new byte[byteCount + 1];
- Encoding.UTF8.GetBytes(value, 0, value.Length, buffer, 0);
- buffer[byteCount] = 0;
- return buffer;
+ Request,
+ Withdraw,
}
- private static void ValidateLocale(string? locale)
+ private static TelemetryConsentOutcome ParseConsentOutcome(
+ string? json,
+ ConsentOutcomeOperation operation)
{
- if (locale?.Contains('\0') == true)
+ using var document = JsonDocument.Parse(json ?? throw new JsonException("missing consent outcome"));
+ var root = document.RootElement;
+ var result = ParseConsentActionResult(root.GetProperty("result").GetString());
+ var operationName = $"{operation}Consent";
+ if (!IsResultForOperation(result, operation) ||
+ !IsKnownConsentStatusReason(root.GetProperty("reason"), operationName))
{
- throw new ArgumentException(
- "Telemetry consent locale cannot contain embedded NUL characters.",
- nameof(locale));
+ return FailClosedOutcome();
}
+ if (!TryParseConsentFields(
+ root,
+ operationName,
+ out var storedState,
+ out var effectiveState,
+ out var policy))
+ {
+ return FailClosedOutcome();
+ }
+ return new(result, storedState, effectiveState, policy);
}
- internal static TelemetryConsentPrompt ParseConsentPrompt(string json)
+ private static bool IsResultForOperation(
+ TelemetryConsentActionResult result,
+ ConsentOutcomeOperation operation)
{
- using var doc = JsonDocument.Parse(json);
- var root = doc.RootElement;
- return new TelemetryConsentPrompt
+ var valid = operation switch
{
- ResourceVersion = root.GetProperty("resourceVersion").GetUInt32(),
- Locale = ParseRequiredString(root, "locale", "locale"),
- Title = ParseConsentMessage(root.GetProperty("title"), "title"),
- Body = ParseConsentMessage(root.GetProperty("body"), "body"),
- AffirmativeLabel = ParseConsentMessage(
- root.GetProperty("affirmativeLabel"),
- "affirmativeLabel"),
- NegativeLabel = ParseConsentMessage(
- root.GetProperty("negativeLabel"),
- "negativeLabel"),
- LearnMoreLabel = ParseConsentMessage(
- root.GetProperty("learnMoreLabel"),
- "learnMoreLabel"),
- LearnMoreUrl = ParseRequiredString(root, "learnMoreUrl", "learnMoreUrl"),
+ ConsentOutcomeOperation.Request =>
+ result is TelemetryConsentActionResult.Granted
+ or TelemetryConsentActionResult.Denied
+ or TelemetryConsentActionResult.Dismissed
+ or TelemetryConsentActionResult.AlreadyGranted
+ or TelemetryConsentActionResult.PolicyBlocked
+ or TelemetryConsentActionResult.NotApplicable,
+ ConsentOutcomeOperation.Withdraw =>
+ result is TelemetryConsentActionResult.Withdrawn
+ or TelemetryConsentActionResult.NotApplicable,
+ _ => false,
};
+ if (!valid && result != TelemetryConsentActionResult.Unknown)
+ {
+ ReportFailClosedCategory(
+ $"{operation}Consent",
+ "Unknown",
+ "native returned an invalid consent result");
+ }
+ return valid;
}
- private static TelemetryConsentMessage ParseConsentMessage(JsonElement value, string path) => new()
+ private static TelemetryConsentStatus FailClosedStatus() =>
+ new(
+ TelemetryConsentState.Undetermined,
+ TelemetryConsentState.Undetermined,
+ TelemetryPolicyState.Blocked);
+
+ private static TelemetryConsentOutcome FailClosedOutcome() =>
+ new(
+ TelemetryConsentActionResult.Unknown,
+ TelemetryConsentState.Undetermined,
+ TelemetryConsentState.Undetermined,
+ TelemetryPolicyState.Blocked);
+
+ private static bool TryParseConsentFields(
+ JsonElement root,
+ string operation,
+ out TelemetryConsentState storedState,
+ out TelemetryConsentState effectiveState,
+ out TelemetryPolicyState policy)
{
- Id = ParseRequiredString(value, "id", $"{path}.id"),
- Text = ParseRequiredString(value, "text", $"{path}.text"),
- };
+ storedState = TelemetryConsentState.Undetermined;
+ effectiveState = TelemetryConsentState.Undetermined;
+ policy = TelemetryPolicyState.Blocked;
- private static string ParseRequiredString(
- JsonElement parent,
- string propertyName,
- string path)
- {
- var value = parent.GetProperty(propertyName);
- if (value.ValueKind != JsonValueKind.String)
+ var storedValue = root.GetProperty("storedState").GetString();
+ var parsedStoredState = ParseKnownConsentState(storedValue);
+ if (parsedStoredState is null)
{
- throw new JsonException(
- $"Telemetry consent prompt field '{path}' must be a string.");
+ _ = UnrecognizedConsentState(storedValue, operation);
+ return false;
}
- return value.GetString()!;
- }
+ var effectiveValue = root.GetProperty("effectiveState").GetString();
+ var parsedEffectiveState = ParseKnownConsentState(effectiveValue);
+ if (parsedEffectiveState is null)
+ {
+ _ = UnrecognizedConsentState(effectiveValue, operation);
+ return false;
+ }
- private static TelemetryConsentStatus ParseConsentStatus(string json)
- {
- using var doc = JsonDocument.Parse(json);
- return ParseConsentStatus(doc.RootElement);
+ var policyValue = root.GetProperty("policy").GetString();
+ var parsedPolicy = ParseKnownPolicyState(policyValue);
+ if (parsedPolicy is null)
+ {
+ _ = UnrecognizedPolicyState(policyValue, operation);
+ return false;
+ }
+
+ storedState = parsedStoredState.Value;
+ effectiveState = parsedEffectiveState.Value;
+ policy = parsedPolicy.Value;
+ return true;
}
- private static TelemetryConsentStatus ParseConsentStatus(JsonElement root) => new()
+ private static TelemetryConsentActionResult ParseConsentActionResult(string? value) => value switch
{
- StoredState = ParseConsentState(root.GetProperty("storedState").GetString() ?? string.Empty),
- EffectiveState = ParseConsentState(root.GetProperty("effectiveState").GetString() ?? string.Empty),
- Reason = root.TryGetProperty("reason", out var reason) && reason.ValueKind != JsonValueKind.Null
- ? ParseConsentStatusReason(reason.GetString() ?? string.Empty)
- : null,
- Policy = ParsePolicyState(root.GetProperty("policy").GetString() ?? string.Empty),
+ "granted" => TelemetryConsentActionResult.Granted,
+ "denied" => TelemetryConsentActionResult.Denied,
+ "dismissed" => TelemetryConsentActionResult.Dismissed,
+ "withdrawn" => TelemetryConsentActionResult.Withdrawn,
+ "alreadyGranted" => TelemetryConsentActionResult.AlreadyGranted,
+ "policyBlocked" => TelemetryConsentActionResult.PolicyBlocked,
+ "notApplicable" => TelemetryConsentActionResult.NotApplicable,
+ _ => UnrecognizedConsentActionResult(value),
};
- private static TelemetryConsentOutcome ParseConsentOutcome(string json)
+ private static TelemetryConsentActionResult UnrecognizedConsentActionResult(string? value)
{
- using var doc = JsonDocument.Parse(json);
- var root = doc.RootElement;
- var status = ParseConsentStatus(root);
- return new TelemetryConsentOutcome
+ ReportFailClosedCategory(
+ "ConsentOutcome",
+ "Unknown",
+ "native returned an unrecognized consent action result");
+ return TelemetryConsentActionResult.Unknown;
+ }
+
+ private static bool IsKnownConsentStatusReason(
+ JsonElement value,
+ string operation)
+ {
+ if (value.ValueKind == JsonValueKind.Null)
+ {
+ return true;
+ }
+
+ return value.GetString() switch
{
- Result = ParseConsentResult(root.GetProperty("result").GetString() ?? string.Empty),
- StoredState = status.StoredState,
- EffectiveState = status.EffectiveState,
- Reason = status.Reason,
- Policy = status.Policy,
+ "no-record" or
+ "store-unreadable" or
+ "store-malformed" or
+ "consent-schema-unsupported" or
+ "prompt-version-missing" or
+ "prompt-version-unsupported" or
+ "policy-blocked" or
+ "presentation-unavailable" or
+ "not-applicable" => true,
+ var reason => ReportUnrecognizedConsentStatusReason(reason, operation),
};
}
- private static TelemetryConsentState ParseConsentState(string value) => value switch
+ private static bool ReportUnrecognizedConsentStatusReason(
+ string? value,
+ string operation)
+ {
+ ReportFailClosedCategory(
+ operation,
+ "Unknown",
+ "native returned an unrecognized consent status reason");
+ return false;
+ }
+
+ ///
+ /// Map the native consent string, failing closed for unknown values.
+ ///
+ private static TelemetryConsentState? ParseKnownConsentState(string? value) => value switch
{
"granted" => TelemetryConsentState.Granted,
"denied" => TelemetryConsentState.Denied,
"undetermined" => TelemetryConsentState.Undetermined,
"not-applicable" => TelemetryConsentState.NotApplicable,
- _ => throw new JsonException($"unknown telemetry consent state '{value}'"),
+ _ => null,
};
- private static TelemetryPolicyState ParsePolicyState(string value) => value switch
+ private static TelemetryConsentState ParseConsentState(string? value, string operation) =>
+ ParseKnownConsentState(value) ?? UnrecognizedConsentState(value, operation);
+
+ private static TelemetryConsentState UnrecognizedConsentState(string? value, string operation)
+ {
+ ReportFailClosedCategory(
+ operation,
+ "Undetermined",
+ "native returned an unrecognized consent state");
+ return TelemetryConsentState.Undetermined;
+ }
+
+ ///
+ /// Map the native policy string, failing closed for unknown values.
+ ///
+ private static TelemetryPolicyState? ParseKnownPolicyState(string? value) => value switch
{
"unrestricted" => TelemetryPolicyState.Unrestricted,
"allowed" => TelemetryPolicyState.Allowed,
"blocked" => TelemetryPolicyState.Blocked,
"not-applicable" => TelemetryPolicyState.NotApplicable,
- _ => throw new JsonException($"unknown telemetry policy state '{value}'"),
+ _ => null,
};
- private static TelemetryConsentStatusReason ParseConsentStatusReason(string value) => value switch
+ private static TelemetryPolicyState ParsePolicyState(string? value, string operation) =>
+ ParseKnownPolicyState(value) ?? UnrecognizedPolicyState(value, operation);
+
+ private static TelemetryPolicyState UnrecognizedPolicyState(string? value, string operation)
{
- "no-record" => TelemetryConsentStatusReason.NoRecord,
- "store-unreadable" => TelemetryConsentStatusReason.StoreUnreadable,
- "store-malformed" => TelemetryConsentStatusReason.StoreMalformed,
- "consent-schema-unsupported" => TelemetryConsentStatusReason.ConsentSchemaUnsupported,
- "prompt-version-missing" => TelemetryConsentStatusReason.PromptVersionMissing,
- "prompt-version-unsupported" => TelemetryConsentStatusReason.PromptVersionUnsupported,
- "not-applicable" => TelemetryConsentStatusReason.NotApplicable,
- _ => throw new JsonException($"unknown telemetry consent status reason '{value}'"),
- };
+ ReportFailClosedCategory(
+ operation,
+ "Blocked",
+ "native returned an unrecognized policy state");
+ return TelemetryPolicyState.Blocked;
+ }
- private static TelemetryConsentResult ParseConsentResult(string value) => value switch
+ private static void ValidateLocale(string? locale)
{
- "granted" => TelemetryConsentResult.Granted,
- "denied" => TelemetryConsentResult.Denied,
- "dismissed" => TelemetryConsentResult.Dismissed,
- "withdrawn" => TelemetryConsentResult.Withdrawn,
- "alreadyGranted" => TelemetryConsentResult.AlreadyGranted,
- "policyBlocked" => TelemetryConsentResult.PolicyBlocked,
- "notApplicable" => TelemetryConsentResult.NotApplicable,
- _ => throw new JsonException($"unknown telemetry consent result '{value}'"),
- };
+ if (locale?.Contains('\0') == true)
+ {
+ throw new ArgumentException(
+ "Telemetry consent locale cannot contain embedded NUL characters.",
+ nameof(locale));
+ }
+ }
+
+ private static byte[] ToNullTerminatedUtf8(string value) => System.Text.Encoding.UTF8.GetBytes(value + "\0");
}
diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeLibraryResolver.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeLibraryResolver.cs
index dfb339a37..625d3a204 100644
--- a/sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeLibraryResolver.cs
+++ b/sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeLibraryResolver.cs
@@ -20,7 +20,7 @@ internal static class NativeLibraryResolver
///
/// Register the resolver once. Called from the static constructor of the
- /// SDK's public entry point so it runs before the first P/Invoke.
+ /// SDK's public entry points so it runs before the first P/Invoke.
///
internal static void Initialize()
{
@@ -29,7 +29,22 @@ internal static void Initialize()
return;
}
- NativeLibrary.SetDllImportResolver(typeof(NativeLibraryResolver).Assembly, Resolve);
+ try
+ {
+ NativeLibrary.SetDllImportResolver(typeof(NativeLibraryResolver).Assembly, Resolve);
+ }
+ catch (Exception ex)
+ {
+ try
+ {
+ Console.Error.WriteLine(
+ $"mxc: could not register the native library resolver ({ex.GetType().Name}: {ex.Message}). " +
+ "Falling back to the default loader.");
+ }
+ catch
+ {
+ }
+ }
}
private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
@@ -54,7 +69,6 @@ private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSe
private static IEnumerable CandidatePaths()
{
var file = NativeFileName();
-
var overrideDir = Environment.GetEnvironmentVariable("MXC_FFI_DIR");
if (!string.IsNullOrEmpty(overrideDir))
{
@@ -65,11 +79,21 @@ private static IEnumerable CandidatePaths()
yield return Path.Combine(baseDir, file);
yield return Path.Combine(baseDir, "runtimes", RuntimeInformation.RuntimeIdentifier, "native", file);
- // Dev layout: walk up looking for the Cargo target dir.
- for (var dir = new DirectoryInfo(baseDir); dir is not null; dir = dir.Parent)
+ var dir = new DirectoryInfo(baseDir);
+ var depth = 0;
+ while (dir is not null && depth++ < 8)
{
- yield return Path.Combine(dir.FullName, "src", "target", "debug", file);
- yield return Path.Combine(dir.FullName, "src", "target", "release", file);
+ if (File.Exists(Path.Combine(dir.FullName, ".git")) ||
+ File.Exists(Path.Combine(dir.FullName, "src", "Cargo.toml")))
+ {
+#if DEBUG
+ yield return Path.Combine(dir.FullName, "src", "target", "debug", file);
+#endif
+ yield return Path.Combine(dir.FullName, "src", "target", "release", file);
+ yield break;
+ }
+
+ dir = dir.Parent;
}
}
diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs
index d37df55cf..8b7a0b1d4 100644
--- a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs
+++ b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs
@@ -77,7 +77,10 @@ internal SandboxPolicy WithoutLegacyCaptureDenials()
/// Telemetry section of a .
public sealed class TelemetrySettings
{
- /// Whether this invocation opts telemetry on, subject to consent and policy.
+ ///
+ /// Opt this invocation into telemetry, subject to persisted user consent
+ /// and administrative policy.
+ ///
[JsonPropertyName("enabled")]
public bool Enabled { get; set; }
}
diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs
index 3460420ac..3a09d1bdd 100644
--- a/sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs
+++ b/sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs
@@ -71,6 +71,12 @@ public abstract class StateAwareProvisionOptions
{
/// Overrides the backend's default state-aware schema version.
public string? Version { get; set; }
+
+ ///
+ /// Optional per-phase telemetry request. Emission is still gated by the
+ /// MXC-owned user consent and administrative policy.
+ ///
+ public TelemetrySettings? Telemetry { get; set; }
}
/// IsolationSession provision options.
@@ -160,6 +166,12 @@ public class StateAwarePhaseOptions
{
/// Overrides the schema version inferred from the sandbox id.
public string? Version { get; set; }
+
+ ///
+ /// Optional per-phase telemetry request. Emission is still gated by the
+ /// MXC-owned user consent and administrative policy.
+ ///
+ public TelemetrySettings? Telemetry { get; set; }
}
/// Process and schema options for a state-aware exec phase.
diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryConsent.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryConsent.cs
new file mode 100644
index 000000000..c42882624
--- /dev/null
+++ b/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryConsent.cs
@@ -0,0 +1,52 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.Mxc.Sdk;
+
+/// An independently localizable canonical consent message.
+public sealed record TelemetryConsentMessage(string Id, string Text);
+
+/// The complete Rust-owned consent resource a host must render verbatim.
+public sealed record TelemetryConsentPrompt(
+ uint ResourceVersion,
+ string Locale,
+ TelemetryConsentMessage Title,
+ TelemetryConsentMessage Body,
+ TelemetryConsentMessage AffirmativeLabel,
+ TelemetryConsentMessage NegativeLabel,
+ TelemetryConsentMessage LearnMoreLabel,
+ string LearnMoreUrl);
+
+/// The explicit result returned by a host consent presenter.
+public enum TelemetryConsentDecision
+{
+ No,
+ Yes,
+ Dismissed,
+}
+
+/// Result of a consent request or withdrawal.
+public enum TelemetryConsentActionResult
+{
+ Unknown = 0,
+ Granted = 1,
+ Denied = 2,
+ Dismissed = 3,
+ Withdrawn = 4,
+ AlreadyGranted = 5,
+ PolicyBlocked = 6,
+ NotApplicable = 7,
+}
+
+/// Persisted and effective consent together with the policy ceiling.
+public sealed record TelemetryConsentStatus(
+ TelemetryConsentState StoredState,
+ TelemetryConsentState EffectiveState,
+ TelemetryPolicyState Policy);
+
+/// Result and resulting status of a consent-changing operation.
+public sealed record TelemetryConsentOutcome(
+ TelemetryConsentActionResult Result,
+ TelemetryConsentState StoredState,
+ TelemetryConsentState EffectiveState,
+ TelemetryPolicyState Policy);
diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryConsentState.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryConsentState.cs
new file mode 100644
index 000000000..0ea58fad7
--- /dev/null
+++ b/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryConsentState.cs
@@ -0,0 +1,31 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.Mxc.Sdk;
+
+///
+/// Telemetry consent state used for both persisted and effective values.
+/// Telemetry is collected only when the effective state is .
+///
+public enum TelemetryConsentState
+{
+ ///
+ /// Consent is unavailable or cannot currently authorize telemetry. Treated
+ /// as for gating; use MxcTelemetry.NeedsConsentPrompt()
+ /// to determine whether to present consent.
+ ///
+ Undetermined = 0,
+
+ /// Consent is granted.
+ Granted = 1,
+
+ /// Consent is denied.
+ Denied = 2,
+
+ ///
+ /// Not a Windows host. MXC does not collect telemetry here, so consent is not
+ /// a meaningful concept — hosts must not offer a consent prompt at all on
+ /// these platforms.
+ ///
+ NotApplicable = 3,
+}
diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryPolicyState.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryPolicyState.cs
new file mode 100644
index 000000000..2c6966059
--- /dev/null
+++ b/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryPolicyState.cs
@@ -0,0 +1,53 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.Mxc.Sdk;
+
+///
+/// The administrative (MDM / Group Policy) telemetry decision for this machine.
+/// See docs/telemetry/telemetry-administrative-policy.md for the admin-facing reference.
+///
+/// An administrator can disable MXC telemetry machine-wide via Intune, another
+/// MDM, or Group Policy. The policy is a ceiling, never a grant: an
+/// administrator who permits telemetry has not consented on the user's behalf,
+/// so an explicit is still
+/// required before anything is collected.
+///
+/// MXC deliberately does not read the Windows-wide diagnostic data setting:
+/// Microsoft's Policy CSP documentation scopes that policy to Windows itself
+/// and states it does not apply to additional installed apps, and the Windows
+/// privacy guidance for app-classified components requires them to own their
+/// own notice and consent experience.
+///
+public enum TelemetryPolicyState
+{
+ ///
+ /// An administrator has denied MXC telemetry, the configured policy value
+ /// could not be understood, or the policy could not be determined at all.
+ /// Nothing is collected regardless of user consent, and hosts must not
+ /// offer a consent prompt.
+ ///
+ /// Because this state also covers "could not be determined", a host should
+ /// word its UI as "telemetry is unavailable on this device" rather than
+ /// asserting that an administrator is responsible.
+ ///
+ Blocked = 0,
+
+ ///
+ /// No administrative policy is configured. Telemetry is governed solely by
+ /// the user's own consent decision. This is not a grant.
+ ///
+ Unrestricted = 1,
+
+ ///
+ /// An administrator has permitted the optional (usage) telemetry category
+ /// MXC emits. Still requires user consent before anything is collected.
+ ///
+ Allowed = 2,
+
+ ///
+ /// Not a Windows host. MXC collects no telemetry here, so administrative
+ /// policy is not a meaningful concept.
+ ///
+ NotApplicable = 3,
+}
diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md
index f0c91946a..212b6ab30 100644
--- a/sdk/dotnet/README.md
+++ b/sdk/dotnet/README.md
@@ -494,15 +494,51 @@ sanitized technical signatures. It does not include commands, credentials,
complete file paths, usernames, sandbox output, raw ETL, or general logger
text. See the [telemetry data inventory](../../docs/telemetry/telemetry.md#data-inventory).
-The .NET consent APIs are UI-agnostic: `MxcTelemetry.RequestConsent` supplies
-the canonical resource to a host callback, while `GetConsentStatus`,
-`NeedsConsentPrompt`, and `WithdrawConsent` provide maintenance operations.
-They persist only explicit yes/no decisions and treat dismissal and failures
-as non-grants. See
+`MxcTelemetry` is UI-agnostic: it passes the canonical prompt to your
+presenter and persists only the typed decision you return.
+`GetConsentStatus`, `NeedsConsentPrompt`, and `WithdrawConsent` provide the
+remaining maintenance operations. Dismissal and failures never grant consent.
+For presenter rules and consent semantics, see
[`docs/telemetry/telemetry-consent-design.md`](../../docs/telemetry/telemetry-consent-design.md)
and its
[SDK presenter requirements](../../docs/telemetry/telemetry-consent-design.md#sdk-presenter-requirements).
+Per-invocation opt-in:
+- One-shot (`Run`/`Spawn`): set
+ `SandboxPolicy.Telemetry = new TelemetrySettings { Enabled = true }`.
+ An explicit policy version must be `0.9.0-alpha` or later.
+- State-aware phases: set each phase's
+ `Telemetry = new TelemetrySettings { Enabled = true }` independently.
+ `ProvisionResult` contains the sandbox identity used by later phases; no
+ telemetry context needs to be forwarded between phases.
+
+These switches do not bypass persisted consent or administrative policy.
+
+```csharp
+using Microsoft.Mxc.Sdk;
+
+var outcome = MxcTelemetry.RequestConsent(prompt =>
+{
+ return ShowTelemetryConsentDialog(
+ title: prompt.Title.Text,
+ body: prompt.Body.Text,
+ affirmativeLabel: prompt.AffirmativeLabel.Text,
+ negativeLabel: prompt.NegativeLabel.Text,
+ learnMoreLabel: prompt.LearnMoreLabel.Text,
+ learnMoreUrl: prompt.LearnMoreUrl);
+});
+// ShowTelemetryConsentDialog returns Yes, No, or Dismissed from the user's
+// action; closing or cancelling the dialog must return Dismissed.
+
+TelemetryConsentStatus status = MxcTelemetry.GetConsentStatus();
+MxcTelemetry.WithdrawConsent();
+```
+
+`RequestConsentAsync` accepts an asynchronous presenter. If consent is never
+requested, the presenter fails, or it returns `Dismissed`, telemetry remains
+off. On non-Windows hosts requests and withdrawals return `NotApplicable`
+without invoking a presenter.
+
`RequestConsentAsync` cancellation is best-effort relative to persistence. It
stops waiting for an unfinished presenter, but once a completed decision wins
the race and native persistence begins, the persisted outcome is authoritative
@@ -511,16 +547,25 @@ cancel the host's presenter task.
### Administrative policy
-An IT administrator can still block MXC telemetry device-wide via MXC's own
-registry policy setting. See
-[`docs/telemetry/telemetry-administrative-policy.md`](../../docs/telemetry/telemetry-administrative-policy.md)
-for the stable registry contract and interaction rules. Read-only queries fail
-closed rather than upgrading an unreadable device state into collection. When
-that fallback hides a native or parsing failure, the SDK reports a bounded set
-of distinct failure signatures through `System.Diagnostics.Trace`. Each
-signature is reported once; if a listener rejects it, a future occurrence may
-try again. Reports include the exception type and HRESULT or native error code,
-but exclude exception messages and stack traces.
+An administrator can block MXC telemetry device-wide through MXC's registry
+policy. `MxcTelemetry.GetPolicy()` reports the result:
+
+```csharp
+if (MxcTelemetry.GetPolicy() == TelemetryPolicyState.Blocked)
+{
+ // Don't show a consent toggle; telemetry is unavailable on this device.
+}
+```
+
+`Allowed` does not grant user consent, while `Blocked` disables collection and
+the consent prompt. Failures return a fail-closed state and never grant
+consent; non-Windows hosts return `NotApplicable`. When a read-only fallback
+hides a native or parsing failure, the SDK reports a bounded set of distinct
+failure signatures through `System.Diagnostics.Trace`. Each signature is
+reported once; if a listener rejects it, a future occurrence may try again.
+Reports include the exception type and HRESULT or native error code, but exclude
+exception messages and stack traces. See
+[`docs/telemetry/telemetry-administrative-policy.md`](../../docs/telemetry/telemetry-administrative-policy.md).
## Projects
diff --git a/sdk/node/README.md b/sdk/node/README.md
index 257bf4bd3..ddce52994 100644
--- a/sdk/node/README.md
+++ b/sdk/node/README.md
@@ -480,6 +480,11 @@ getAvailableToolsPolicy(env?, options?) → FilesystemPolicyResult
getUserProfilePolicy() → FilesystemPolicyResult
getTemporaryFilesPolicy(env?) → FilesystemPolicyResult
+// Telemetry consent (Windows-only; see Telemetry Consent section below)
+queryTelemetryConsentAsync() → Promise<{ storedState, effectiveState, needsPrompt, policy, error? }>
+requestTelemetryConsent(presenter, locale?) → Promise
+withdrawTelemetryConsentAsync() → Promise
+
// Capability types
UiCapabilitySupport
@@ -496,7 +501,11 @@ Full TypeScript definitions ship with the package (`dist/index.d.ts`). All expor
## Telemetry Consent
-Telemetry is off-by-default unless the caller opts in with top-level `telemetry.enabled: true` and the applicable Windows consent/policy gates permit collection.
+MXC only ever collects telemetry on Windows, and only after the end user has
+explicitly opted in — a persisted, MXC-owned consent flag gates every
+emission (never a Windows-level setting like Diagnostics & feedback). See
+[`docs/telemetry/telemetry-consent-design.md`](https://github.com/microsoft/mxc/blob/main/docs/telemetry/telemetry-consent-design.md)
+for the full design.
When a telemetry-enabled Windows ProcessContainer run successfully produces a
Learning Mode `captureDenials` verbose artifact, telemetry can include its
@@ -505,19 +514,80 @@ complete file paths, usernames, sandbox output, raw ETL, or general logger
text. See the
[telemetry data inventory](https://github.com/microsoft/mxc/blob/main/docs/telemetry/telemetry.md#data-inventory).
-Telemetry consent behavior follows
-[`docs/telemetry/telemetry-consent-design.md`](https://github.com/microsoft/mxc/blob/main/docs/telemetry/telemetry-consent-design.md):
-the SDK stays UI-agnostic, renders the canonical resource verbatim through a
-host presenter, persists only explicit yes/no decisions, treats dismissal and
-failures as non-grants, and never lets policy or transport failures opt a user
-in.
+Telemetry is additionally off unless a one-shot `SandboxPolicy` or
+`ContainerConfig` using schema 0.9 or later includes
+`telemetry: { enabled: true }`, or a state-aware call passes
+`config.telemetry: { enabled: true }`. This switch does not require
+`options.experimental` and cannot bypass consent or administrative policy.
+
+The SDK does not ship a consent UI. It passes the canonical prompt to your
+presenter. Render its supplied fields verbatim and return a typed decision.
+Follow the
+[SDK presenter requirements](https://github.com/microsoft/mxc/blob/main/docs/telemetry/telemetry-consent-design.md#sdk-presenter-requirements)
+for control mappings, dismissal behavior, and withdrawal:
+
+```typescript
+import {
+ requestTelemetryConsent,
+ queryTelemetryConsentAsync,
+ withdrawTelemetryConsentAsync,
+} from '@microsoft/mxc-sdk';
+
+const outcome = await requestTelemetryConsent(
+ (prompt, signal) => showTelemetryConsentDialog({
+ title: prompt.title.text,
+ body: prompt.body.text,
+ affirmativeLabel: prompt.affirmativeLabel.text,
+ negativeLabel: prompt.negativeLabel.text,
+ learnMoreLabel: prompt.learnMoreLabel.text,
+ learnMoreUrl: prompt.learnMoreUrl,
+ signal,
+ }),
+ 'en-US',
+);
+// showTelemetryConsentDialog returns 'yes', 'no', or 'dismissed' from the
+// user's action and dismisses pending UI if signal is aborted.
+
+const status = await queryTelemetryConsentAsync();
+await withdrawTelemetryConsentAsync();
+```
+
+If the API is never called, the presenter fails, or it returns `dismissed`,
+telemetry remains off. On non-Windows hosts requests and withdrawals return
+`notApplicable` without invoking the presenter.
+
+`queryTelemetryConsentAsync()` fails closed to `'undetermined'` rather than
+`'granted'`. Its `error` field is present when the command fails or returns an
+invalid response. A valid native fail-closed response can return
+`'undetermined'` or a blocked policy without `error`; any accompanying native
+diagnostic is reported once through `console.warn`:
+
+```typescript
+const { effectiveState, storedState, needsPrompt, policy, error } =
+ await queryTelemetryConsentAsync();
+if (error) {
+ console.warn(`mxc: could not read telemetry consent: ${error}`);
+}
+```
### Administrative policy
-An IT administrator can still block MXC telemetry device-wide via MXC's own
-registry policy setting. See
-[`docs/telemetry/telemetry-administrative-policy.md`](https://github.com/microsoft/mxc/blob/main/docs/telemetry/telemetry-administrative-policy.md)
-for the stable registry contract and interaction rules.
+An IT administrator can block MXC telemetry device-wide via MXC's own
+Group Policy / MDM setting. The query result's `policy` field reports the
+result:
+
+```typescript
+const { policy } = await queryTelemetryConsentAsync();
+// 'unrestricted' | 'allowed' | 'blocked' | 'not-applicable'
+if (policy === 'blocked') {
+ // Don't show a consent toggle; telemetry is unavailable on this device.
+}
+```
+
+`'allowed'` does not grant user consent, while `'blocked'` disables collection
+and the consent prompt. An unreadable or missing `policy` field reads back as
+`'blocked'`; non-Windows hosts return `'not-applicable'`. See
+[`docs/telemetry/telemetry-administrative-policy.md`](https://github.com/microsoft/mxc/blob/main/docs/telemetry/telemetry-administrative-policy.md).
---
diff --git a/sdk/node/package.json b/sdk/node/package.json
index e446f5b22..bfb4535e5 100644
--- a/sdk/node/package.json
+++ b/sdk/node/package.json
@@ -23,7 +23,7 @@
"watch": "tsc --watch",
"clean": "rimraf dist",
"test": "npm run test:unit",
- "test:unit": "npm run build:test-unit && node --test dist-tests/tests/unit/sandbox.test.js dist-tests/tests/unit/policy.test.js dist-tests/tests/unit/logger.test.js dist-tests/tests/unit/errors.test.js dist-tests/tests/unit/state-aware-types.test.js dist-tests/tests/unit/state-aware.test.js dist-tests/tests/unit/platform.test.js dist-tests/tests/unit/wire-conformance.test.js dist-tests/tests/unit/wire-conformance-state-aware.test.js",
+ "test:unit": "npm run build:test-unit && node --test dist-tests/tests/unit/sandbox.test.js dist-tests/tests/unit/policy.test.js dist-tests/tests/unit/logger.test.js dist-tests/tests/unit/errors.test.js dist-tests/tests/unit/state-aware-types.test.js dist-tests/tests/unit/state-aware.test.js dist-tests/tests/unit/platform.test.js dist-tests/tests/unit/telemetry.test.js dist-tests/tests/unit/default-consent-protocol-runner.test.js dist-tests/tests/unit/wire-conformance.test.js dist-tests/tests/unit/wire-conformance-state-aware.test.js",
"test:integration": "cd tests/integration && npm install && npm run build && npm test",
"prepublishOnly": "npm run build"
},
diff --git a/sdk/node/src/helper.ts b/sdk/node/src/helper.ts
index f5d2f3281..afb25fdfc 100644
--- a/sdk/node/src/helper.ts
+++ b/sdk/node/src/helper.ts
@@ -10,6 +10,7 @@ import { ContainerConfig, ContainmentBackend, ContainmentTypes, ExperimentalBack
import { findWxcExecutable, findLxcExecutable, findSeatbeltExecutable, getPlatformSupport } from './platform.js';
import { SandboxSpawnOptions } from './sandbox.js';
import { diagLog } from './diagnostic.js';
+import { mxcErrorFromCode } from './errors.js';
/** SDK version read from package.json at module load time. */
export const SDK_VERSION: string = (() => {
@@ -208,6 +209,15 @@ export function resolveExecutableAndArgs(
config: ContainerConfig,
options: SandboxSpawnOptions = {},
): { executablePath: string; args: string[] } {
+ if (
+ config.experimental !== null
+ && typeof config.experimental === 'object'
+ && 'telemetry' in config.experimental
+ ) {
+ throw new Error(
+ "'experimental.telemetry' is no longer accepted; use top-level 'telemetry' instead.",
+ );
+ }
if (!config.process?.commandLine) {
throw new Error('script is required. Set process.commandLine on the config or pass a script to spawnSandbox().');
}
diff --git a/sdk/node/src/index.ts b/sdk/node/src/index.ts
index 1f7101e6c..a4bd9cb9f 100644
--- a/sdk/node/src/index.ts
+++ b/sdk/node/src/index.ts
@@ -146,3 +146,23 @@ export {
stopSandbox,
deprovisionSandbox,
} from './state-aware.js';
+
+// Export telemetry consent functions and types
+export {
+ TelemetryConfig,
+} from './types.js';
+
+export {
+ TelemetryConsentMessage,
+ TelemetryConsentResult,
+ TelemetryConsentState,
+ TelemetryConsentPrompt,
+ TelemetryConsentDecision,
+ TelemetryConsentOutcome,
+ TelemetryConsentPresenter,
+ TelemetryConsentQuery,
+ TelemetryPolicyState,
+ requestTelemetryConsent,
+ queryTelemetryConsentAsync,
+ withdrawTelemetryConsentAsync,
+} from './telemetry.js';
diff --git a/sdk/node/src/platform.ts b/sdk/node/src/platform.ts
index c99f2bf86..b8e98b62e 100644
--- a/sdk/node/src/platform.ts
+++ b/sdk/node/src/platform.ts
@@ -36,6 +36,8 @@ const bwrapProbeScriptDirectory = fs.existsSync(
? __dirname
: path.join(getSdkPackageRoot(), 'dist');
let windowsSandboxAvailableCache: boolean | undefined;
+let wxcExecutableCache: { binDir: string | undefined; executable: string } | undefined;
+let wxcExecutableVerifier = verifyWxcExecutable;
/**
* Check if Windows Sandbox feature is enabled via DISM.
@@ -736,10 +738,25 @@ function getDarwinRustTargetTriple(): string {
* @returns Path to wxc-exec.exe if found, null otherwise
*/
export function findWxcExecutable(): string | null {
+ const binDir = process.env.MXC_BIN_DIR;
+ const overridePath = binDir
+ ? path.join(binDir, getSdkArch(), 'wxc-exec.exe')
+ : undefined;
+ const cached = wxcExecutableCache;
+ if (
+ cached !== undefined
+ && cached.binDir === binDir
+ && (overridePath === undefined || cached.executable === overridePath)
+ && wxcExecutableVerifier(cached.executable)
+ ) {
+ return cached.executable;
+ }
+ wxcExecutableCache = undefined;
+
// Allow override for bundled deployments (debugging/testing)
- if (process.env.MXC_BIN_DIR) {
- const overridePath = path.join(process.env.MXC_BIN_DIR, getSdkArch(), 'wxc-exec.exe');
- if (verifyWxcExecutable(overridePath)) {
+ if (overridePath !== undefined) {
+ if (wxcExecutableVerifier(overridePath)) {
+ wxcExecutableCache = { binDir, executable: overridePath };
return overridePath;
}
}
@@ -762,7 +779,8 @@ export function findWxcExecutable(): string | null {
];
for (const wxcPath of possiblePaths) {
- if (verifyWxcExecutable(wxcPath)) {
+ if (wxcExecutableVerifier(wxcPath)) {
+ wxcExecutableCache = { binDir, executable: wxcPath };
return wxcPath;
}
}
@@ -770,6 +788,18 @@ export function findWxcExecutable(): string | null {
return null;
}
+/** @internal Test-only: clear the resolved executable path. */
+export function _resetWxcExecutableCache(): void {
+ wxcExecutableCache = undefined;
+}
+
+/** @internal Test-only: override executable verification. */
+export function _setWxcExecutableVerifier(
+ verifier: ((execPath: string) => boolean) | null,
+): void {
+ wxcExecutableVerifier = verifier ?? verifyWxcExecutable;
+}
+
/**
* Verify that an executable exists at the given path
* @param execPath - Path to verify
diff --git a/sdk/node/src/sandbox.ts b/sdk/node/src/sandbox.ts
index b71577791..007242ea3 100644
--- a/sdk/node/src/sandbox.ts
+++ b/sdk/node/src/sandbox.ts
@@ -6,7 +6,12 @@ import * as os from 'os';
import { spawn, ChildProcess } from 'child_process';
import { randomBytes } from "crypto";
import { parse as semverParse } from 'semver';
-import { SandboxPolicy, ContainerConfig, ContainmentType, ContainmentBackend } from './types.js';
+import {
+ SandboxPolicy,
+ ContainerConfig,
+ ContainmentType,
+ ContainmentBackend,
+} from './types.js';
import { prepareSpawn, diagLogVersion, applyLinuxNetworkPolicy } from './helper.js';
import { diagLog } from './diagnostic.js';
import { MxcError, mxcErrorFromEnvelope } from './errors.js';
@@ -301,6 +306,7 @@ export function createConfigFromPolicy(
commandLine: '',
timeout: policy.timeoutMs ?? 0,
},
+ telemetry: policy.telemetry === undefined ? undefined : { ...policy.telemetry },
};
// Microvm: delegate to dedicated builder
diff --git a/sdk/node/src/state-aware-helper.ts b/sdk/node/src/state-aware-helper.ts
index 800b611b0..e39f56fdf 100644
--- a/sdk/node/src/state-aware-helper.ts
+++ b/sdk/node/src/state-aware-helper.ts
@@ -2,12 +2,12 @@
// Licensed under the MIT License.
import { spawn } from 'child_process';
-import { parse as semverParse } from 'semver';
import { resolveBinaryAndCommonArgs } from './helper.js';
import { SandboxSpawnOptions } from './sandbox.js';
import { mxcErrorFromCode, mxcErrorFromEnvelope, WireError } from './errors.js';
import { diagLog } from './diagnostic.js';
import { Phase, StateAwareContainmentBackend } from './state-aware-types.js';
+import { TelemetryConfig } from './types.js';
export const STATE_AWARE_VERSION = '0.6.0-alpha';
@@ -96,23 +96,21 @@ export interface BuildEnvelopeArgs {
* remaining backend-specific fields under `experimental..`.
*/
export function buildStateAwareEnvelope(args: BuildEnvelopeArgs): Record {
- const { phase, backendKey, containment, sandboxId, config } = args;
+ const {
+ phase,
+ backendKey,
+ containment,
+ sandboxId,
+ config,
+ } = args;
// Copy of config; fields are removed as they are lifted into the envelope.
// Anything left becomes experimental...
const backendSpecific: Record = { ...(config ?? {}) };
const defaultVersion = DEFAULT_STATE_AWARE_VERSION[backendKey] ?? STATE_AWARE_VERSION;
const suppliedVersion = typeof backendSpecific.version === 'string' && backendSpecific.version;
- const hasTelemetry = backendSpecific.telemetry !== undefined;
+ const telemetry = backendSpecific.telemetry as TelemetryConfig | undefined;
+ const hasTelemetry = telemetry !== undefined;
const version = suppliedVersion || (hasTelemetry ? TELEMETRY_STATE_AWARE_VERSION : defaultVersion);
- if (hasTelemetry && suppliedVersion) {
- const parsed = semverParse(suppliedVersion);
- if (parsed && parsed.major === 0 && parsed.minor < 9) {
- throw mxcErrorFromCode(
- 'malformed_request',
- `telemetry requires schema version ${TELEMETRY_STATE_AWARE_VERSION} or later; got ${suppliedVersion}`,
- );
- }
- }
delete backendSpecific.version;
const envelope: Record = { version, phase };
@@ -122,6 +120,10 @@ export function buildStateAwareEnvelope(args: BuildEnvelopeArgs): Record =
string & { readonly __mxcBrand: 'SandboxId'; readonly __mxcBackend: C };
+interface StateAwareConfig {
+ /**
+ * Schema version. When omitted, the SDK selects `0.9.0-alpha` if telemetry
+ * is present; otherwise it selects the backend default.
+ */
+ version?: string;
+ /** Optional telemetry request for this phase. */
+ telemetry?: TelemetryConfig;
+}
+
// IsolationSession per-(backend, phase) Configs. Each declares only
// the fields the SDK currently exposes at that phase — scoped to
// what the backend honors per the policy honor matrix and currently
// implements. TypeScript rejects passing fields outside this set.
-export interface IsolationSessionProvisionConfig {
- /** Schema version (semver). When omitted, the SDK fills in its own SUPPORTED_VERSION. */
- version?: string;
- telemetry?: TelemetryConfig;
+export interface IsolationSessionProvisionConfig extends StateAwareConfig {
/**
* Optional identifier for the calling application.
*
@@ -75,30 +82,15 @@ export interface IsolationSessionProvisionConfig {
network: { defaultPolicy: 'allow'; allowLocalNetwork: true };
}
-export interface IsolationSessionStartConfig {
- /** Schema version (semver). */
- version?: string;
- telemetry?: TelemetryConfig;
-}
+export type IsolationSessionStartConfig = StateAwareConfig;
-export interface IsolationSessionExecConfig {
- /** Schema version (semver). */
- version?: string;
- telemetry?: TelemetryConfig;
+export interface IsolationSessionExecConfig extends StateAwareConfig {
process: ProcessConfig;
}
-export interface IsolationSessionStopConfig {
- /** Schema version (semver). */
- version?: string;
- telemetry?: TelemetryConfig;
-}
+export type IsolationSessionStopConfig = StateAwareConfig;
-export interface IsolationSessionDeprovisionConfig {
- /** Schema version (semver). */
- version?: string;
- telemetry?: TelemetryConfig;
-}
+export type IsolationSessionDeprovisionConfig = StateAwareConfig;
/**
* IsolationSession's provision-phase metadata surfaced to the caller: the
@@ -118,10 +110,7 @@ export interface IsolationSessionProvisionMetadata {
// (readwrite/readonly/denied HOST paths) is honored at provision and is
// immutable thereafter.
-export interface WindowsSandboxProvisionConfig {
- /** Schema version (semver). When omitted, the SDK fills in its own SUPPORTED_VERSION. */
- version?: string;
- telemetry?: TelemetryConfig;
+export interface WindowsSandboxProvisionConfig extends StateAwareConfig {
/**
* Filesystem policy applied at provision and frozen for the life of the
* sandbox. `readwritePaths` / `readonlyPaths` are mapped into the guest at
@@ -133,30 +122,15 @@ export interface WindowsSandboxProvisionConfig {
filesystem?: FilesystemConfig;
}
-export interface WindowsSandboxStartConfig {
- /** Schema version (semver). */
- version?: string;
- telemetry?: TelemetryConfig;
-}
+export type WindowsSandboxStartConfig = StateAwareConfig;
-export interface WindowsSandboxExecConfig {
- /** Schema version (semver). */
- version?: string;
- telemetry?: TelemetryConfig;
+export interface WindowsSandboxExecConfig extends StateAwareConfig {
process: ProcessConfig;
}
-export interface WindowsSandboxStopConfig {
- /** Schema version (semver). */
- version?: string;
- telemetry?: TelemetryConfig;
-}
+export type WindowsSandboxStopConfig = StateAwareConfig;
-export interface WindowsSandboxDeprovisionConfig {
- /** Schema version (semver). */
- version?: string;
- telemetry?: TelemetryConfig;
-}
+export type WindowsSandboxDeprovisionConfig = StateAwareConfig;
// WSLc per-(backend, phase) Configs. WSLc runs each sandbox as a warm
// container behind a persistent host-side daemon (one amortized WSL session
@@ -164,10 +138,7 @@ export interface WindowsSandboxDeprovisionConfig {
// provision and frozen for the sandbox's lifetime; a cooperative env-var proxy
// may be injected per-exec.
-export interface WslcProvisionConfig {
- /** Schema version (semver). When omitted, the SDK fills in `0.8.0-alpha`. */
- version?: string;
- telemetry?: TelemetryConfig;
+export interface WslcProvisionConfig extends StateAwareConfig {
/**
* Filesystem policy applied at provision and frozen for the life of the
* sandbox. `readwritePaths` / `readonlyPaths` become container volume mounts
@@ -200,16 +171,9 @@ export interface WslcProvisionConfig {
imageTarPath?: string;
}
-export interface WslcStartConfig {
- /** Schema version (semver). */
- version?: string;
- telemetry?: TelemetryConfig;
-}
+export type WslcStartConfig = StateAwareConfig;
-export interface WslcExecConfig {
- /** Schema version (semver). */
- version?: string;
- telemetry?: TelemetryConfig;
+export interface WslcExecConfig extends StateAwareConfig {
process: ProcessConfig;
/**
* Per-exec network overrides. Only `proxy` is honored: it injects a
@@ -224,17 +188,9 @@ export interface WslcExecConfig {
network?: NetworkConfig;
}
-export interface WslcStopConfig {
- /** Schema version (semver). */
- version?: string;
- telemetry?: TelemetryConfig;
-}
+export type WslcStopConfig = StateAwareConfig;
-export interface WslcDeprovisionConfig {
- /** Schema version (semver). */
- version?: string;
- telemetry?: TelemetryConfig;
-}
+export type WslcDeprovisionConfig = StateAwareConfig;
/**
* The five per-phase Config slots every state-aware backend must declare.
diff --git a/sdk/node/src/telemetry.ts b/sdk/node/src/telemetry.ts
new file mode 100644
index 000000000..775c63288
--- /dev/null
+++ b/sdk/node/src/telemetry.ts
@@ -0,0 +1,751 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { execFile, spawn } from 'node:child_process';
+import { findWxcExecutable } from './platform.js';
+
+const TELEMETRY_CONSENT_STATES = ['granted', 'denied', 'undetermined', 'not-applicable'] as const;
+const TELEMETRY_POLICY_STATES = ['unrestricted', 'allowed', 'blocked', 'not-applicable'] as const;
+const TELEMETRY_CONSENT_DECISIONS = ['yes', 'no', 'dismissed'] as const;
+const TELEMETRY_CONSENT_RESULTS = [
+ 'granted',
+ 'denied',
+ 'dismissed',
+ 'withdrawn',
+ 'alreadyGranted',
+ 'policyBlocked',
+ 'presentationUnavailable',
+ 'notApplicable',
+] as const;
+const CONSENT_STATUS_REASONS = [
+ 'no-record',
+ 'store-unreadable',
+ 'store-malformed',
+ 'consent-schema-unsupported',
+ 'prompt-version-missing',
+ 'prompt-version-unsupported',
+ 'policy-blocked',
+ 'presentation-unavailable',
+ 'not-applicable',
+] as const;
+// Protocol-only results: never a successful outcome for a caller.
+const CONSENT_PROTOCOL_ONLY_RESULTS = [
+ 'status',
+ 'presentationRequired',
+] as const;
+const CONSENT_PROTOCOL_RESULTS = [
+ ...CONSENT_PROTOCOL_ONLY_RESULTS,
+ ...TELEMETRY_CONSENT_RESULTS,
+] as const;
+
+export type TelemetryConsentState = (typeof TELEMETRY_CONSENT_STATES)[number];
+export type TelemetryPolicyState = (typeof TELEMETRY_POLICY_STATES)[number];
+export type TelemetryConsentDecision = (typeof TELEMETRY_CONSENT_DECISIONS)[number];
+export type TelemetryConsentResult = (typeof TELEMETRY_CONSENT_RESULTS)[number];
+type ConsentStatusReason = (typeof CONSENT_STATUS_REASONS)[number];
+type TelemetryConsentProtocolResult = (typeof CONSENT_PROTOCOL_RESULTS)[number];
+
+export interface TelemetryConsentMessage {
+ id: string;
+ text: string;
+}
+
+export interface TelemetryConsentPrompt {
+ resourceVersion: number;
+ locale: string;
+ title: TelemetryConsentMessage;
+ body: TelemetryConsentMessage;
+ affirmativeLabel: TelemetryConsentMessage;
+ negativeLabel: TelemetryConsentMessage;
+ learnMoreLabel: TelemetryConsentMessage;
+ learnMoreUrl: string;
+}
+
+export interface TelemetryConsentOutcome {
+ action: 'request' | 'withdraw';
+ result: TelemetryConsentResult;
+ storedState: TelemetryConsentState;
+ effectiveState: TelemetryConsentState;
+ policy: TelemetryPolicyState;
+ needsPrompt: boolean;
+}
+
+interface TelemetryConsentProtocolResponse
+ extends Omit {
+ action: ConsentAction;
+ result: TelemetryConsentProtocolResult;
+ reason: ConsentStatusReason | null;
+ prompt?: TelemetryConsentPrompt | null;
+ challenge?: string | null;
+}
+
+export type TelemetryConsentPresenter = (
+ prompt: TelemetryConsentPrompt,
+ signal?: AbortSignal,
+) => TelemetryConsentDecision | Promise;
+
+export interface TelemetryConsentQuery {
+ state: TelemetryConsentState;
+ storedState: TelemetryConsentState;
+ effectiveState: TelemetryConsentState;
+ needsPrompt: boolean;
+ policy: TelemetryPolicyState;
+ error?: string;
+}
+
+interface ConsentCommandOutput {
+ stdout: string;
+ stderr: string;
+}
+
+type ConsentAsyncRunner = (args: readonly string[]) => Promise;
+type ConsentAction = 'request' | 'withdraw' | 'status';
+type ConsentProtocolRunner = (
+ locale: string | undefined,
+ presenter: TelemetryConsentPresenter,
+) => Promise;
+type ConsentChildFactory = (args: readonly string[]) => ReturnType;
+
+const DEFAULT_CONSENT_REQUEST_TIMEOUT_MS = 30_000;
+const MAX_CONSENT_STDOUT_BYTES = 1024 * 1024;
+const MAX_CONSENT_STDERR_BYTES = 64 * 1024;
+const MAX_CONSENT_PROTOCOL_LINES = 16;
+let consentRequestTimeoutMs = DEFAULT_CONSENT_REQUEST_TIMEOUT_MS;
+const defaultConsentChildFactory: ConsentChildFactory = (args) =>
+ spawn(executable(), [...args], {
+ env: process.env,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ windowsHide: true,
+ });
+let consentChildFactory: ConsentChildFactory = defaultConsentChildFactory;
+
+function maintenanceArgs(action: 'request' | 'withdraw' | 'status', locale?: string): string[] {
+ const args = ['--telemetry-consent', action];
+ if (action === 'request') {
+ if (locale !== undefined) {
+ args.push(`--telemetry-consent-locale=${locale}`);
+ }
+ }
+ return args;
+}
+
+function executable(): string {
+ const path = findWxcExecutable();
+ if (!path) {
+ throw new Error('wxc-exec was not found; the MXC native binary is missing from this installation');
+ }
+ return path;
+}
+
+function defaultConsentAsyncRunner(args: readonly string[]): Promise {
+ return new Promise((resolve, reject) => {
+ execFile(
+ executable(),
+ [...args],
+ {
+ timeout: 5000,
+ encoding: 'utf-8',
+ windowsHide: true,
+ },
+ (error, stdout, stderr) => {
+ if (error) {
+ reject(error);
+ } else {
+ resolve({ stdout, stderr });
+ }
+ },
+ );
+ });
+}
+
+async function defaultConsentProtocolRunner(
+ locale: string | undefined,
+ presenter: TelemetryConsentPresenter,
+): Promise {
+ return new Promise((resolve, reject) => {
+ const child = consentChildFactory(maintenanceArgs('request', locale));
+ const childStdin = child.stdin;
+ const childStdout = child.stdout;
+ const childStderr = child.stderr;
+ if (childStdin === null || childStdout === null || childStderr === null) {
+ child.kill();
+ reject(new Error('telemetry consent process did not expose stdio pipes'));
+ return;
+ }
+ let stdout = '';
+ let stderr = '';
+ let finalResponse: TelemetryConsentOutcome | undefined;
+ let presentationSeen = false;
+ let presenterResponsePending = false;
+ let terminalSeen = false;
+ let settled = false;
+ let timeout: NodeJS.Timeout | null = null;
+ let timeoutStartedAt = 0;
+ let timeoutRemainingMs = consentRequestTimeoutMs;
+ let lineQueue: Promise = Promise.resolve();
+ let childKilled = false;
+ let stdoutBytes = 0;
+ let stderrBytes = 0;
+ let protocolLines = 0;
+ let bufferedOutputReceivedWhilePresenterPending = false;
+ let childExitCode: number | null | undefined;
+ const presenterAbort = new AbortController();
+ let rejectPresenterWait: ((error: Error) => void) | undefined;
+
+ const clearProtocolDeadline = (): void => {
+ if (timeout !== null) {
+ clearTimeout(timeout);
+ timeout = null;
+ }
+ };
+ const pauseProtocolDeadline = (): boolean => {
+ if (timeout !== null) {
+ timeoutRemainingMs -= Date.now() - timeoutStartedAt;
+ clearProtocolDeadline();
+ }
+ if (timeoutRemainingMs <= 0) {
+ fail(new Error('telemetry consent request timed out'));
+ return false;
+ }
+ return true;
+ };
+ const resumeProtocolDeadline = (): void => {
+ if (settled) {
+ return;
+ }
+ clearProtocolDeadline();
+ if (timeoutRemainingMs <= 0) {
+ fail(new Error('telemetry consent request timed out'));
+ return;
+ }
+ timeoutStartedAt = Date.now();
+ timeout = setTimeout(() => {
+ fail(new Error('telemetry consent request timed out'));
+ }, timeoutRemainingMs);
+ };
+
+ const fail = (error: unknown): void => {
+ if (!settled) {
+ settled = true;
+ clearProtocolDeadline();
+ presenterAbort.abort();
+ rejectPresenterWait?.(
+ error instanceof Error ? error : new Error(String(error)),
+ );
+ rejectPresenterWait = undefined;
+ if (!childKilled) {
+ childKilled = true;
+ child.kill();
+ }
+ reject(error instanceof Error ? error : new Error(String(error)));
+ }
+ };
+ resumeProtocolDeadline();
+
+ const processResponse = async (
+ response: TelemetryConsentProtocolResponse,
+ ): Promise => {
+ if (settled) {
+ return;
+ }
+
+ if (response.result !== 'presentationRequired') {
+ if (terminalSeen) {
+ fail(new Error('telemetry consent protocol emitted multiple terminal responses'));
+ return;
+ }
+ terminalSeen = true;
+ finalResponse = toConsentOutcome(response);
+ return;
+ }
+ if (terminalSeen) {
+ fail(new Error('telemetry consent protocol emitted a presentation after its terminal response'));
+ return;
+ }
+ if (presentationSeen) {
+ fail(new Error('telemetry consent protocol emitted multiple presentations'));
+ return;
+ }
+ presentationSeen = true;
+ if (!isConsentPrompt(response.prompt) || !isChallenge(response.challenge)) {
+ fail(new Error('telemetry consent presentation omitted its prompt or challenge'));
+ return;
+ }
+ if (childExitCode !== undefined) {
+ fail(new Error(
+ `telemetry consent process exited before presentation completed (${childExitCode ?? 'no exit code'})`,
+ ));
+ return;
+ }
+
+ const resourceVersion = response.prompt.resourceVersion;
+ let decision: TelemetryConsentDecision = 'dismissed';
+ if (!pauseProtocolDeadline()) {
+ return;
+ }
+ try {
+ const processEnded = new Promise((_resolve, reject) => {
+ rejectPresenterWait = reject;
+ });
+ decision = await Promise.race([
+ Promise.resolve(presenter(response.prompt, presenterAbort.signal)),
+ processEnded,
+ ]);
+ if (!isDecision(decision)) {
+ throw new Error(`consent presenter returned invalid decision '${String(decision)}'`);
+ }
+ } catch (error) {
+ rejectPresenterWait = undefined;
+ fail(error);
+ return;
+ } finally {
+ rejectPresenterWait = undefined;
+ }
+ if (settled) {
+ return;
+ }
+ resumeProtocolDeadline();
+ if (settled) {
+ return;
+ }
+ presenterResponsePending = false;
+ childStdin.write(`${JSON.stringify({
+ challenge: response.challenge,
+ resourceVersion,
+ decision,
+ })}\n`);
+ childStdin.end();
+ };
+
+ const receiveLine = (
+ line: string,
+ receivedWhilePresenterPending = false,
+ ): void => {
+ if (settled) {
+ return;
+ }
+ protocolLines += 1;
+ if (protocolLines > MAX_CONSENT_PROTOCOL_LINES) {
+ fail(new Error('telemetry consent process exceeded the protocol line limit'));
+ return;
+ }
+ if (line.trim() === '') {
+ return;
+ }
+
+ let response: TelemetryConsentProtocolResponse;
+ try {
+ response = parseMaintenanceResponse(line, 'request');
+ } catch (error) {
+ fail(error);
+ return;
+ }
+ if (
+ (receivedWhilePresenterPending || presenterResponsePending)
+ && response.result !== 'presentationRequired'
+ ) {
+ fail(new Error(
+ 'telemetry consent protocol emitted a terminal response before the presenter decision was written',
+ ));
+ return;
+ }
+ if (response.result === 'presentationRequired') {
+ presenterResponsePending = true;
+ }
+ lineQueue = lineQueue
+ .then(() => processResponse(response))
+ .catch(fail);
+ };
+
+ childStdout.setEncoding('utf8');
+ childStdout.on('data', (chunk: string) => {
+ if (settled) {
+ return;
+ }
+ stdoutBytes += Buffer.byteLength(chunk, 'utf8');
+ if (stdoutBytes > MAX_CONSENT_STDOUT_BYTES) {
+ fail(new Error('telemetry consent process exceeded the stdout limit'));
+ return;
+ }
+ stdout += chunk;
+ const lines = stdout.split(/\r?\n/);
+ stdout = lines.pop() ?? '';
+ const bufferedLineWasPremature = bufferedOutputReceivedWhilePresenterPending;
+ bufferedOutputReceivedWhilePresenterPending = false;
+ for (const [index, line] of lines.entries()) {
+ receiveLine(line, index === 0 && bufferedLineWasPremature);
+ }
+ if (lines.length === 0 && bufferedLineWasPremature) {
+ bufferedOutputReceivedWhilePresenterPending = true;
+ }
+ if (presenterResponsePending && stdout.trim() !== '') {
+ bufferedOutputReceivedWhilePresenterPending = true;
+ }
+ });
+ childStdout.on('error', fail);
+ childStderr.setEncoding('utf8');
+ childStderr.on('data', (chunk: string) => {
+ if (settled) {
+ return;
+ }
+ stderrBytes += Buffer.byteLength(chunk, 'utf8');
+ if (stderrBytes > MAX_CONSENT_STDERR_BYTES) {
+ fail(new Error('telemetry consent process exceeded the stderr limit'));
+ return;
+ }
+ stderr += chunk;
+ });
+ childStderr.on('error', fail);
+ childStdin.on('error', (error) => {
+ if (!settled) {
+ fail(error);
+ }
+ });
+ child.on('error', fail);
+ child.on('close', (code) => {
+ childExitCode = code;
+ if (rejectPresenterWait !== undefined) {
+ presenterAbort.abort();
+ rejectPresenterWait(
+ new Error(`telemetry consent process exited before presentation completed (${code ?? 'no exit code'})`),
+ );
+ rejectPresenterWait = undefined;
+ }
+ void (async (): Promise => {
+ await lineQueue;
+ if (settled) return;
+ clearProtocolDeadline();
+ if (stdout.trim() !== '') {
+ receiveLine(stdout, bufferedOutputReceivedWhilePresenterPending);
+ await lineQueue;
+ if (settled) return;
+ }
+ if (finalResponse === undefined) {
+ fail(new Error(`telemetry consent process failed (${code ?? 'no exit code'}): ${stderr.trim()}`));
+ return;
+ }
+ const validTerminalExit = (
+ (finalResponse.result === 'presentationUnavailable' && code === 1)
+ || (finalResponse.result !== 'presentationUnavailable' && code === 0)
+ );
+ if (!validTerminalExit) {
+ fail(new Error(`telemetry consent process failed (${code ?? 'no exit code'}): ${stderr.trim()}`));
+ return;
+ }
+ reportNativeDiagnostic('requestTelemetryConsent', stderr);
+ settled = true;
+ resolve(finalResponse);
+ })().catch(fail);
+ });
+ });
+}
+
+let consentAsyncRunner: ConsentAsyncRunner = defaultConsentAsyncRunner;
+let protocolRunner: ConsentProtocolRunner = defaultConsentProtocolRunner;
+let platformOverride: NodeJS.Platform | null = null;
+
+/** @internal Test-only. */
+export function _setTelemetryConsentAsyncRunner(runner: ConsentAsyncRunner | null): void {
+ consentAsyncRunner = runner ?? defaultConsentAsyncRunner;
+}
+
+/** @internal Test-only. */
+export function _setTelemetryConsentProtocolRunner(runner: ConsentProtocolRunner | null): void {
+ protocolRunner = runner ?? defaultConsentProtocolRunner;
+}
+
+/** @internal Test-only. */
+export function _setTelemetryConsentChildFactory(factory: ConsentChildFactory | null): void {
+ consentChildFactory = factory ?? defaultConsentChildFactory;
+}
+
+/** @internal Test-only. */
+export function _setTelemetryConsentTimeoutMs(timeoutMs: number | null): void {
+ consentRequestTimeoutMs = timeoutMs ?? DEFAULT_CONSENT_REQUEST_TIMEOUT_MS;
+}
+
+/** @internal Test-only. */
+export function _setTelemetryPlatform(platform: NodeJS.Platform | null): void {
+ platformOverride = platform;
+}
+
+function isWindows(): boolean {
+ return (platformOverride ?? process.platform) === 'win32';
+}
+
+function includes(values: readonly T[], value: unknown): value is T {
+ return typeof value === 'string' && values.includes(value as T);
+}
+
+function isConsentState(value: unknown): value is TelemetryConsentState {
+ return includes(TELEMETRY_CONSENT_STATES, value);
+}
+
+function isPolicyState(value: unknown): value is TelemetryPolicyState {
+ return includes(TELEMETRY_POLICY_STATES, value);
+}
+
+function isDecision(value: unknown): value is TelemetryConsentDecision {
+ return includes(TELEMETRY_CONSENT_DECISIONS, value);
+}
+
+function isStatusReason(value: unknown): value is ConsentStatusReason {
+ return includes(CONSENT_STATUS_REASONS, value);
+}
+
+function isConsentMessage(value: unknown): value is TelemetryConsentMessage {
+ if (value === null || typeof value !== 'object') {
+ return false;
+ }
+ const message = value as Record;
+ return typeof message.id === 'string' && typeof message.text === 'string';
+}
+
+function isConsentPrompt(value: unknown): value is TelemetryConsentPrompt {
+ if (value === null || typeof value !== 'object') {
+ return false;
+ }
+ const prompt = value as Record;
+ return Number.isSafeInteger(prompt.resourceVersion)
+ && (prompt.resourceVersion as number) > 0
+ && typeof prompt.locale === 'string'
+ && isConsentMessage(prompt.title)
+ && isConsentMessage(prompt.body)
+ && isConsentMessage(prompt.affirmativeLabel)
+ && isConsentMessage(prompt.negativeLabel)
+ && isConsentMessage(prompt.learnMoreLabel)
+ && typeof prompt.learnMoreUrl === 'string';
+}
+
+function isChallenge(value: unknown): value is string {
+ return typeof value === 'string' && value.length > 0;
+}
+
+function isResult(value: unknown): value is TelemetryConsentProtocolResult {
+ return includes(CONSENT_PROTOCOL_RESULTS, value);
+}
+
+function isResultForAction(
+ action: ConsentAction,
+ result: TelemetryConsentProtocolResult,
+): boolean {
+ switch (action) {
+ case 'status':
+ return result === 'status' || result === 'notApplicable';
+ case 'withdraw':
+ return result === 'withdrawn' || result === 'notApplicable';
+ case 'request':
+ return [
+ 'presentationRequired',
+ 'granted',
+ 'denied',
+ 'dismissed',
+ 'alreadyGranted',
+ 'policyBlocked',
+ 'presentationUnavailable',
+ 'notApplicable',
+ ].includes(result);
+ }
+}
+
+function shouldPrompt(
+ effectiveState: TelemetryConsentState,
+ policy: TelemetryPolicyState,
+): boolean {
+ return effectiveState === 'undetermined'
+ && (policy === 'unrestricted' || policy === 'allowed');
+}
+
+function parseMaintenanceResponse(
+ stdout: string,
+ expectedAction: ConsentAction,
+): TelemetryConsentProtocolResponse {
+ const parsed: unknown = JSON.parse(stdout);
+ if (parsed === null || typeof parsed !== 'object') {
+ throw new Error('unrecognised telemetry consent output');
+ }
+ const value = parsed as Record;
+ if (
+ !isConsentState(value.storedState)
+ || !isConsentState(value.effectiveState)
+ || !isPolicyState(value.policy)
+ || !isResult(value.result)
+ || value.action !== expectedAction
+ || !isResultForAction(expectedAction, value.result)
+ || typeof value.needsPrompt !== 'boolean'
+ || value.needsPrompt !== shouldPrompt(value.effectiveState, value.policy)
+ || !Object.hasOwn(value, 'reason')
+ || (
+ value.reason !== null
+ && !isStatusReason(value.reason)
+ )
+ ) {
+ throw new Error(`unrecognised telemetry consent output: ${stdout.trim().slice(0, 200)}`);
+ }
+ if (
+ value.result === 'presentationRequired'
+ && (!isConsentPrompt(value.prompt) || !isChallenge(value.challenge))
+ ) {
+ throw new Error(`unrecognised telemetry consent output: ${stdout.trim().slice(0, 200)}`);
+ }
+ return parsed as TelemetryConsentProtocolResponse;
+}
+
+function toConsentOutcome(response: TelemetryConsentProtocolResponse): TelemetryConsentOutcome {
+ if (
+ response.action === 'status'
+ || includes(CONSENT_PROTOCOL_ONLY_RESULTS, response.result)
+ ) {
+ throw new Error('unrecognised telemetry consent terminal output');
+ }
+ return {
+ action: response.action,
+ result: response.result,
+ storedState: response.storedState,
+ effectiveState: response.effectiveState,
+ policy: response.policy,
+ needsPrompt: response.needsPrompt,
+ };
+}
+
+const MAX_REPORTED_FAILURE_CATEGORIES = 64;
+const MAX_DIAGNOSTIC_LENGTH = 512;
+const reportedFailureCategories = new Set();
+
+function tryRegisterFailureCategory(category: string): boolean {
+ if (
+ reportedFailureCategories.has(category)
+ || reportedFailureCategories.size >= MAX_REPORTED_FAILURE_CATEGORIES
+ ) {
+ return false;
+ }
+ reportedFailureCategories.add(category);
+ return true;
+}
+
+function boundedDiagnostic(detail: string): string {
+ const normalized = detail.replace(/\s+/g, ' ').trim();
+ return normalized.length <= MAX_DIAGNOSTIC_LENGTH
+ ? normalized
+ : `${normalized.slice(0, MAX_DIAGNOSTIC_LENGTH)}...`;
+}
+
+function reportFailClosed(operation: string, safeResult: string, detail: string): void {
+ try {
+ const category = `${operation}:${safeResult}:failure`;
+ if (tryRegisterFailureCategory(category)) {
+ const message = `mxc-sdk: ${operation} failed and is reporting '${safeResult}' to stay fail-closed: ${boundedDiagnostic(detail)}`;
+ console.warn(message);
+ }
+ } catch {
+ // Reporting must not affect the fail-closed result.
+ }
+}
+
+function reportNativeDiagnostic(operation: string, stderr: string): void {
+ const detail = stderr.trim();
+ if (detail === '') {
+ return;
+ }
+ try {
+ const category = `${operation}:native`;
+ if (tryRegisterFailureCategory(category)) {
+ console.warn(`mxc-sdk: ${operation} native diagnostic: ${boundedDiagnostic(detail)}`);
+ }
+ } catch {
+ // Reporting must not affect the native result.
+ }
+}
+
+/** @internal Test-only. */
+export function _resetTelemetryFailureReporting(): void {
+ reportedFailureCategories.clear();
+}
+
+function notApplicable(action: 'request' | 'withdraw'): TelemetryConsentOutcome {
+ return {
+ action,
+ result: 'notApplicable',
+ storedState: 'not-applicable',
+ effectiveState: 'not-applicable',
+ needsPrompt: false,
+ policy: 'not-applicable',
+ };
+}
+
+function consentQueryFromResponse(
+ response: TelemetryConsentProtocolResponse,
+): TelemetryConsentQuery {
+ return {
+ state: response.effectiveState,
+ storedState: response.storedState,
+ effectiveState: response.effectiveState,
+ needsPrompt: shouldPrompt(response.effectiveState, response.policy),
+ policy: response.policy,
+ };
+}
+
+function failedConsentQuery(operation: string, error: unknown): TelemetryConsentQuery {
+ const detail = error instanceof Error ? error.message : String(error);
+ reportFailClosed(operation, 'undetermined', detail);
+ return {
+ state: 'undetermined',
+ storedState: 'undetermined',
+ effectiveState: 'undetermined',
+ needsPrompt: false,
+ policy: 'blocked',
+ error: `failed to read telemetry consent: ${detail}`,
+ };
+}
+
+/** Read persisted/effective consent and policy without blocking the event loop. */
+export async function queryTelemetryConsentAsync(): Promise {
+ if (!isWindows()) {
+ return {
+ state: 'not-applicable',
+ storedState: 'not-applicable',
+ effectiveState: 'not-applicable',
+ needsPrompt: false,
+ policy: 'not-applicable',
+ };
+ }
+ try {
+ const output = await consentAsyncRunner(maintenanceArgs('status'));
+ reportNativeDiagnostic('queryTelemetryConsentAsync', output.stderr);
+ return consentQueryFromResponse(
+ parseMaintenanceResponse(output.stdout, 'status'),
+ );
+ } catch (error) {
+ return failedConsentQuery('queryTelemetryConsentAsync', error);
+ }
+}
+
+/** Request consent with the versioned canonical consent resource. */
+export async function requestTelemetryConsent(
+ presenter: TelemetryConsentPresenter,
+ locale?: string,
+): Promise {
+ if (!isWindows()) {
+ return notApplicable('request');
+ }
+ return protocolRunner(locale, presenter);
+}
+
+/** Idempotently withdraw telemetry consent without blocking the event loop. */
+export async function withdrawTelemetryConsentAsync(): Promise {
+ if (!isWindows()) {
+ return notApplicable('withdraw');
+ }
+ try {
+ const output = await consentAsyncRunner(maintenanceArgs('withdraw'));
+ reportNativeDiagnostic('withdrawTelemetryConsentAsync', output.stderr);
+ return toConsentOutcome(parseMaintenanceResponse(
+ output.stdout,
+ 'withdraw',
+ ));
+ } catch (error) {
+ throw new Error(
+ `failed to withdraw telemetry consent: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+}
diff --git a/sdk/node/src/types.ts b/sdk/node/src/types.ts
index e2b5d3d8d..5b945979b 100644
--- a/sdk/node/src/types.ts
+++ b/sdk/node/src/types.ts
@@ -345,11 +345,16 @@ export interface PortMapping {
protocol?: 'tcp';
}
-/** Telemetry configuration for TraceLogging ETW support. */
+/**
+ * Telemetry configuration for TraceLogging ETW support.
+ */
export interface TelemetryConfig {
/**
- * Explicit telemetry opt-in. `true` requests telemetry subject to user
- * consent and administrative policy; `false` or `undefined` keeps it off.
+ * Per-invocation telemetry opt-in.
+ *
+ * `true` requests telemetry for this invocation; emission is still gated by
+ * persisted user consent and administrative policy. `false` (or `undefined`)
+ * disables telemetry for this invocation.
*/
enabled?: boolean;
}
@@ -388,11 +393,11 @@ export interface ContainerConfig {
network?: NetworkConfig;
/** Runtime values supplied separately from sandbox policy. */
runtimeConfig?: RuntimeConfig;
- /** Telemetry configuration */
+ /** Telemetry configuration for TraceLogging ETW support */
telemetry?: TelemetryConfig;
/** Experimental features (only applied when --experimental flag is set) */
experimental?: {
- /** WSLC SDK configuration for Linux containers from Windows */
+ /** WSLC SDK configuration for Linux containers from Windows */
wslc?: WslcConfig;
};
/** macOS Seatbelt sandbox configuration (macOS only) */
@@ -452,6 +457,8 @@ export type SandboxPolicy = {
};
/** Schema 0.8 runtime values supplied separately from sandbox policy. */
runtimeConfig?: RuntimeConfig;
+ /** Per-invocation telemetry opt-in, subject to consent and policy. */
+ telemetry?: TelemetryConfig;
/** Schema 0.8 ProcessContainer-specific policy. */
processContainer?: {
/** ProcessContainer-specific networking settings. */
diff --git a/sdk/node/tests/integration/test-helpers.ts b/sdk/node/tests/integration/test-helpers.ts
index 8240cf8f7..8e3e14b4d 100644
--- a/sdk/node/tests/integration/test-helpers.ts
+++ b/sdk/node/tests/integration/test-helpers.ts
@@ -71,6 +71,9 @@ export const EXPECTED_MACOS_BINARIES = [
const OPTIONAL_BINARIES = [
'wslcsdk.dll', // Only built with --with-wslc
'wxc-wslc-daemon.exe', // Only built with --with-wslc
+ 'nanvixd.exe', // Only built with --with-microvm
+ 'nanvix_rootfs.img', // Only built with --with-microvm
+ 'python3.initrd', // Only built with --with-microvm
'plm.exe', // Permissive Learning Mode helper (Windows-only); staged
// only when the plm crate is included in the build.
// Test-only binaries. The GitHub build artifact carries them so the
diff --git a/sdk/node/tests/unit/default-consent-protocol-runner.test.ts b/sdk/node/tests/unit/default-consent-protocol-runner.test.ts
new file mode 100644
index 000000000..46e8dfb8a
--- /dev/null
+++ b/sdk/node/tests/unit/default-consent-protocol-runner.test.ts
@@ -0,0 +1,799 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+// Exercises the production protocol runner with controlled child-process I/O.
+
+import { describe, it, beforeEach, afterEach } from 'node:test';
+import assert from 'node:assert';
+import { EventEmitter } from 'node:events';
+import { readFileSync } from 'node:fs';
+import { PassThrough, type Readable, type Writable } from 'node:stream';
+import type { ChildProcess } from 'node:child_process';
+
+import {
+ requestTelemetryConsent,
+ _setTelemetryPlatform,
+ _setTelemetryConsentChildFactory,
+ _setTelemetryConsentProtocolRunner,
+ _setTelemetryConsentTimeoutMs,
+ _resetTelemetryFailureReporting,
+ type TelemetryConsentPrompt,
+} from '../../src/telemetry.js';
+
+// A minimal ChildProcess-shaped fake wxc-exec that lets a test drive the
+// stdout/stderr/exit sequence turn-by-turn.
+interface FakeChild extends EventEmitter {
+ stdout: Readable;
+ stderr: Readable;
+ stdin: Writable;
+ kill(signal?: NodeJS.Signals | number): boolean;
+ killed: boolean;
+ killCount: number;
+ emitClose(code: number): void;
+ writeStdout(chunk: string): void;
+ writeStderr(chunk: string): void;
+ stdinChunks: string[];
+ stdinEnded: boolean;
+}
+
+function makeFakeChild(): FakeChild {
+ const emitter = new EventEmitter() as FakeChild;
+ emitter.stdout = new PassThrough();
+ emitter.stderr = new PassThrough();
+ emitter.stdin = new PassThrough();
+ emitter.killed = false;
+ emitter.killCount = 0;
+ emitter.stdinChunks = [];
+ emitter.stdinEnded = false;
+ emitter.stdin.on('data', (chunk) => {
+ emitter.stdinChunks.push(chunk.toString('utf8'));
+ });
+ emitter.stdin.on('end', () => {
+ emitter.stdinEnded = true;
+ });
+ emitter.kill = (): boolean => {
+ emitter.killCount += 1;
+ emitter.killed = true;
+ (emitter.stdout as PassThrough).end();
+ (emitter.stderr as PassThrough).end();
+ return true;
+ };
+ emitter.emitClose = (code: number): void => {
+ (emitter.stdout as PassThrough).end();
+ (emitter.stderr as PassThrough).end();
+ emitter.emit('close', code);
+ };
+ emitter.writeStdout = (chunk: string): void => {
+ (emitter.stdout as PassThrough).write(chunk);
+ };
+ emitter.writeStderr = (chunk: string): void => {
+ (emitter.stderr as PassThrough).write(chunk);
+ };
+ return emitter;
+}
+
+// The runner types the factory as returning a `ChildProcess`. Our fake covers
+// only the subset the runner uses. Cast once here rather than everywhere.
+function installFakeChildFactory(): { current: FakeChild; args: readonly string[] } {
+ const box: { current: FakeChild; args: readonly string[] } = {
+ current: null as unknown as FakeChild,
+ args: [],
+ };
+ _setTelemetryConsentChildFactory((args) => {
+ box.args = args;
+ box.current = makeFakeChild();
+ return box.current as unknown as ChildProcess;
+ });
+ return box;
+}
+
+const prompt: TelemetryConsentPrompt = {
+ resourceVersion: 1,
+ locale: 'en-US',
+ title: { id: 'telemetry.consent.title', text: 'Help improve MXC' },
+ body: { id: 'telemetry.consent.body', text: 'canonical body' },
+ affirmativeLabel: { id: 'telemetry.consent.yes', text: 'Yes' },
+ negativeLabel: { id: 'telemetry.consent.no', text: 'No' },
+ learnMoreLabel: { id: 'telemetry.consent.learnMore', text: 'Learn more' },
+ learnMoreUrl: 'https://example.microsoft.com/consent',
+};
+const yesDecisionFixture = JSON.parse(readFileSync(
+ new URL('../../../../../tests/fixtures/telemetry-consent/presenter-decision-yes.json', import.meta.url),
+ 'utf8',
+)) as Record;
+
+function presentationLine(challenge = 'request-a'): string {
+ return `${JSON.stringify({
+ action: 'request',
+ result: 'presentationRequired',
+ challenge,
+ prompt,
+ storedState: 'undetermined',
+ effectiveState: 'undetermined',
+ needsPrompt: true,
+ policy: 'unrestricted',
+ reason: null,
+ })}\n`;
+}
+
+function grantedLine(): string {
+ return `${JSON.stringify({
+ action: 'request',
+ result: 'granted',
+ storedState: 'granted',
+ effectiveState: 'granted',
+ needsPrompt: false,
+ policy: 'unrestricted',
+ reason: null,
+ })}\n`;
+}
+
+async function waitFor(predicate: () => boolean, timeoutMs = 3_000): Promise {
+ const start = Date.now();
+ while (!predicate()) {
+ if (Date.now() - start > timeoutMs) {
+ throw new Error(`waitFor: predicate did not become true within ${timeoutMs}ms`);
+ }
+ await new Promise((r) => setTimeout(r, 5));
+ }
+}
+
+describe('defaultConsentProtocolRunner (real code path)', () => {
+ beforeEach(() => {
+ _setTelemetryPlatform('win32');
+ // Ensure the DEFAULT runner is exercised, not one previously injected.
+ _setTelemetryConsentProtocolRunner(null);
+ _resetTelemetryFailureReporting();
+ });
+
+ afterEach(() => {
+ _setTelemetryPlatform(null);
+ _setTelemetryConsentChildFactory(null);
+ _setTelemetryConsentProtocolRunner(null);
+ _setTelemetryConsentTimeoutMs(null);
+ });
+
+ it('assembles a presentationRequired line split across multiple stdout chunks', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+ assert.deepStrictEqual(box.args, [
+ '--telemetry-consent',
+ 'request',
+ ]);
+ assert.ok(!box.args.includes('--config-base64'));
+
+ const line = presentationLine();
+ // Split the line in three fragments; the runner must accumulate them.
+ child.writeStdout(line.slice(0, 10));
+ await new Promise((r) => setImmediate(r));
+ child.writeStdout(line.slice(10, 40));
+ await new Promise((r) => setImmediate(r));
+ child.writeStdout(line.slice(40));
+
+ await waitFor(() => child.stdinEnded);
+ const echo = JSON.parse(child.stdinChunks.join('').trim());
+ assert.strictEqual(echo.decision, 'yes');
+ assert.deepStrictEqual(echo, yesDecisionFixture);
+
+ child.writeStdout(grantedLine());
+ child.emitClose(0);
+ const outcome = await promise;
+ assert.strictEqual(outcome.result, 'granted');
+ assert.strictEqual(Object.hasOwn(outcome, 'challenge'), false);
+ assert.strictEqual(Object.hasOwn(outcome, 'prompt'), false);
+ });
+
+ it('echoes the native resource version even if the presenter mutates its prompt', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent((presentedPrompt) => {
+ presentedPrompt.resourceVersion = 99;
+ return 'yes';
+ });
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStdout(presentationLine());
+ await waitFor(() => child.stdinEnded);
+ const echo = JSON.parse(child.stdinChunks.join('').trim());
+ assert.strictEqual(echo.resourceVersion, prompt.resourceVersion);
+
+ child.writeStdout(grantedLine());
+ child.emitClose(0);
+ assert.strictEqual((await promise).result, 'granted');
+ });
+
+ it('reports successful native request diagnostics once', async () => {
+ const warnings: string[] = [];
+ const originalWarn = console.warn;
+ console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(' '));
+ try {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStderr('mxc: telemetry administrative policy failure\n');
+ child.writeStdout(presentationLine());
+ await waitFor(() => child.stdinEnded);
+ child.writeStdout(grantedLine());
+ child.emitClose(0);
+
+ assert.strictEqual((await promise).result, 'granted');
+ } finally {
+ console.warn = originalWarn;
+ }
+ assert.deepStrictEqual(warnings, [
+ 'mxc-sdk: requestTelemetryConsent native diagnostic: '
+ + 'mxc: telemetry administrative policy failure',
+ ]);
+ });
+
+ it('passes the requested locale only on the dedicated request command', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'dismissed', 'fr-FR');
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ assert.deepStrictEqual(box.args, [
+ '--telemetry-consent',
+ 'request',
+ '--telemetry-consent-locale=fr-FR',
+ ]);
+ child.writeStdout(`${JSON.stringify({
+ action: 'request',
+ result: 'dismissed',
+ storedState: 'undetermined',
+ effectiveState: 'undetermined',
+ needsPrompt: true,
+ policy: 'unrestricted',
+ reason: null,
+ })}\n`);
+ child.emitClose(0);
+ assert.strictEqual((await promise).result, 'dismissed');
+ });
+
+ it('rejects multiple terminal responses', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ await new Promise((r) => setImmediate(r));
+
+ box.current.writeStdout(grantedLine() + grantedLine());
+
+ await assert.rejects(promise, /multiple terminal responses/);
+ assert.strictEqual(box.current.killCount, 1);
+ });
+
+ it('rejects a presentation after a terminal response', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ await new Promise((r) => setImmediate(r));
+
+ box.current.writeStdout(grantedLine() + presentationLine());
+
+ await assert.rejects(promise, /presentation after its terminal response/);
+ assert.strictEqual(box.current.killCount, 1);
+ });
+
+ it('returns a presenter-unavailable terminal response', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ await new Promise((r) => setImmediate(r));
+
+ box.current.writeStdout(`${JSON.stringify({
+ action: 'request',
+ result: 'presentationUnavailable',
+ storedState: 'undetermined',
+ effectiveState: 'undetermined',
+ needsPrompt: true,
+ policy: 'unrestricted',
+ reason: 'presentation-unavailable',
+ })}\n`);
+ box.current.writeStderr('mxc: host presenter unavailable');
+ box.current.emitClose(1);
+
+ assert.deepStrictEqual(await promise, {
+ action: 'request',
+ result: 'presentationUnavailable',
+ storedState: 'undetermined',
+ effectiveState: 'undetermined',
+ needsPrompt: true,
+ policy: 'unrestricted',
+ });
+ assert.strictEqual(box.current.killCount, 0);
+ });
+
+ it('rejects multiple presentations', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ await new Promise((r) => setImmediate(r));
+
+ box.current.writeStdout(presentationLine() + presentationLine('request-b'));
+
+ await assert.rejects(promise, /multiple presentations/);
+ assert.strictEqual(box.current.killCount, 1);
+ });
+
+ it('suspends the IO timeout while the presenter is thinking', async () => {
+ // The runner is documented to clear the IO timeout right before awaiting
+ // the presenter and rearm it after. A very short timeout would trip if
+ // the presenter's think time were counted toward it.
+ _setTelemetryConsentTimeoutMs(200);
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(async (): Promise<'yes'> => {
+ await new Promise((r) => setTimeout(r, 500));
+ return 'yes';
+ });
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStdout(presentationLine());
+ // The 500 ms sleep would trip a naive 200 ms timeout if suspend/resume
+ // were broken; we wait for the presenter echo instead.
+ await waitFor(() => child.stdinEnded, 3_000);
+ child.writeStdout(grantedLine());
+ child.emitClose(0);
+ const outcome = await promise;
+ assert.strictEqual(outcome.result, 'granted');
+ });
+
+ it('does not invoke the presenter when the IO deadline has already expired', async () => {
+ _setTelemetryConsentTimeoutMs(1_000);
+ const originalNow = Date.now;
+ let now = 1_000;
+ Date.now = () => now;
+ try {
+ const box = installFakeChildFactory();
+ let presenterCalls = 0;
+ const promise = requestTelemetryConsent(() => {
+ presenterCalls += 1;
+ return 'yes';
+ });
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ now = 2_000;
+ child.writeStdout(presentationLine());
+
+ await assert.rejects(promise, /timed out/);
+ assert.strictEqual(presenterCalls, 0);
+ assert.deepStrictEqual(child.stdinChunks, []);
+ assert.strictEqual(child.killCount, 1);
+ } finally {
+ Date.now = originalNow;
+ }
+ });
+
+ it('does not re-arm the IO timeout from stdout or stderr while the presenter is active', async () => {
+ _setTelemetryConsentTimeoutMs(100);
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(async (): Promise<'yes'> => {
+ box.current.writeStderr('still presenting\n');
+ box.current.writeStdout('\n');
+ await new Promise((r) => setTimeout(r, 300));
+ return 'yes';
+ });
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStdout(presentationLine());
+ await waitFor(() => child.stdinEnded, 3_000);
+ child.writeStdout(grantedLine());
+ child.emitClose(0);
+ const outcome = await promise;
+ assert.strictEqual(outcome.result, 'granted');
+ assert.strictEqual(child.killed, false);
+ });
+
+ it('rejects terminal output received before the presenter decision is written', async () => {
+ const box = installFakeChildFactory();
+ let presenterStarted = false;
+ const promise = requestTelemetryConsent(() => {
+ presenterStarted = true;
+ return new Promise<'yes'>(() => {});
+ });
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStdout(presentationLine());
+ await waitFor(() => presenterStarted);
+ child.writeStdout(grantedLine());
+
+ await assert.rejects(promise, /before the presenter decision was written/);
+ assert.strictEqual(child.killCount, 1);
+ assert.deepStrictEqual(child.stdinChunks, []);
+ });
+
+ it('rejects unterminated terminal output buffered before the presenter decision', async () => {
+ const box = installFakeChildFactory();
+ let resolvePresenter!: (decision: 'no') => void;
+ const promise = requestTelemetryConsent(
+ () => new Promise<'no'>((resolve) => {
+ resolvePresenter = resolve;
+ }),
+ );
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStdout(presentationLine());
+ await waitFor(() => resolvePresenter !== undefined);
+ child.writeStdout(grantedLine().trimEnd());
+ resolvePresenter('no');
+ await waitFor(() => child.stdinEnded);
+ child.emitClose(0);
+
+ await assert.rejects(promise, /before the presenter decision was written/);
+ const echo = JSON.parse(child.stdinChunks.join('').trim());
+ assert.strictEqual(echo.decision, 'no');
+ });
+
+ it('does not start a queued presenter after the child closes', async () => {
+ const box = installFakeChildFactory();
+ let presenterCalls = 0;
+ const promise = requestTelemetryConsent(() => {
+ presenterCalls += 1;
+ return new Promise<'yes'>(() => {});
+ });
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStdout(presentationLine());
+ child.emitClose(0);
+
+ await assert.rejects(promise, /exited before presentation completed \(0\)/);
+ assert.strictEqual(presenterCalls, 0);
+ assert.deepStrictEqual(child.stdinChunks, []);
+ });
+
+ it('fails closed when the presenter throws and never writes a fallback decision', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => {
+ throw new Error('UI unavailable');
+ });
+ const rejection = assert.rejects(promise, /UI unavailable/);
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStdout(presentationLine());
+ await waitFor(() => child.killed);
+ child.emitClose(1);
+ await rejection;
+ assert.deepStrictEqual(child.stdinChunks, []);
+ assert.strictEqual(child.stdinEnded, false);
+ });
+
+ it('fails closed when a dynamically typed presenter returns an invalid decision', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'maybe' as unknown as 'yes');
+ const rejection = assert.rejects(promise, /invalid decision 'maybe'/);
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStdout(presentationLine());
+ await waitFor(() => child.killed);
+ child.emitClose(1);
+ await rejection;
+ assert.deepStrictEqual(child.stdinChunks, []);
+ assert.strictEqual(child.stdinEnded, false);
+ });
+
+ it('rejects cleanly on a malformed presentation line and kills the child', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ const rejection = assert.rejects(promise);
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStdout('this is not json at all\n');
+ await waitFor(() => child.killed);
+ child.emitClose(1);
+ await rejection;
+ assert.strictEqual(child.killCount, 1);
+ });
+
+ it('rejects a status response on the request protocol', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ const rejection = assert.rejects(promise, /unrecognised telemetry consent output/);
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStdout(`${JSON.stringify({
+ action: 'status',
+ result: 'status',
+ storedState: 'granted',
+ effectiveState: 'granted',
+ needsPrompt: false,
+ policy: 'allowed',
+ reason: null,
+ })}\n`);
+
+ await waitFor(() => child.killed);
+ child.emitClose(1);
+ await rejection;
+ assert.strictEqual(child.killCount, 1);
+ assert.deepStrictEqual(child.stdinChunks, []);
+ });
+
+ it('does not process queued lines after a protocol failure', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ const rejection = assert.rejects(promise);
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStdout(`not json\n${grantedLine()}`);
+ await waitFor(() => child.killed);
+ child.emitClose(1);
+ await rejection;
+ assert.strictEqual(child.killCount, 1);
+ assert.deepStrictEqual(child.stdinChunks, []);
+ });
+
+ it('kills the child when stdin fails while replying to the presenter', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(async (): Promise<'yes'> => {
+ box.current.stdin.emit('error', new Error('stdin broke'));
+ return 'yes';
+ });
+ const rejection = assert.rejects(promise, /stdin broke/);
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStdout(presentationLine());
+ await waitFor(() => child.killed);
+ child.emitClose(1);
+ await rejection;
+ });
+
+ it('fails closed when the child stdout stream errors', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.stdout.emit('error', new Error('stdout broke'));
+
+ await assert.rejects(promise, /stdout broke/);
+ assert.strictEqual(child.killed, true);
+ assert.strictEqual(child.killCount, 1);
+ });
+
+ it('fails closed when the child stderr stream errors', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.stderr.emit('error', new Error('stderr broke'));
+
+ await assert.rejects(promise, /stderr broke/);
+ assert.strictEqual(child.killed, true);
+ assert.strictEqual(child.killCount, 1);
+ });
+
+ it('rejects cleanly when the child exits before responding', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStderr('boom\n');
+ child.emitClose(2);
+ await assert.rejects(promise, /telemetry consent process failed \(2\)/);
+ });
+
+ it('fires the IO timeout when the child produces no output at all', async () => {
+ _setTelemetryConsentTimeoutMs(50);
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+ // Do not write to stdout/stderr; the timeout must fire.
+ await assert.rejects(promise, /timed out/);
+ assert.strictEqual(child.killed, true);
+ });
+
+ it('does not let continuous stderr output extend the IO timeout', async () => {
+ _setTelemetryConsentTimeoutMs(50);
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+ const interval = setInterval(() => child.writeStderr('diagnostic\n'), 10);
+ try {
+ await assert.rejects(promise, /timed out/);
+ } finally {
+ clearInterval(interval);
+ }
+ assert.strictEqual(child.killed, true);
+ });
+
+ it('does not let partial stdout keep the protocol alive past its deadline', async () => {
+ _setTelemetryConsentTimeoutMs(50);
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+ const interval = setInterval(() => child.writeStdout('x'), 10);
+ try {
+ await assert.rejects(promise, /timed out/);
+ } finally {
+ clearInterval(interval);
+ }
+ assert.strictEqual(child.killed, true);
+ });
+
+ it('rejects stdout that exceeds the protocol buffer limit', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStdout('x'.repeat(1024 * 1024 + 1));
+
+ await assert.rejects(promise, /stdout limit/);
+ assert.strictEqual(child.killed, true);
+ });
+
+ it('rejects stderr that exceeds the diagnostic buffer limit', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStderr('x'.repeat(64 * 1024 + 1));
+
+ await assert.rejects(promise, /stderr limit/);
+ assert.strictEqual(child.killed, true);
+ });
+
+ it('rejects more than sixteen protocol lines and kills the child', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStdout('\n'.repeat(17));
+
+ await assert.rejects(promise, /protocol line limit/);
+ assert.strictEqual(child.killed, true);
+ });
+
+ it('counts an unterminated final line against the protocol limit', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStdout(`${'\n'.repeat(16)}partial`);
+ child.emitClose(0);
+
+ await assert.rejects(promise, /protocol line limit/);
+ assert.strictEqual(child.killed, true);
+ });
+
+ it('aborts a pending presenter when the child fails', async () => {
+ const box = installFakeChildFactory();
+ let observedSignal: AbortSignal | undefined;
+ const promise = requestTelemetryConsent((_prompt, signal) => {
+ observedSignal = signal;
+ return new Promise<'yes'>(() => {});
+ });
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStdout(presentationLine());
+ await waitFor(() => observedSignal !== undefined);
+ child.emitClose(2);
+
+ await assert.rejects(promise, /exited before presentation completed/);
+ assert.strictEqual(observedSignal?.aborted, true);
+ });
+
+ it('aborts a pending presenter when the child exits successfully', async () => {
+ const box = installFakeChildFactory();
+ let observedSignal: AbortSignal | undefined;
+ const promise = requestTelemetryConsent((_prompt, signal) => {
+ observedSignal = signal;
+ return new Promise<'yes'>(() => {});
+ });
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStdout(presentationLine());
+ await waitFor(() => observedSignal !== undefined);
+ child.emitClose(0);
+
+ await assert.rejects(promise, /exited before presentation completed \(0\)/);
+ assert.strictEqual(observedSignal?.aborted, true);
+ });
+
+ it('kills the child on a presentation missing its challenge', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ const rejection = assert.rejects(promise);
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStdout(`${JSON.stringify({
+ action: 'request',
+ result: 'presentationRequired',
+ prompt,
+ // Missing `challenge`.
+ storedState: 'undetermined',
+ effectiveState: 'undetermined',
+ needsPrompt: true,
+ policy: 'unrestricted',
+ reason: null,
+ })}\n`);
+ await waitFor(() => child.killed);
+ child.emitClose(1);
+ await rejection;
+ });
+
+ it('kills the child on a presentation with a malformed prompt', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ const rejection = assert.rejects(promise);
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStdout(`${JSON.stringify({
+ action: 'request',
+ result: 'presentationRequired',
+ prompt: { ...prompt, title: { id: 1, text: 'invalid' } },
+ challenge: 'request-a',
+ storedState: 'undetermined',
+ effectiveState: 'undetermined',
+ needsPrompt: true,
+ policy: 'unrestricted',
+ reason: null,
+ })}\n`);
+ await waitFor(() => child.killed);
+ child.emitClose(1);
+ await rejection;
+ });
+
+ it('kills the child when a response omits its required reason field', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ const rejection = assert.rejects(promise, /unrecognised telemetry consent output/);
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStdout(`${JSON.stringify({
+ action: 'request',
+ result: 'policyBlocked',
+ storedState: 'undetermined',
+ effectiveState: 'undetermined',
+ needsPrompt: false,
+ policy: 'blocked',
+ })}\n`);
+ await waitFor(() => child.killed);
+ child.emitClose(1);
+ await rejection;
+ });
+
+ it('kills the child on an unknown status reason', async () => {
+ const box = installFakeChildFactory();
+ const promise = requestTelemetryConsent(() => 'yes');
+ const rejection = assert.rejects(promise);
+ await new Promise((r) => setImmediate(r));
+ const child = box.current;
+
+ child.writeStdout(`${JSON.stringify({
+ action: 'request',
+ result: 'policyBlocked',
+ storedState: 'undetermined',
+ effectiveState: 'undetermined',
+ needsPrompt: false,
+ reason: 'future-reason',
+ policy: 'blocked',
+ })}\n`);
+ await waitFor(() => child.killed);
+ child.emitClose(1);
+ await rejection;
+ });
+});
diff --git a/sdk/node/tests/unit/platform.test.ts b/sdk/node/tests/unit/platform.test.ts
index aa7f6421a..e5ea79aac 100644
--- a/sdk/node/tests/unit/platform.test.ts
+++ b/sdk/node/tests/unit/platform.test.ts
@@ -21,6 +21,8 @@ import {
_setLxcAvailabilityProbe,
_setPlatformDiagnosticLogger,
findWxcExecutable,
+ _resetWxcExecutableCache,
+ _setWxcExecutableVerifier,
} from '../../src/platform.js';
const isWindows = os.platform() === 'win32';
@@ -352,6 +354,7 @@ describe('findWxcExecutable failure modes', () => {
beforeEach(() => {
prevBinDir = process.env.MXC_BIN_DIR;
+ _resetWxcExecutableCache();
});
afterEach(() => {
@@ -360,6 +363,8 @@ describe('findWxcExecutable failure modes', () => {
} else {
process.env.MXC_BIN_DIR = prevBinDir;
}
+ _setWxcExecutableVerifier(null);
+ _resetWxcExecutableCache();
});
it('returns a string or null and never throws under a nonexistent MXC_BIN_DIR', () => {
@@ -381,6 +386,65 @@ describe('findWxcExecutable failure modes', () => {
const result = findWxcExecutable();
assert.ok(result === null || typeof result === 'string');
});
+
+ it('does not cache a failed executable lookup', () => {
+ process.env.MXC_BIN_DIR = path.join(
+ os.tmpdir(),
+ `mxc-sdk-unit-no-such-dir-${process.pid}`,
+ );
+ _setWxcExecutableVerifier(() => false);
+ assert.strictEqual(findWxcExecutable(), null);
+
+ _setWxcExecutableVerifier((candidate) => candidate.startsWith(process.env.MXC_BIN_DIR!));
+ assert.ok(findWxcExecutable()?.startsWith(process.env.MXC_BIN_DIR));
+ });
+
+ it('honors an MXC_BIN_DIR override configured after a cached lookup', () => {
+ delete process.env.MXC_BIN_DIR;
+ _setWxcExecutableVerifier(() => true);
+ const initial = findWxcExecutable();
+ assert.ok(initial);
+
+ const override = path.join(os.tmpdir(), `mxc-sdk-unit-override-${process.pid}`);
+ process.env.MXC_BIN_DIR = override;
+
+ const resolved = findWxcExecutable();
+ assert.ok(resolved?.startsWith(override));
+ assert.notStrictEqual(resolved, initial);
+ });
+
+ it('honors an MXC_BIN_DIR executable staged after caching a fallback', () => {
+ const override = path.join(os.tmpdir(), `mxc-sdk-unit-override-${process.pid}`);
+ const overrideExecutable = path.join(
+ override,
+ os.arch() === 'arm64' ? 'arm64' : 'x64',
+ 'wxc-exec.exe',
+ );
+ process.env.MXC_BIN_DIR = override;
+
+ let overrideAvailable = false;
+ _setWxcExecutableVerifier((candidate) =>
+ candidate === overrideExecutable ? overrideAvailable : true,
+ );
+
+ const fallback = findWxcExecutable();
+ assert.ok(fallback);
+ assert.notStrictEqual(fallback, overrideExecutable);
+
+ overrideAvailable = true;
+ assert.strictEqual(findWxcExecutable(), overrideExecutable);
+ });
+
+ it('revalidates a cached executable before returning it', () => {
+ let cachedPath: string | null = null;
+ _setWxcExecutableVerifier((candidate) => candidate !== cachedPath);
+ cachedPath = findWxcExecutable();
+ assert.ok(cachedPath);
+
+ const resolved = findWxcExecutable();
+ assert.ok(resolved);
+ assert.notStrictEqual(resolved, cachedPath);
+ });
});
// IsolationSession availability is now reported by the native probe
diff --git a/sdk/node/tests/unit/sandbox.test.ts b/sdk/node/tests/unit/sandbox.test.ts
index 526cda2c0..2e23a4970 100644
--- a/sdk/node/tests/unit/sandbox.test.ts
+++ b/sdk/node/tests/unit/sandbox.test.ts
@@ -11,6 +11,7 @@ import {
_setLxcAvailabilityProbe,
} from '../../src/platform.js';
import { ContainerConfig, SandboxPolicy, SandboxingMethod } from '../../src/types.js';
+import { MxcError } from '../../src/errors.js';
import { platformSkip } from './test-helpers.js';
describe('buildSandboxPayload', () => {
@@ -1915,4 +1916,64 @@ describe('resolveExecutableAndArgs (containment validation)', { skip: platformSk
);
});
});
+
+ describe('one-shot telemetry', () => {
+ function decodeConfig(args: string[]): ContainerConfig {
+ const index = args.indexOf('--config-base64');
+ assert.ok(index >= 0, '--config-base64 should be present in args');
+ return JSON.parse(
+ Buffer.from(args[index + 1], 'base64').toString('utf-8'),
+ ) as ContainerConfig;
+ }
+
+ it('serializes config telemetry with schema 0.9', () => {
+ const config = makeConfig('process');
+ config.version = '0.9.0-alpha';
+ config.telemetry = { enabled: true };
+ const { args } = resolveExecutableAndArgs(config, {
+ executablePath: fakeExe,
+ skipPlatformCheck: true,
+ });
+
+ assert.deepStrictEqual(decodeConfig(args).telemetry, { enabled: true });
+ });
+
+ it('propagates SandboxPolicy telemetry through the primary one-shot API', () => {
+ const payload = buildSandboxPayload('echo hi', {
+ version: '0.9.0-alpha',
+ telemetry: { enabled: true },
+ });
+
+ assert.deepStrictEqual(payload.telemetry, { enabled: true });
+ });
+
+ it('leaves config telemetry schema validation to the native parser', () => {
+ const config = makeConfig('process');
+ config.telemetry = { enabled: true };
+ const { args } = resolveExecutableAndArgs(config, {
+ executablePath: fakeExe,
+ skipPlatformCheck: true,
+ });
+
+ const serialized = decodeConfig(args);
+ assert.deepStrictEqual(serialized.telemetry, { enabled: true });
+ assert.strictEqual(serialized.version, config.version);
+ });
+
+ for (const experimental of [true, 'invalid']) {
+ it(`leaves malformed experimental value ${JSON.stringify(experimental)} to the native parser`, () => {
+ const config = {
+ ...makeConfig('process'),
+ experimental,
+ } as unknown as ContainerConfig;
+
+ const { args } = resolveExecutableAndArgs(config, {
+ executablePath: fakeExe,
+ skipPlatformCheck: true,
+ });
+
+ assert.strictEqual(decodeConfig(args).experimental, experimental);
+ });
+ }
+ });
});
diff --git a/sdk/node/tests/unit/state-aware.test.ts b/sdk/node/tests/unit/state-aware.test.ts
index c9d364034..1c5f24f0f 100644
--- a/sdk/node/tests/unit/state-aware.test.ts
+++ b/sdk/node/tests/unit/state-aware.test.ts
@@ -33,19 +33,15 @@ describe('buildStateAwareEnvelope', () => {
assert.equal(env.experimental, undefined);
});
- it('rejects telemetry with an explicitly older schema version', () => {
- assert.throws(
- () => buildStateAwareEnvelope({
- phase: 'start',
- backendKey: 'windows_sandbox',
- sandboxId: 'wsb:01234567',
- config: { version: '0.8.0-alpha', telemetry: { enabled: true } },
- }),
- (error: unknown) =>
- error instanceof MxcError &&
- error.code === 'malformed_request' &&
- error.message.includes('telemetry requires schema version 0.9.0-alpha'),
- );
+ it('leaves explicit telemetry schema validation to the native parser', () => {
+ const env = buildStateAwareEnvelope({
+ phase: 'start',
+ backendKey: 'windows_sandbox',
+ sandboxId: 'wsb:01234567',
+ config: { version: '0.8.0-alpha', telemetry: { enabled: true } },
+ });
+ assert.deepEqual(env.telemetry, { enabled: true });
+ assert.equal(env.version, '0.8.0-alpha');
});
it('produces a provision envelope with cross-cutting fields lifted to top-level', () => {
@@ -157,6 +153,34 @@ describe('buildStateAwareEnvelope', () => {
assert.strictEqual(wire.experimental, undefined);
});
+ it('never emits correlationVector on state-aware envelopes', () => {
+ const nonProvision = buildStateAwareEnvelope({
+ phase: 'start',
+ backendKey: 'isolation_session',
+ sandboxId: 'iso:abc',
+ });
+ assert.strictEqual(nonProvision.correlationVector, undefined);
+
+ const provision = buildStateAwareEnvelope({
+ phase: 'provision',
+ backendKey: 'isolation_session',
+ containment: 'isolation_session',
+ });
+ assert.strictEqual(provision.correlationVector, undefined);
+ });
+
+ it('places stable telemetry at the envelope top level', () => {
+ const env = buildStateAwareEnvelope({
+ phase: 'start',
+ backendKey: 'isolation_session',
+ sandboxId: 'iso:abc',
+ config: { telemetry: { enabled: true } },
+ });
+ assert.deepStrictEqual(env.telemetry, { enabled: true });
+ assert.strictEqual(env.version, '0.9.0-alpha');
+ assert.strictEqual(env.experimental, undefined);
+ });
+
});
describe('parseNonExecResponse', () => {
@@ -335,6 +359,24 @@ describe('startSandbox', { skip: platformSkip }, () => {
);
});
+ it('does not serialize correlationVector onto the start envelope', async () => {
+ const fake = fakeSpawn({ stdout: '{"result":{}}', exitCode: 0 });
+ _setSpawnImpl(fake.spawn);
+ const id = 'iso:reg-abc:prov-1' as SandboxId<'isolation_session'>;
+ await startSandbox(id, undefined, testOptions());
+ assert.strictEqual(fake.captured.envelope?.correlationVector, undefined);
+ });
+
+ it('relays stable telemetry from phase config onto the start envelope', async () => {
+ const fake = fakeSpawn({ stdout: '{"result":{}}', exitCode: 0 });
+ _setSpawnImpl(fake.spawn);
+ const id = 'iso:reg-abc:prov-1' as SandboxId<'isolation_session'>;
+ await startSandbox(id, { telemetry: { enabled: false } }, testOptions());
+ assert.deepStrictEqual(fake.captured.envelope?.telemetry, { enabled: false });
+ assert.strictEqual(fake.captured.envelope?.version, '0.9.0-alpha');
+ assert.strictEqual(fake.captured.envelope?.experimental, undefined);
+ });
+
});
describe('stopSandbox', { skip: platformSkip }, () => {
@@ -361,6 +403,13 @@ describe('stopSandbox', { skip: platformSkip }, () => {
);
});
+ it('does not serialize correlationVector onto the stop envelope', async () => {
+ const fake = fakeSpawn({ stdout: '{"result":{}}', exitCode: 0 });
+ _setSpawnImpl(fake.spawn);
+ const id = 'iso:abc' as SandboxId<'isolation_session'>;
+ await stopSandbox(id, undefined, testOptions());
+ assert.strictEqual(fake.captured.envelope?.correlationVector, undefined);
+ });
});
describe('deprovisionSandbox', { skip: platformSkip }, () => {
@@ -374,6 +423,14 @@ describe('deprovisionSandbox', { skip: platformSkip }, () => {
assert.strictEqual(fake.captured.envelope?.phase, 'deprovision');
assert.strictEqual(fake.captured.envelope?.sandboxId, 'iso:abc');
});
+
+ it('does not serialize correlationVector onto the deprovision envelope', async () => {
+ const fake = fakeSpawn({ stdout: '{"result":{}}', exitCode: 0 });
+ _setSpawnImpl(fake.spawn);
+ const id = 'iso:abc' as SandboxId<'isolation_session'>;
+ await deprovisionSandbox(id, undefined, testOptions());
+ assert.strictEqual(fake.captured.envelope?.correlationVector, undefined);
+ });
});
describe('execInSandboxAsync', { skip: platformSkip }, () => {
@@ -432,6 +489,17 @@ describe('execInSandboxAsync', { skip: platformSkip }, () => {
);
});
+ it('does not serialize correlationVector onto the exec envelope', async () => {
+ const fake = fakeSpawn({ stdout: 'hi\n', stderr: '', exitCode: 0 });
+ _setSpawnImpl(fake.spawn);
+ const id = 'iso:abc' as SandboxId<'isolation_session'>;
+ await execInSandboxAsync(
+ id,
+ { process: { commandLine: 'echo hi' } },
+ testOptions(),
+ );
+ assert.strictEqual(fake.captured.envelope?.correlationVector, undefined);
+ });
});
describe('windows_sandbox state-aware lifecycle', () => {
diff --git a/sdk/node/tests/unit/telemetry.test.ts b/sdk/node/tests/unit/telemetry.test.ts
new file mode 100644
index 000000000..7e254d8a9
--- /dev/null
+++ b/sdk/node/tests/unit/telemetry.test.ts
@@ -0,0 +1,315 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { describe, it, beforeEach, afterEach } from 'node:test';
+import assert from 'node:assert';
+import {
+ queryTelemetryConsentAsync,
+ requestTelemetryConsent,
+ withdrawTelemetryConsentAsync,
+ _setTelemetryConsentAsyncRunner,
+ _setTelemetryConsentProtocolRunner,
+ _setTelemetryPlatform,
+ _resetTelemetryFailureReporting,
+ type TelemetryConsentPrompt,
+} from '../../src/telemetry.js';
+
+const prompt: TelemetryConsentPrompt = {
+ resourceVersion: 1,
+ locale: 'en-US',
+ title: { id: 'telemetry.consent.title', text: 'Help improve Microsoft eXecution Container (MXC)' },
+ body: { id: 'telemetry.consent.body', text: 'canonical body' },
+ affirmativeLabel: { id: 'telemetry.consent.yes', text: 'Yes' },
+ negativeLabel: { id: 'telemetry.consent.no', text: 'No' },
+ learnMoreLabel: { id: 'telemetry.consent.learnMore', text: 'Privacy Statement' },
+ learnMoreUrl: 'https://go.microsoft.com/fwlink/?linkid=521839',
+};
+
+function status(
+ effectiveState: 'granted' | 'denied' | 'undetermined' | 'not-applicable',
+ policy: 'unrestricted' | 'allowed' | 'blocked' | 'not-applicable' = 'unrestricted',
+ needsPrompt = false,
+): string {
+ return JSON.stringify({
+ action: 'status',
+ result: 'status',
+ storedState: effectiveState,
+ effectiveState,
+ needsPrompt,
+ policy,
+ reason: null,
+ });
+}
+
+function commandOutput(stdout: string, stderr = ''): { stdout: string; stderr: string } {
+ return { stdout, stderr };
+}
+
+describe('telemetry consent', () => {
+ beforeEach(() => {
+ _setTelemetryPlatform('win32');
+ });
+
+ afterEach(() => {
+ _setTelemetryConsentAsyncRunner(null);
+ _setTelemetryConsentProtocolRunner(null);
+ _setTelemetryPlatform(null);
+ });
+
+ it('parses typed stored/effective status', async () => {
+ _setTelemetryConsentAsyncRunner(async () => commandOutput(status('granted', 'allowed')));
+ assert.deepStrictEqual(await queryTelemetryConsentAsync(), {
+ state: 'granted',
+ storedState: 'granted',
+ effectiveState: 'granted',
+ needsPrompt: false,
+ policy: 'allowed',
+ });
+ });
+
+ it('queries status through the dedicated consent command', async () => {
+ let args: readonly string[] = [];
+ _setTelemetryConsentAsyncRunner(async (value) => {
+ args = value;
+ return commandOutput(status('undetermined', 'unrestricted', true));
+ });
+ assert.strictEqual((await queryTelemetryConsentAsync()).needsPrompt, true);
+ assert.deepStrictEqual(args, ['--telemetry-consent', 'status']);
+ assert.ok(!args.includes('--config-base64'));
+ });
+
+ it('deduplicates variable fail-closed details by operation and safe result', async () => {
+ _resetTelemetryFailureReporting();
+ const warnings: string[] = [];
+ const originalWarn = console.warn;
+ console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(' '));
+ try {
+ _setTelemetryConsentAsyncRunner(async () => commandOutput('not json'));
+ assert.strictEqual((await queryTelemetryConsentAsync()).effectiveState, 'undetermined');
+ assert.strictEqual((await queryTelemetryConsentAsync()).effectiveState, 'undetermined');
+ _setTelemetryConsentAsyncRunner(async () => commandOutput('different invalid output'));
+ assert.strictEqual((await queryTelemetryConsentAsync()).effectiveState, 'undetermined');
+ } finally {
+ console.warn = originalWarn;
+ }
+ assert.strictEqual(warnings.length, 1);
+ assert.ok(warnings.every((warning) => /fail-closed/.test(warning)));
+ });
+
+ it('deduplicates variable native status diagnostics without changing the typed result', async () => {
+ _resetTelemetryFailureReporting();
+ const warnings: string[] = [];
+ const originalWarn = console.warn;
+ console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(' '));
+ try {
+ let call = 0;
+ _setTelemetryConsentAsyncRunner(async () => {
+ call += 1;
+ return commandOutput(
+ JSON.stringify({
+ action: 'status',
+ result: 'status',
+ storedState: 'undetermined',
+ effectiveState: 'undetermined',
+ needsPrompt: false,
+ policy: 'blocked',
+ reason: 'store-unreadable',
+ }),
+ `mxc: telemetry consent store could not be read (${call})`,
+ );
+ });
+ const first = await queryTelemetryConsentAsync();
+ const second = await queryTelemetryConsentAsync();
+ assert.strictEqual(first.effectiveState, 'undetermined');
+ assert.strictEqual(first.policy, 'blocked');
+ assert.strictEqual(first.error, undefined);
+ assert.deepStrictEqual(second, first);
+ } finally {
+ console.warn = originalWarn;
+ }
+ assert.deepStrictEqual(warnings, [
+ 'mxc-sdk: queryTelemetryConsentAsync native diagnostic: '
+ + 'mxc: telemetry consent store could not be read (1)',
+ ]);
+ });
+
+ it('fails closed when native prompt eligibility conflicts with consent state or policy', async () => {
+ for (const response of [
+ status('granted', 'allowed', true),
+ status('undetermined', 'blocked', true),
+ ]) {
+ _setTelemetryConsentAsyncRunner(async () => commandOutput(response));
+ const query = await queryTelemetryConsentAsync();
+ assert.deepStrictEqual({
+ ...query,
+ error: undefined,
+ }, {
+ state: 'undetermined',
+ storedState: 'undetermined',
+ effectiveState: 'undetermined',
+ needsPrompt: false,
+ policy: 'blocked',
+ error: undefined,
+ });
+ assert.match(query.error ?? '', /unrecognised telemetry consent output/);
+ }
+ });
+
+ it('fails status queries closed for mismatched actions and invalid results', async () => {
+ _setTelemetryConsentAsyncRunner(async () => commandOutput(JSON.stringify({
+ action: 'status',
+ result: 'withdrawn',
+ storedState: 'granted',
+ effectiveState: 'granted',
+ needsPrompt: false,
+ policy: 'allowed',
+ reason: null,
+ })));
+ const asyncQuery = await queryTelemetryConsentAsync();
+ assert.strictEqual(asyncQuery.effectiveState, 'undetermined');
+ assert.strictEqual(asyncQuery.policy, 'blocked');
+ assert.strictEqual(asyncQuery.needsPrompt, false);
+ });
+
+ it('fails status queries closed when the native response omits reason', async () => {
+ _setTelemetryConsentAsyncRunner(async () => commandOutput(JSON.stringify({
+ action: 'status',
+ result: 'status',
+ storedState: 'granted',
+ effectiveState: 'granted',
+ needsPrompt: false,
+ policy: 'allowed',
+ })));
+ const query = await queryTelemetryConsentAsync();
+ assert.strictEqual(query.state, 'undetermined');
+ assert.strictEqual(query.storedState, 'undetermined');
+ assert.strictEqual(query.effectiveState, 'undetermined');
+ assert.strictEqual(query.policy, 'blocked');
+ assert.strictEqual(query.needsPrompt, false);
+ });
+
+ it('binds a synchronous presenter decision to the canonical prompt', async () => {
+ let observedLocale: string | undefined;
+ _setTelemetryConsentProtocolRunner(async (locale, presenter) => {
+ observedLocale = locale;
+ const decision = await presenter(prompt);
+ assert.strictEqual(decision, 'yes');
+ return {
+ action: 'request',
+ result: 'granted',
+ storedState: 'granted',
+ effectiveState: 'granted',
+ needsPrompt: false,
+ policy: 'unrestricted',
+ };
+ });
+
+ const outcome = await requestTelemetryConsent((value) => {
+ assert.deepStrictEqual(value, prompt);
+ return 'yes';
+ }, 'en-US');
+ assert.strictEqual(observedLocale, 'en-US');
+ assert.strictEqual(outcome.result, 'granted');
+ });
+
+ it('supports an asynchronous presenter and propagates presenter failure', async () => {
+ _setTelemetryConsentProtocolRunner(async (_locale, presenter) => {
+ await presenter(prompt);
+ throw new Error('should not continue');
+ });
+ await assert.rejects(
+ requestTelemetryConsent(async () => {
+ await Promise.resolve();
+ throw new Error('UI unavailable');
+ }),
+ /UI unavailable/,
+ );
+ });
+
+ it('queries and withdraws through the non-blocking runner', async () => {
+ const actions: string[] = [];
+ _setTelemetryConsentAsyncRunner(async (args) => {
+ assert.deepStrictEqual(args.slice(0, 1), ['--telemetry-consent']);
+ const action = args[1]!;
+ actions.push(action);
+ return commandOutput(action === 'status'
+ ? status('granted', 'allowed')
+ : JSON.stringify({
+ action: 'withdraw',
+ result: 'withdrawn',
+ storedState: 'denied',
+ effectiveState: 'denied',
+ needsPrompt: false,
+ policy: 'unrestricted',
+ reason: null,
+ }));
+ });
+
+ assert.strictEqual((await queryTelemetryConsentAsync()).effectiveState, 'granted');
+ assert.strictEqual((await withdrawTelemetryConsentAsync()).result, 'withdrawn');
+ assert.deepStrictEqual(actions, ['status', 'withdraw']);
+ });
+
+ it('rejects withdrawal responses with mismatched actions or invalid results', async () => {
+ _setTelemetryConsentAsyncRunner(async () => commandOutput(JSON.stringify({
+ action: 'withdraw',
+ result: 'status',
+ storedState: 'denied',
+ effectiveState: 'denied',
+ needsPrompt: false,
+ policy: 'blocked',
+ reason: null,
+ })));
+ await assert.rejects(
+ withdrawTelemetryConsentAsync(),
+ /unrecognised telemetry consent output/,
+ );
+ });
+
+ it('rejects withdrawal responses that omit reason', async () => {
+ _setTelemetryConsentAsyncRunner(async () => commandOutput(JSON.stringify({
+ action: 'withdraw',
+ result: 'withdrawn',
+ storedState: 'denied',
+ effectiveState: 'denied',
+ needsPrompt: false,
+ policy: 'unrestricted',
+ })));
+ await assert.rejects(
+ withdrawTelemetryConsentAsync(),
+ /unrecognised telemetry consent output/,
+ );
+ });
+});
+
+describe('telemetry consent is Windows-only', () => {
+ afterEach(() => {
+ _setTelemetryConsentAsyncRunner(null);
+ _setTelemetryConsentProtocolRunner(null);
+ _setTelemetryPlatform(null);
+ });
+
+ for (const platform of ['linux', 'darwin'] as const) {
+ it(`does not query or present consent on ${platform}`, async () => {
+ _setTelemetryPlatform(platform);
+ let called = false;
+ _setTelemetryConsentAsyncRunner(async () => {
+ called = true;
+ throw new Error('must not run');
+ });
+ _setTelemetryConsentProtocolRunner(async () => {
+ called = true;
+ throw new Error('must not run');
+ });
+
+ const request = await requestTelemetryConsent(() => {
+ called = true;
+ return 'yes';
+ });
+ assert.strictEqual(request.result, 'notApplicable');
+ assert.strictEqual((await queryTelemetryConsentAsync()).state, 'not-applicable');
+ assert.strictEqual((await withdrawTelemetryConsentAsync()).result, 'notApplicable');
+ assert.strictEqual(called, false);
+ });
+ }
+});
diff --git a/sdk/node/tests/unit/wire-conformance.test.ts b/sdk/node/tests/unit/wire-conformance.test.ts
index cbb06dc70..4fe23dbaa 100644
--- a/sdk/node/tests/unit/wire-conformance.test.ts
+++ b/sdk/node/tests/unit/wire-conformance.test.ts
@@ -62,6 +62,7 @@ import type {
PortMapping as PublicPortMapping,
LxcConfig,
SeatbeltConfig,
+ TelemetryConfig,
ContainerConfig,
ClipboardPolicy as PublicClipboardPolicy,
ContainmentType,
@@ -86,6 +87,7 @@ import type {
PortMapping as WirePortMapping,
Lxc as WireLxc,
Seatbelt as WireSeatbelt,
+ Telemetry as WireTelemetry,
MXCConfiguration as WireMxcConfig,
ClipboardPolicy as WireClipboardPolicy,
Containment as WireContainment,
@@ -167,6 +169,7 @@ type _BaseProcessUiVals = AssertTrue>;
type _PortMappingVals = AssertTrue>;
type _SeatbeltVals = AssertTrue>;
+type _TelemetryVals = AssertTrue>;
type _LxcVals = AssertTrue, WireLxc>>;
// --- key conformance (rename / removal detection) -------------------------
@@ -187,6 +190,7 @@ type _BaseProcessUiKeys = AssertTrue, never>>;
type _PortMappingKeys = AssertTrue, never>>;
type _SeatbeltKeys = AssertTrue, never>>;
+type _TelemetryKeys = AssertTrue, never>>;
// `FilesystemConfig.clearPolicyOnExit` is an SDK-side convenience flag mapped
// into `lifecycle.preservePolicy`; it is not a wire `filesystem` field.
@@ -251,6 +255,7 @@ type _ProcessContainerWireKeys = AssertTrue<
type _SeatbeltWireKeys = AssertTrue<
Equivalent, 'guiAccess' | 'launchMethod'>
>;
+type _TelemetryWireKeys = AssertTrue, never>>;
// Root: the SDK's `ContainerConfig` intentionally omits the schema-metadata keys
// (`$schema`, `_comment`), the state-aware-only keys (`phase`, `sandboxId`),