feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,839 @@
|
||||
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<int>() ?? 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<bool>());
|
||||
Assert.Equal(0, invocation.Parameters?["offset"]?.GetValue<int>());
|
||||
},
|
||||
invocation => Assert.Equal(1, invocation.Parameters?["offset"]?.GetValue<int>()));
|
||||
}
|
||||
|
||||
[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<string>());
|
||||
Assert.Equal("job-1", invocation.Parameters?["id"]?.GetValue<string>());
|
||||
Assert.Equal("run-1", invocation.Parameters?["runId"]?.GetValue<string>());
|
||||
}
|
||||
|
||||
[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<string>());
|
||||
Assert.Equal("every", invocation.Parameters?["schedule"]?["kind"]?.GetValue<string>());
|
||||
Assert.Equal("systemEvent", invocation.Parameters?["payload"]?["kind"]?.GetValue<string>());
|
||||
Assert.Equal(
|
||||
"nexus.safe-reminder",
|
||||
invocation.Parameters?["declarationKey"]?.GetValue<string>());
|
||||
}
|
||||
|
||||
[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<bool>() ?? 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<string>());
|
||||
Assert.Equal(
|
||||
false,
|
||||
gateway.Invocations.Last().Parameters?["patch"]?["enabled"]?.GetValue<bool>());
|
||||
}
|
||||
|
||||
[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<string>());
|
||||
}
|
||||
|
||||
[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<string>());
|
||||
Assert.Equal("Operator stop", invocation.Parameters?["reason"]?.GetValue<string>());
|
||||
}
|
||||
|
||||
[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<string>());
|
||||
Assert.Equal("openai/gpt-5.4", invocation.Parameters?["model"]?.GetValue<string>());
|
||||
}
|
||||
|
||||
[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<bool>());
|
||||
}
|
||||
|
||||
[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<string>(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<string>());
|
||||
Assert.Equal("force", invocation.Parameters?["mode"]?.GetValue<string>());
|
||||
}
|
||||
|
||||
[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<string, string?>
|
||||
{
|
||||
["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<OpenClawControlService>.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<string> methods,
|
||||
IEnumerable<string> 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<string> AdvertisedMethods { get; set; } =
|
||||
new HashSet<string>(StringComparer.Ordinal);
|
||||
public IReadOnlySet<string> AdvertisedEvents { get; set; } =
|
||||
new HashSet<string>(StringComparer.Ordinal);
|
||||
public IReadOnlySet<string> GrantedScopes { get; set; } =
|
||||
new HashSet<string>(StringComparer.Ordinal);
|
||||
public DateTimeOffset? LastEventAt { get; set; }
|
||||
public Func<string, JsonNode?, JsonNode?>? Handler { get; set; }
|
||||
public List<(string Method, JsonNode? Parameters, OpenClawInvocationContext? Context)> Invocations { get; } = [];
|
||||
|
||||
public bool Supports(string method) => AdvertisedMethods.Contains(method);
|
||||
|
||||
public Task<JsonNode?> 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<GatewayEventEnvelope> GetRecentEvents(int limit = 100) => [];
|
||||
}
|
||||
Reference in New Issue
Block a user