using System.Globalization; using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Nexus.Api.Services; using Xunit; namespace Nexus.Api.Tests; public sealed class OpenClawControlServiceTests { [Fact] public async Task DisconnectedTaskList_ReportsRecoveryWithoutInvokingGateway() { var gateway = new StubOpenClawConnector { ConnectionState = GatewayConnectionState.Disconnected }; var service = CreateService(gateway); var result = await service.GetTasksAsync(); Assert.Equal("disconnected", result.State); Assert.Empty(result.Items); Assert.NotNull(result.Recovery); Assert.Empty(gateway.Invocations); } [Fact] public async Task DisconnectedMutation_WithInvalidMetadata_ReturnsControlledInvalidResult() { var gateway = new StubOpenClawConnector { ConnectionState = GatewayConnectionState.Disconnected }; var service = CreateService(gateway); var invalidContext = new OpenClawInvocationContext( "idem-task-1", "corr-task-1", "owner-1", "not-a-traceparent"); var result = await service.CancelTaskAsync( "task-1", null, invocationContext: invalidContext); Assert.False(result.Ok); Assert.Equal("invalid", result.State); Assert.Empty(gateway.Invocations); } [Fact] public void PairingRecovery_OnlyEmbedsShellSafeRequestId() { var gateway = new StubOpenClawConnector { ConnectionState = GatewayConnectionState.Disconnected, PairingRequired = true, PairingRequestId = "request'; remove-item secret" }; var service = CreateService(gateway); var connection = service.GetConnection(); Assert.True(connection.PairingRequired); Assert.Equal("request'; remove-item secret", connection.PairingRequestId); Assert.NotNull(connection.Recovery); Assert.DoesNotContain("request'; remove-item secret", connection.Recovery); Assert.DoesNotContain("openclaw devices approve", connection.Recovery); } [Fact] public async Task TaskList_NormalizesGatewayLedgerShape() { var gateway = Connected( methods: ["tasks.list", "tasks.cancel"], scopes: ["operator.read", "operator.write"]); gateway.Handler = (method, _) => method == "tasks.list" ? JsonNode.Parse( """ { "tasks": [ { "id": "task-1", "title": "Index workspace", "status": "running", "agentId": "researcher", "sessionKey": "agent:researcher:main", "runId": "run-1", "startedAtMs": 1785000000000, "progress": 42 }, { "id": "task-2", "title": "Review output", "status": "completed" } ], "nextCursor": "next-1" } """) : null; var service = CreateService(gateway); var result = await service.GetTasksAsync(); Assert.Equal("ready", result.State); Assert.Equal("next-1", result.NextCursor); Assert.Equal(2, result.Items.Count); Assert.Equal("running", result.Items[0].Status); Assert.True(result.Items[0].CanCancel); Assert.Equal("succeeded", result.Items[1].Status); Assert.False(result.Items[1].CanCancel); } [Fact] public async Task CronList_MapsScheduleAndRequiresAdminForRun() { var gateway = Connected( methods: ["cron.list", "cron.run"], scopes: ["operator.read"]); gateway.Handler = (_, _) => JsonNode.Parse( """ { "jobs": [{ "id": "job-1", "name": "Morning brief", "enabled": true, "schedule": { "kind": "cron", "expr": "0 7 * * *", "tz": "Europe/Berlin" }, "state": { "nextRunAtMs": 1785000000000, "lastRunStatus": "ok" } }] } """); var service = CreateService(gateway); var result = await service.GetCronJobsAsync(); var job = Assert.Single(result.Items); Assert.Equal("0 7 * * *", job.Schedule); Assert.Equal("Europe/Berlin", job.TimeZone); Assert.Equal("ok", job.Status); Assert.False(job.CanRun); } [Fact] public async Task CronList_ForwardsFiltersAndTranslatesOffsetCursor() { var gateway = Connected( methods: ["cron.list"], scopes: ["operator.read"]); gateway.Handler = (_, parameters) => { var offset = parameters?["offset"]?.GetValue() ?? 0; return offset == 0 ? JsonNode.Parse( """ { "jobs": [{ "id": "job-1", "name": "First", "enabled": true, "schedule": { "kind": "every", "everyMs": 60000 }, "state": {} }], "nextOffset": 1 } """) : JsonNode.Parse( """ { "jobs": [{ "id": "job-2", "name": "Second", "enabled": true, "schedule": { "kind": "every", "everyMs": 120000 }, "state": {} }], "nextOffset": null } """); }; var service = CreateService(gateway); var first = await service.GetCronJobsAsync( includeDisabled: false, limit: 1); var second = await service.GetCronJobsAsync( includeDisabled: false, limit: 1, cursor: first.NextCursor); Assert.NotNull(first.NextCursor); Assert.Equal("job-2", Assert.Single(second.Items).Id); Assert.Collection( gateway.Invocations, invocation => { Assert.Equal(false, invocation.Parameters?["includeDisabled"]?.GetValue()); Assert.Equal(0, invocation.Parameters?["offset"]?.GetValue()); }, invocation => Assert.Equal(1, invocation.Parameters?["offset"]?.GetValue())); } [Fact] public async Task CronDetail_MapsEditableDefinitionAndStableResourceHash() { var gateway = Connected( methods: ["cron.get", "cron.update", "cron.remove", "cron.run"], scopes: ["operator.read", "operator.admin"]); gateway.Handler = (_, _) => CronJob( name: "Morning brief", enabled: true, deliveryTarget: "+49 151 12345678"); var service = CreateService(gateway); var first = await service.GetCronJobAsync("job-1"); var second = await service.GetCronJobAsync("job-1"); Assert.True(first.Ok); Assert.NotNull(first.Data); Assert.Equal("cron", first.Data.Schedule.Kind); Assert.Equal("agentTurn", first.Data.Payload.Kind); Assert.Equal("+49 151 12345678", first.Data.Delivery?.Target); Assert.Equal(first.Data.ResourceHash, second.Data?.ResourceHash); Assert.True(first.Data.CanUpdate); Assert.True(first.Data.CanDelete); } [Fact] public async Task CronRuns_UsesRunFilterAndRedactsDeliveryTargets() { var gateway = Connected( methods: ["cron.runs"], scopes: ["operator.read"]); gateway.Handler = (_, _) => JsonNode.Parse( """ { "entries": [{ "ts": 1785000000000, "jobId": "job-1", "runId": "run-1", "action": "finished", "status": "error", "summary": "Delivery to +49 151 12345678 failed", "deliveryError": "Webhook https://example.invalid/hooks/secret failed" }], "nextOffset": 1 } """); var service = CreateService(gateway); var result = await service.GetCronRunsAsync( "job-1", limit: 1, runId: "run-1"); var run = Assert.Single(result.Items); Assert.DoesNotContain("12345678", run.Summary); Assert.DoesNotContain("example.invalid", run.DeliveryError); Assert.NotNull(result.NextCursor); var invocation = Assert.Single(gateway.Invocations); Assert.Equal("job", invocation.Parameters?["scope"]?.GetValue()); Assert.Equal("job-1", invocation.Parameters?["id"]?.GetValue()); Assert.Equal("run-1", invocation.Parameters?["runId"]?.GetValue()); } [Fact] public async Task CronCreate_BlocksCommandPayloadBeforeGatewayInvocation() { var gateway = Connected( methods: ["cron.add"], scopes: ["operator.admin"]); var service = CreateService(gateway, allowCommandCron: false); var request = new Nexus.Api.Models.CreateOpenClawCronJobRequest( "Unsafe", JsonNode.Parse("""{ "kind": "every", "everyMs": 60000 }""")!.AsObject(), "isolated", "now", JsonNode.Parse("""{ "kind": "command", "argv": ["whoami"] }""")!.AsObject()); var result = await service.CreateCronJobAsync(request); Assert.False(result.Ok); Assert.Equal("restricted", result.State); Assert.Equal("cron", result.Operation?.PrimaryRef?.Type); Assert.StartsWith("create:", result.Operation?.PrimaryRef?.Id); Assert.Empty(gateway.Invocations); } [Fact] public async Task CronCreate_UsesOfficialAddShapeAndReturnsTypedDetail() { var gateway = Connected( methods: ["cron.add"], scopes: ["operator.admin"]); gateway.Handler = (_, _) => new JsonObject { ["created"] = true, ["job"] = CronJob(name: "Safe reminder", enabled: true) }; var service = CreateService(gateway); var request = new Nexus.Api.Models.CreateOpenClawCronJobRequest( "Safe reminder", JsonNode.Parse("""{ "kind": "every", "everyMs": 60000 }""")!.AsObject(), "main", "now", JsonNode.Parse("""{ "kind": "systemEvent", "text": "Check status" }""")!.AsObject(), DeclarationKey: "nexus.safe-reminder"); var result = await service.CreateCronJobAsync(request); Assert.True(result.Ok); Assert.Equal("job-1", result.Data?.Id); Assert.Equal("cron", result.Operation?.PrimaryRef?.Type); Assert.Equal("job-1", result.Operation?.PrimaryRef?.Id); var invocation = Assert.Single(gateway.Invocations); Assert.Equal("cron.add", invocation.Method); Assert.Equal("Safe reminder", invocation.Parameters?["name"]?.GetValue()); Assert.Equal("every", invocation.Parameters?["schedule"]?["kind"]?.GetValue()); Assert.Equal("systemEvent", invocation.Parameters?["payload"]?["kind"]?.GetValue()); Assert.Equal( "nexus.safe-reminder", invocation.Parameters?["declarationKey"]?.GetValue()); } [Fact] public async Task CronPatch_RejectsStaleHashWithoutMutatingGateway() { var gateway = Connected( methods: ["cron.get", "cron.update"], scopes: ["operator.read", "operator.admin"]); gateway.Handler = (method, _) => method == "cron.get" ? CronJob(name: "Morning brief", enabled: true) : throw new InvalidOperationException("stale writes must not reach cron.update"); var service = CreateService(gateway); var patch = JsonNode.Parse("""{ "enabled": false }""")!.AsObject(); var result = await service.PatchCronJobAsync( "job-1", patch, expectedHash: "stale"); Assert.False(result.Ok); Assert.Equal("conflict", result.State); Assert.Single(gateway.Invocations); Assert.Equal("cron.get", gateway.Invocations[0].Method); } [Fact] public async Task CronPatch_UsesOfficialPatchShapeAfterHashCheck() { var gateway = Connected( methods: ["cron.get", "cron.update"], scopes: ["operator.read", "operator.admin"]); gateway.Handler = (method, parameters) => method switch { "cron.get" => CronJob(name: "Morning brief", enabled: true), "cron.update" => CronJob( name: "Morning brief", enabled: parameters?["patch"]?["enabled"]?.GetValue() ?? true), _ => null }; var service = CreateService(gateway); var detail = await service.GetCronJobAsync("job-1"); var result = await service.PatchCronJobAsync( "job-1", JsonNode.Parse("""{ "enabled": false }""")!.AsObject(), detail.Data!.ResourceHash); Assert.True(result.Ok); Assert.False(result.Data!.Enabled); Assert.Equal("cron.update", gateway.Invocations.Last().Method); Assert.Equal("job-1", gateway.Invocations.Last().Parameters?["id"]?.GetValue()); Assert.Equal( false, gateway.Invocations.Last().Parameters?["patch"]?["enabled"]?.GetValue()); } [Fact] public async Task CronDelete_ChecksHashAndUsesOfficialRemoveShape() { var gateway = Connected( methods: ["cron.get", "cron.remove"], scopes: ["operator.read", "operator.admin"]); gateway.Handler = (method, _) => method switch { "cron.get" => CronJob(name: "Morning brief", enabled: true), "cron.remove" => JsonNode.Parse("""{ "removed": true }"""), _ => null }; var service = CreateService(gateway); var detail = await service.GetCronJobAsync("job-1"); var result = await service.DeleteCronJobAsync( "job-1", detail.Data!.ResourceHash); Assert.True(result.Ok); Assert.Equal("cron.remove", gateway.Invocations.Last().Method); Assert.Equal("job-1", gateway.Invocations.Last().Parameters?["id"]?.GetValue()); } [Fact] public async Task CronMutation_RequiresLocalManagementGate() { var gateway = Connected( methods: ["cron.run"], scopes: ["operator.admin"]); var service = CreateService(gateway, managementEnabled: false); var result = await service.RunCronJobAsync("job-1"); Assert.False(result.Ok); Assert.Equal("management_disabled", result.State); Assert.Equal("cron", result.Operation?.PrimaryRef?.Type); Assert.Equal("job-1", result.Operation?.PrimaryRef?.Id); Assert.Empty(gateway.Invocations); } [Fact] public void Capabilities_DistinguishMissingMethodFromMissingScope() { var gateway = Connected( methods: ["tasks.list", "tasks.cancel"], scopes: ["operator.read"]); var service = CreateService(gateway); var capabilities = service.GetCapabilities(); Assert.Equal("ready", capabilities.Single(item => item.Id == "tasks-read").State); Assert.Equal("forbidden", capabilities.Single(item => item.Id == "tasks-cancel").State); Assert.Equal("unsupported", capabilities.Single(item => item.Id == "sessions-read").State); } [Fact] public async Task CancelTask_UsesDocumentedTaskIdAndReason() { var gateway = Connected( methods: ["tasks.cancel"], scopes: ["operator.write"]); gateway.Handler = (_, _) => JsonNode.Parse( """ { "found": true, "cancelled": true, "task": { "id": "task-9", "title": "Long run", "status": "cancelled" } } """); var service = CreateService(gateway); var result = await service.CancelTaskAsync("task-9", "Operator stop"); Assert.True(result.Ok); Assert.Equal("openclaw-task", result.Operation?.PrimaryRef?.Type); Assert.Equal("task-9", result.Operation?.PrimaryRef?.Id); var invocation = Assert.Single(gateway.Invocations); Assert.Equal("tasks.cancel", invocation.Method); Assert.Equal("task-9", invocation.Parameters?["taskId"]?.GetValue()); Assert.Equal("Operator stop", invocation.Parameters?["reason"]?.GetValue()); } [Fact] public async Task ResolveApproval_RejectsUnknownDecisionBeforeGatewayCall() { var gateway = Connected( methods: ["approval.resolve"], scopes: ["operator.approvals"]); var service = CreateService(gateway); var result = await service.ResolveApprovalAsync("approval-1", "exec", "approve"); Assert.False(result.Ok); Assert.Equal("invalid", result.State); Assert.Equal("approval", result.Operation?.PrimaryRef?.Type); Assert.Equal("approval-1", result.Operation?.PrimaryRef?.Id); Assert.Empty(gateway.Invocations); } [Fact] public async Task PatchSessionModel_UsesDocumentedSessionKeyAndModel() { var gateway = Connected( methods: ["sessions.patch"], scopes: ["operator.write"]); gateway.Handler = (_, _) => JsonNode.Parse( """{ "model": "openai/gpt-5.4", "provider": "openai" }"""); var service = CreateService(gateway); var result = await service.PatchSessionModelAsync( "agent:iris:main", "openai/gpt-5.4"); Assert.True(result.Ok); var invocation = Assert.Single(gateway.Invocations); Assert.Equal("sessions.patch", invocation.Method); Assert.Equal("agent:iris:main", invocation.Parameters?["key"]?.GetValue()); Assert.Equal("openai/gpt-5.4", invocation.Parameters?["model"]?.GetValue()); } [Fact] public async Task ModelAuthStatus_AggregatesProfilesAndDropsSensitiveGatewayFields() { var gateway = Connected( methods: ["models.authStatus"], scopes: ["operator.read"]); gateway.Handler = (_, _) => JsonNode.Parse( """ { "ts": 1785400000000, "providers": [{ "provider": "openai-codex", "displayName": "OpenAI Codex", "status": "expiring", "expiry": { "at": 1785486400000, "remainingMs": 86400000, "label": "untrusted gateway label" }, "profiles": [ { "profileId": "bao@example.test", "type": "oauth", "status": "ok", "accessToken": "secret-oauth-token" }, { "profileId": "second-private-profile", "type": "oauth", "status": "ok" }, { "profileId": "legacy-token-profile", "type": "token", "status": "expired" } ], "apiKey": { "source": "env", "envVar": "OPENAI_API_KEY", "value": "secret-api-key" }, "usage": { "providerId": "openai", "summary": "82% remaining", "plan": "team", "accountEmail": "billing@example.test", "billing": [{ "amount": 99, "currency": "EUR" }], "windows": [{ "label": "5h", "usedPercent": 18 }] } }] } """); var service = CreateService(gateway); var result = await service.GetModelAuthStatusAsync(refresh: true); Assert.Equal("ready", result.State); var provider = Assert.Single(result.Items); Assert.Equal("openai-codex", provider.Provider); Assert.Equal("OpenAI Codex", provider.DisplayName); Assert.Equal("expiring", provider.Status); Assert.Equal("1d", provider.Expiry?.Label); Assert.Equal("env", provider.ApiKey?.Source); Assert.Equal("OPENAI_API_KEY", provider.ApiKey?.EnvVar); Assert.Equal("82% remaining", provider.Usage?.Summary); Assert.Equal("team", provider.Usage?.Plan); var oauth = Assert.Single( provider.Profiles, profile => profile.Type == "oauth" && profile.Status == "ok"); Assert.Equal(2, oauth.Count); var token = Assert.Single( provider.Profiles, profile => profile.Type == "token" && profile.Status == "expired"); Assert.Equal(1, token.Count); var serialized = JsonSerializer.Serialize(provider); Assert.DoesNotContain("profileId", serialized, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("bao@example.test", serialized, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("billing", serialized, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("secret", serialized, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("windows", serialized, StringComparison.OrdinalIgnoreCase); var invocation = Assert.Single(gateway.Invocations); Assert.Equal("models.authStatus", invocation.Method); Assert.True(invocation.Parameters?["refresh"]?.GetValue()); } [Fact] public async Task ModelAuthStatus_RejectsUnsafeMetadataAndReportsUnsupported() { var gateway = Connected( methods: ["models.authStatus"], scopes: ["operator.read"]); gateway.Handler = (_, _) => JsonNode.Parse( """ { "providers": [{ "provider": "custom", "displayName": "Custom", "status": "future-state", "profiles": [{ "type": "future-profile", "status": "future-state" }], "apiKey": { "source": "env", "envVar": "bad env name" }, "usage": { "summary": "details at billing@example.test", "plan": "https://provider.example/private" } }] } """); var service = CreateService(gateway); var result = await service.GetModelAuthStatusAsync(); var provider = Assert.Single(result.Items); Assert.Equal("unknown", provider.Status); var profile = Assert.Single(provider.Profiles); Assert.Equal("unknown", profile.Type); Assert.Equal("unknown", profile.Status); Assert.Null(provider.ApiKey?.EnvVar); Assert.Null(provider.Usage); gateway.AdvertisedMethods = new HashSet(StringComparer.Ordinal); var unsupported = await service.GetModelAuthStatusAsync(); Assert.Equal("unsupported", unsupported.State); Assert.Empty(unsupported.Items); } [Fact] public async Task RunCronJob_RequiresAdminAndUsesForceMode() { var gateway = Connected( methods: ["cron.run"], scopes: ["operator.admin"]); gateway.Handler = (_, _) => JsonNode.Parse( """{ "enqueued": true, "runId": "run-17" }"""); var service = CreateService(gateway); var result = await service.RunCronJobAsync("job-17"); Assert.True(result.Ok); var invocation = Assert.Single(gateway.Invocations); Assert.Equal("cron.run", invocation.Method); Assert.Equal("job-17", invocation.Parameters?["id"]?.GetValue()); Assert.Equal("force", invocation.Parameters?["mode"]?.GetValue()); } [Fact] public async Task MutationContext_DeduplicatesRetryAndDoesNotAddUndocumentedGatewayField() { var testRoot = Path.Combine( Path.GetTempPath(), "nexus-openclaw-control-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(testRoot); try { var gateway = Connected( methods: ["tasks.cancel"], scopes: ["operator.write"]); gateway.Handler = (_, _) => JsonNode.Parse( """ { "found": true, "cancelled": true, "task": { "id": "task-9", "title": "Long run", "status": "cancelled" } } """); var audit = new OpenClawOperationAuditStore( Options.Create(new GatewayConnectorOptions { DeviceStatePath = Path.Combine(testRoot, "device.json"), OperationAuditPath = Path.Combine(testRoot, "operations.jsonl") })); var service = CreateService(gateway, audit); var context = OpenClawInvocationContext.Create( actor: "owner-1", idempotencyKey: "idem-task-9", correlationId: "corr-task-9", traceParent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", includeIdempotencyParameter: true); var first = await service.CancelTaskAsync( "task-9", "Operator stop", invocationContext: context); var retry = await service.CancelTaskAsync( "task-9", "Operator stop", invocationContext: context); var conflict = await service.CancelTaskAsync( "task-9", "Different reason", invocationContext: context); Assert.True(first.Ok); Assert.Equal("replayed", retry.State); Assert.Equal("idempotency_conflict", conflict.State); var invocation = Assert.Single(gateway.Invocations); Assert.Equal(context.TraceParent, invocation.Context?.TraceParent); Assert.Equal(context.CorrelationId, first.CorrelationId); Assert.Equal(context.IdempotencyKey, first.IdempotencyKey); Assert.Equal(context.CorrelationId, first.Operation?.OperationId); Assert.Equal("openclaw-task", retry.Operation?.PrimaryRef?.Type); Assert.Equal("task-9", conflict.Operation?.PrimaryRef?.Id); Assert.Null(invocation.Parameters?["idempotencyKey"]); } finally { var safeRoot = Path.GetFullPath(Path.Combine( Path.GetTempPath(), "nexus-openclaw-control-tests")); var resolved = Path.GetFullPath(testRoot); if (resolved.StartsWith(safeRoot + Path.DirectorySeparatorChar, StringComparison.Ordinal) && Directory.Exists(resolved)) { Directory.Delete(resolved, recursive: true); } } } private static OpenClawControlService CreateService( StubOpenClawConnector gateway, IOpenClawOperationAuditStore? auditStore = null, bool managementEnabled = true, bool allowCommandCron = true) { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Integrations:OpenClaw:BaseUrl"] = "http://127.0.0.1:18789", ["Integrations:OpenClaw:Token"] = "test-only-token", ["Integrations:OpenClaw:ManagementEnabled"] = managementEnabled.ToString(CultureInfo.InvariantCulture), ["Integrations:OpenClaw:AllowCommandCron"] = allowCommandCron.ToString(CultureInfo.InvariantCulture) }) .Build(); return new OpenClawControlService( gateway, configuration, new StubOpenClawWriteGate( managementEnabled ? OpenClawWriteGateDecision.Permit() : OpenClawWriteGateDecision.Block( "management_disabled", "OpenClaw management is disabled.")), NullLogger.Instance, operationAuditStore: auditStore); } private static JsonNode CronJob( string name, bool enabled, string? deliveryTarget = null) { var job = JsonNode.Parse( $$""" { "id": "job-1", "name": {{JsonSerializer.Serialize(name)}}, "enabled": {{enabled.ToString().ToLowerInvariant()}}, "createdAtMs": 1784000000000, "updatedAtMs": 1785000000000, "schedule": { "kind": "cron", "expr": "0 7 * * *", "tz": "Europe/Berlin" }, "sessionTarget": "isolated", "wakeMode": "now", "payload": { "kind": "agentTurn", "message": "Prepare brief" }, "state": { "nextRunAtMs": 1786000000000 } } """)!.AsObject(); if (deliveryTarget is not null) { job["delivery"] = new JsonObject { ["mode"] = "announce", ["channel"] = "telegram", ["to"] = deliveryTarget }; } return job; } private static StubOpenClawConnector Connected( IEnumerable methods, IEnumerable scopes) { return new StubOpenClawConnector { ConnectionState = GatewayConnectionState.Connected, GatewayVersion = "2026.7.1", ProtocolVersion = 4, AdvertisedMethods = methods.ToHashSet(StringComparer.Ordinal), GrantedScopes = scopes.ToHashSet(StringComparer.Ordinal) }; } } public sealed class StubOpenClawConnector : IGatewayConnector { public GatewayConnectionState ConnectionState { get; set; } = GatewayConnectionState.Initializing; public string? GatewayVersion { get; set; } public string? RequiredVersion { get; set; } public DateTimeOffset? LastConnectedAt { get; set; } public int ReconnectAttempts { get; set; } public string? StatusMessage { get; set; } public string? DeviceId { get; set; } public string? ActiveEndpoint { get; set; } public string? ActiveTlsFingerprint { get; set; } public bool DeviceTokenConfigured { get; set; } public bool PairingRequired { get; set; } public string? PairingRequestId { get; set; } public int? ProtocolVersion { get; set; } public IReadOnlySet AdvertisedMethods { get; set; } = new HashSet(StringComparer.Ordinal); public IReadOnlySet AdvertisedEvents { get; set; } = new HashSet(StringComparer.Ordinal); public IReadOnlySet GrantedScopes { get; set; } = new HashSet(StringComparer.Ordinal); public DateTimeOffset? LastEventAt { get; set; } public Func? Handler { get; set; } public List<(string Method, JsonNode? Parameters, OpenClawInvocationContext? Context)> Invocations { get; } = []; public bool Supports(string method) => AdvertisedMethods.Contains(method); public Task InvokeAsync( string method, object? parameters = null, TimeSpan? timeout = null, CancellationToken cancellationToken = default, OpenClawInvocationContext? invocationContext = null) { var node = parameters switch { null => null, JsonNode jsonNode => jsonNode.DeepClone(), _ => JsonSerializer.SerializeToNode(parameters) }; Invocations.Add((method, node, invocationContext)); return Task.FromResult(Handler?.Invoke(method, node)); } public IReadOnlyList GetRecentEvents(int limit = 100) => []; }