feat: validate single-client broadcast operations
Build web service image / build (push) Successful in 1m9s
Build web service image / build (push) Successful in 1m9s
This commit is contained in:
@@ -33,6 +33,7 @@ public enum RemoteNodeStatus
|
||||
public enum RemoteTaskStatus
|
||||
{
|
||||
Pending,
|
||||
WaitingForClient,
|
||||
Accepted,
|
||||
Running,
|
||||
Succeeded,
|
||||
@@ -246,7 +247,8 @@ public sealed record RemoteNodeRegistration(
|
||||
[property: JsonPropertyName("protocol_version")] string ProtocolVersion,
|
||||
[property: JsonPropertyName("capabilities")] IReadOnlyList<string> Capabilities,
|
||||
[property: JsonPropertyName("reporting_config_version")] long ReportingConfigVersion,
|
||||
[property: JsonPropertyName("accounts")] IReadOnlyList<RemoteAccountSummary> Accounts);
|
||||
[property: JsonPropertyName("accounts")] IReadOnlyList<RemoteAccountSummary> Accounts,
|
||||
[property: JsonPropertyName("connection_id")] string? ConnectionId = null);
|
||||
|
||||
public sealed record RemoteAccountSummary(
|
||||
[property: JsonPropertyName("account_id")] string AccountId,
|
||||
@@ -259,13 +261,15 @@ public sealed record RemoteNodeRegistrationResponse(
|
||||
[property: JsonPropertyName("node_id")] string NodeId,
|
||||
[property: JsonPropertyName("status")] RemoteNodeStatus Status,
|
||||
[property: JsonPropertyName("authenticated")] bool Authenticated,
|
||||
[property: JsonPropertyName("correlation_id")] string CorrelationId);
|
||||
[property: JsonPropertyName("correlation_id")] string CorrelationId,
|
||||
[property: JsonPropertyName("connection_id")] string? ConnectionId = null);
|
||||
|
||||
public sealed record RemoteHeartbeatResponse(
|
||||
[property: JsonPropertyName("node_id")] string NodeId,
|
||||
[property: JsonPropertyName("status")] RemoteNodeStatus Status,
|
||||
[property: JsonPropertyName("last_heartbeat_at")] DateTimeOffset LastHeartbeatAt,
|
||||
[property: JsonPropertyName("correlation_id")] string CorrelationId);
|
||||
[property: JsonPropertyName("correlation_id")] string CorrelationId,
|
||||
[property: JsonPropertyName("connection_id")] string? ConnectionId = null);
|
||||
|
||||
public sealed record RemoteTaskBatch(
|
||||
[property: JsonPropertyName("tasks")] IReadOnlyList<RemoteTaskEnvelope> Tasks);
|
||||
@@ -282,7 +286,8 @@ public sealed record RemoteHeartbeat(
|
||||
[property: JsonPropertyName("queue_length")] int QueueLength,
|
||||
[property: JsonPropertyName("reporting_config_version")] long ReportingConfigVersion,
|
||||
[property: JsonPropertyName("correlation_id")] string CorrelationId,
|
||||
[property: JsonPropertyName("last_error_code")] string? LastErrorCode = null);
|
||||
[property: JsonPropertyName("last_error_code")] string? LastErrorCode = null,
|
||||
[property: JsonPropertyName("connection_id")] string? ConnectionId = null);
|
||||
|
||||
public sealed record RemoteTaskEnvelope(
|
||||
[property: JsonPropertyName("task_id")] string TaskId,
|
||||
|
||||
@@ -22,6 +22,7 @@ public sealed class RemoteControlClient : IDisposable
|
||||
private readonly Uri? _baseAddress;
|
||||
private readonly X509Certificate2? _clientCertificate;
|
||||
private readonly X509Certificate2? _serverCaCertificate;
|
||||
private string? _connectionId;
|
||||
private bool _authenticated;
|
||||
|
||||
public RemoteControlClient(RemoteAgentOptions options, HttpClient? httpClient = null)
|
||||
@@ -57,7 +58,9 @@ public sealed class RemoteControlClient : IDisposable
|
||||
AuthState = RemoteAuthState.Authenticating;
|
||||
try
|
||||
{
|
||||
var response = await SendAsync<RemoteNodeRegistrationResponse>(HttpMethod.Post, "/v1/nodes/register", registration, false, cancellationToken);
|
||||
var connectionId = registration.ConnectionId ?? NewConnectionId();
|
||||
var response = await SendAsync<RemoteNodeRegistrationResponse>(HttpMethod.Post, "/v1/nodes/register", registration with { ConnectionId = connectionId }, false, cancellationToken);
|
||||
_connectionId = response.ConnectionId ?? connectionId;
|
||||
_authenticated = response.Authenticated;
|
||||
AuthState = response.Authenticated ? RemoteAuthState.Authenticated : RemoteAuthState.AuthenticationFailed;
|
||||
LastSuccessAt = DateTimeOffset.UtcNow;
|
||||
@@ -75,7 +78,7 @@ public sealed class RemoteControlClient : IDisposable
|
||||
|
||||
public Task<RemoteHeartbeatResponse> HeartbeatAsync(RemoteHeartbeat heartbeat, CancellationToken cancellationToken = default) =>
|
||||
SendAuthenticatedAsync<RemoteHeartbeatResponse>(HttpMethod.Post,
|
||||
$"/v1/nodes/{Escape(heartbeat.NodeId)}/heartbeat", heartbeat, cancellationToken);
|
||||
$"/v1/nodes/{Escape(heartbeat.NodeId)}/heartbeat", heartbeat with { ConnectionId = heartbeat.ConnectionId ?? _connectionId }, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<RemoteTaskEnvelope>> PollTasksAsync(
|
||||
string accountId,
|
||||
@@ -205,7 +208,7 @@ public sealed class RemoteControlClient : IDisposable
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = TryDeserializeError(responseBytes, correlationId);
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || error.Code == "StaleConnection")
|
||||
{
|
||||
_authenticated = false;
|
||||
AuthState = RemoteAuthState.AuthenticationFailed;
|
||||
@@ -292,6 +295,7 @@ public sealed class RemoteControlClient : IDisposable
|
||||
}
|
||||
|
||||
private static string Escape(string value) => Uri.EscapeDataString(value);
|
||||
private static string NewConnectionId() => Guid.NewGuid().ToString("N");
|
||||
private static string NewCorrelationId() => Guid.NewGuid().ToString("N");
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -31,7 +31,7 @@ try
|
||||
var serviceOptions = JsonSerializer.Deserialize<ServiceOptions>(
|
||||
await File.ReadAllTextAsync(configPath, shutdown.Token), ServiceHost.ConfigurationJson)
|
||||
?? throw new ArgumentException("A service configuration is required.");
|
||||
await using var app = ServiceHost.Build(serviceOptions, new WindowsAgentBackend(new AccountBindingStore(serviceOptions)),
|
||||
await using var app = ServiceHost.Build(serviceOptions, new WindowsAgentBackend(new AccountBindingStore(serviceOptions), serviceOptions),
|
||||
logPath: Path.Combine(Path.GetDirectoryName(configPath)!, "wxagent.log"));
|
||||
await app.StartAsync(shutdown.Token);
|
||||
try { await Task.Delay(Timeout.InfiniteTimeSpan, shutdown.Token); }
|
||||
|
||||
@@ -4,33 +4,38 @@ using WxAgent.Windows;
|
||||
|
||||
namespace WxAgent.Host;
|
||||
|
||||
public sealed class WindowsAgentBackend(AccountBindingStore bindings) : IAgentBackend, IAgentEventSource
|
||||
public sealed class WindowsAgentBackend(AccountBindingStore bindings, ServiceOptions options) : IAgentBackend, IAgentEventSource
|
||||
{
|
||||
private const bool ListenerEventsEnabled = true;
|
||||
private readonly bool validationOperationsEnabled = options.EnableValidationOperations;
|
||||
private readonly SemaphoreSlim bindingGate = new(1, 1);
|
||||
|
||||
public IReadOnlyList<AgentCapability> Capabilities { get; } =
|
||||
public IReadOnlyList<AgentCapability> Capabilities =>
|
||||
[
|
||||
new("agent-status", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
||||
new("agent-diagnose", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
||||
new("accounts-list", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
||||
new("agent-status", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("agent-diagnose", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("accounts-list", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("account-binding", true, false, true, true, true, "manage", false, 30, "Auto-binds a unique database/UI identity match; manual binding remains available as fallback.", ["docs/WebUI-MCP-开发计划.md"]),
|
||||
new("sessions-list", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
||||
new("sessions-search", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
||||
new("session-current", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
||||
new("sessions-scroll", true, false, false, true, false, "manage", false, 30, "Requires manage permission and navigation acceptance.", ["Navigation acceptance pending."], "4.1.13.63"),
|
||||
new("session-open", true, false, false, true, true, "manage", false, 30, "Requires manage permission and navigation acceptance.", ["Navigation acceptance pending."], "4.1.13.63"),
|
||||
new("messages-read", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
||||
new("contacts-list", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
||||
new("db-messages", true, false, false, false, false, "read", false, 30, "Database key/account query acceptance is pending.", ["Requires explicit verified account key scope."], "4.1.13.63"),
|
||||
new("db-merged", true, false, false, false, false, "read", false, 30, "Database key/account query acceptance is pending.", ["Requires explicit verified account key scope."], "4.1.13.63"),
|
||||
new("group-members", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
||||
new("sessions-list", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("sessions-search", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("session-current", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("sessions-scroll", true, false, validationOperationsEnabled, true, false, "manage", false, 30,
|
||||
validationOperationsEnabled ? null : "Validation operations are disabled for this service session.", ["Controlled validation mode."], "4.1.13.65"),
|
||||
new("session-open", true, false, validationOperationsEnabled, true, true, "manage", false, 30,
|
||||
validationOperationsEnabled ? null : "Validation operations are disabled for this service session.", ["Controlled validation mode."], "4.1.13.65"),
|
||||
new("messages-read", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("contacts-list", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("db-messages", true, false, false, false, false, "read", false, 30, "Database key/account query acceptance is pending.", ["Requires explicit verified account key scope."], "4.1.13.65"),
|
||||
new("db-merged", true, false, false, false, false, "read", false, 30, "Database key/account query acceptance is pending.", ["Requires explicit verified account key scope."], "4.1.13.65"),
|
||||
new("group-members", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("listener-events", true, ListenerEventsEnabled, ListenerEventsEnabled, true, false, "read", false, 30,
|
||||
ListenerEventsEnabled ? null : "Listener events are reserved for controlled validation.", ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
||||
new("send-text", true, false, false, true, true, "write", false, 30,
|
||||
"Active UI/database account binding has not been verified for this service session.", []),
|
||||
ListenerEventsEnabled ? null : "Listener events are reserved for controlled validation.", ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("send-text", true, true, validationOperationsEnabled, true, true, "write", true, 30,
|
||||
validationOperationsEnabled ? null : "Validation operations are disabled for this service session.", ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("broadcast-text", true, true, validationOperationsEnabled, true, true, "write", true, 300,
|
||||
validationOperationsEnabled ? null : "Validation operations are disabled for this service session.", ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("group-at-all", true, false, false, true, true, "write", true, 30,
|
||||
"Service account binding and group-owner validation required.", []),
|
||||
"No group-at-all operation is registered; validate group targets through confirmed send-text tasks.", []),
|
||||
new("next-unread", false, false, false, true, false, "read", false, 30,
|
||||
"Deferred by docs/PENDING.md.", [])
|
||||
];
|
||||
|
||||
@@ -133,19 +133,134 @@ public sealed class AgentService(IAgentBackend backend, ServiceSecurity security
|
||||
public Task<Page<MessageInfo>> MessagesAsync(string? session, int limit, int offset, bool includeContent, CancellationToken ct) =>
|
||||
MessagesAsync(null, session, limit, offset, includeContent, ct);
|
||||
|
||||
public OperationRecord SubmitOperation(OperationSubmitRequest request, CancellationToken ct)
|
||||
public async Task<OperationRecord> SubmitOperationAsync(OperationSubmitRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!string.Equals(request.Kind, "send-text", StringComparison.Ordinal))
|
||||
throw new ServiceException("UnsupportedOperation", 400, "Only send-text is available in this phase.");
|
||||
var kind = RequireText(request.Kind, "kind", 80);
|
||||
var accountId = RequireText(request.AccountId, "accountId", 200);
|
||||
var targetId = RequireText(request.TargetId, "targetId", 512);
|
||||
var text = PrepareText(request.Text);
|
||||
var idempotencyKey = RequireText(request.IdempotencyKey, "idempotencyKey", 128);
|
||||
if (!request.Confirmed) throw new ServiceException("ConfirmationRequired", 409, "Explicit send confirmation is required.");
|
||||
var capability = RequireCapability("send-text");
|
||||
var canonical = System.Text.Json.JsonSerializer.Serialize(new { request.Kind, accountId, targetId, text });
|
||||
return operations.Submit(Identity, accountId, capability, idempotencyKey, canonical,
|
||||
cancellation => backend.SendTextAsync(accountId, targetId, text, cancellation));
|
||||
|
||||
if (string.Equals(kind, "send-text", StringComparison.Ordinal))
|
||||
{
|
||||
var targetId = RequireText(request.TargetId, "targetId", 512);
|
||||
var capability = RequireCapability("send-text");
|
||||
var canonical = System.Text.Json.JsonSerializer.Serialize(new { kind, accountId, targetId, text });
|
||||
return operations.Submit(Identity, accountId, capability, idempotencyKey, canonical,
|
||||
cancellation => backend.SendTextAsync(accountId, targetId, text, cancellation));
|
||||
}
|
||||
|
||||
if (!string.Equals(kind, "broadcast-text", StringComparison.Ordinal))
|
||||
throw new ServiceException("UnsupportedOperation", 400, "Only send-text and broadcast-text are available in this phase.");
|
||||
if (!string.IsNullOrWhiteSpace(request.TargetId))
|
||||
throw new ServiceException("InvalidRequest", 400, "broadcast-text uses the targets list, not targetId.");
|
||||
|
||||
var capabilityForBroadcast = RequireCapability("broadcast-text");
|
||||
var targets = await FreezeBroadcastTargetsAsync(accountId, request.Targets, ct).ConfigureAwait(false);
|
||||
var initialDetails = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
targetIds = targets,
|
||||
stopOnError = request.StopOnError
|
||||
}, ServiceHost.Json);
|
||||
var canonicalBroadcast = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
kind,
|
||||
accountId,
|
||||
targetIds = targets,
|
||||
text,
|
||||
stopOnError = request.StopOnError
|
||||
});
|
||||
return operations.SubmitResult(Identity, accountId, capabilityForBroadcast, idempotencyKey, canonicalBroadcast,
|
||||
initialDetails,
|
||||
cancellation => ExecuteBroadcastAsync(accountId, targets, text, request.StopOnError, cancellation));
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> FreezeBroadcastTargetsAsync(string accountId, IReadOnlyList<string>? requested, CancellationToken ct)
|
||||
{
|
||||
if (requested is null || requested.Count is < 1 or > 20)
|
||||
throw new ServiceException("InvalidRequest", 400, "broadcast-text requires 1..20 targets.");
|
||||
|
||||
var targets = requested
|
||||
.Select(target => RequireText(target, "target", 512))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (targets.Length == 0) throw new ServiceException("InvalidRequest", 400, "broadcast-text requires at least one unique target.");
|
||||
|
||||
// Freeze the current UI target list before enqueueing. The account binding and identity are
|
||||
// still revalidated by every side-effecting send, so this preflight cannot bypass account safety.
|
||||
var visible = await backend.SessionsAsync(ct).ConfigureAwait(false);
|
||||
var visibleIds = visible.Select(session => session.AutomationId).ToHashSet(StringComparer.Ordinal);
|
||||
var missing = targets.Where(target => !visibleIds.Contains(target)).ToArray();
|
||||
if (missing.Length > 0)
|
||||
throw new ServiceException("TargetNotFound", 409, "Every broadcast target must be a visible, uniquely bound session.");
|
||||
return targets;
|
||||
}
|
||||
|
||||
private async Task<OperationExecutionResult> ExecuteBroadcastAsync(string accountId, IReadOnlyList<string> targets,
|
||||
string text, bool stopOnError, CancellationToken ct)
|
||||
{
|
||||
var items = new List<BroadcastItemResult>(targets.Count);
|
||||
var stopped = false;
|
||||
string? stopReason = null;
|
||||
string? errorCode = null;
|
||||
var unconfirmed = false;
|
||||
|
||||
foreach (var target in targets)
|
||||
{
|
||||
try
|
||||
{
|
||||
await backend.SendTextAsync(accountId, target, text, ct).ConfigureAwait(false);
|
||||
items.Add(new BroadcastItemResult(target, "Succeeded", null));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
items.Add(new BroadcastItemResult(target, "Unconfirmed", "Cancelled"));
|
||||
stopped = true;
|
||||
stopReason = "Cancelled";
|
||||
errorCode = "Cancelled";
|
||||
unconfirmed = true;
|
||||
break;
|
||||
}
|
||||
catch (ServiceException exception)
|
||||
{
|
||||
var itemState = exception.Code == "ResultUnconfirmed" ? "Unconfirmed" : "Failed";
|
||||
items.Add(new BroadcastItemResult(target, itemState, exception.Code));
|
||||
errorCode ??= exception.Code == "ResultUnconfirmed" ? "ResultUnconfirmed" : "BroadcastItemFailed";
|
||||
if (itemState == "Unconfirmed" || stopOnError)
|
||||
{
|
||||
stopped = true;
|
||||
stopReason = exception.Code;
|
||||
unconfirmed = itemState == "Unconfirmed";
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (WxAgentException exception)
|
||||
{
|
||||
var code = exception.Code.ToString();
|
||||
var itemState = exception.Code == WxAgentErrorCode.ResultUnconfirmed ? "Unconfirmed" : "Failed";
|
||||
items.Add(new BroadcastItemResult(target, itemState, code));
|
||||
errorCode ??= itemState == "Unconfirmed" ? "ResultUnconfirmed" : "BroadcastItemFailed";
|
||||
if (itemState == "Unconfirmed" || stopOnError)
|
||||
{
|
||||
stopped = true;
|
||||
stopReason = code;
|
||||
unconfirmed = itemState == "Unconfirmed";
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
items.Add(new BroadcastItemResult(target, "Unconfirmed", "ExecutionFailed"));
|
||||
stopped = true;
|
||||
stopReason = "ExecutionFailed";
|
||||
errorCode = "ExecutionFailed";
|
||||
unconfirmed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var details = System.Text.Json.JsonSerializer.Serialize(new BroadcastResult(targets, items, stopped, stopReason), ServiceHost.Json);
|
||||
return new OperationExecutionResult(details, errorCode, unconfirmed);
|
||||
}
|
||||
|
||||
private static string RequireText(string? value, string name, int maxLength)
|
||||
|
||||
@@ -57,9 +57,13 @@ public sealed class AgentTools(AgentService service)
|
||||
[McpServerTool(Name = "operations_list", ReadOnly = true), Description("List bounded operations owned by the current identity; accountId is a caller-side view filter, not an authorization boundary.")]
|
||||
public Task<CallToolResult> Operations(string? accountId = null, int limit = 50, int offset = 0) => Result(() => Task.FromResult<object>(service.Operations(accountId, limit, offset)));
|
||||
|
||||
[McpServerTool(Name = "operation_submit"), Description("Submit an explicitly confirmed operation. send-text remains disabled until Windows validation is complete.")]
|
||||
[McpServerTool(Name = "operation_submit"), Description("Submit one explicitly confirmed send-text operation. The targetId must be a frozen visible session automation ID.")]
|
||||
public Task<CallToolResult> SubmitOperation(string kind, string accountId, string targetId, string? text, string idempotencyKey, bool confirmed = false, CancellationToken cancellationToken = default) =>
|
||||
Result(() => Task.FromResult<object>(service.SubmitOperation(new OperationSubmitRequest(kind, accountId, targetId, text, idempotencyKey, confirmed), cancellationToken)));
|
||||
Result(async () => await service.SubmitOperationAsync(new OperationSubmitRequest(kind, accountId, targetId, text, idempotencyKey, confirmed), cancellationToken));
|
||||
|
||||
[McpServerTool(Name = "broadcast_text"), Description("Submit an explicitly confirmed broadcast-text operation for a frozen list of 1..20 visible session automation IDs. Sends sequentially, stops on the first known failure by default, and returns per-target results; replaying the same idempotency key never replays terminal writes.")]
|
||||
public Task<CallToolResult> BroadcastText(string accountId, string[] targets, string text, string idempotencyKey, bool confirmed = false, bool stopOnError = true, CancellationToken cancellationToken = default) =>
|
||||
Result(async () => await service.SubmitOperationAsync(new OperationSubmitRequest("broadcast-text", accountId, null, text, idempotencyKey, confirmed, targets, stopOnError), cancellationToken));
|
||||
|
||||
[McpServerTool(Name = "operation_get", ReadOnly = true), Description("Read one operation owned by the current identity; terminal writes are never replayed.")]
|
||||
public Task<CallToolResult> Operation(string operationId) => Result(() => Task.FromResult<object>(service.Operation(operationId)));
|
||||
|
||||
@@ -6,11 +6,13 @@ using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace WxAgent.Service;
|
||||
|
||||
public sealed record OperationExecutionResult(string? Details = null, string? ErrorCode = null, bool Unconfirmed = false);
|
||||
|
||||
public sealed class OperationQueue(OperationStore store, ServiceSecurity security) : BackgroundService
|
||||
{
|
||||
private const int QueueCapacity = 100;
|
||||
private sealed record Work(OperationRecord Record, ServiceIdentity Identity, string Permission,
|
||||
Func<CancellationToken, Task> Action, CancellationTokenSource Cancel);
|
||||
Func<CancellationToken, Task<OperationExecutionResult>> Action, CancellationTokenSource Cancel);
|
||||
private readonly Channel<Work> queue = Channel.CreateBounded<Work>(new BoundedChannelOptions(QueueCapacity)
|
||||
{ SingleReader = true, FullMode = BoundedChannelFullMode.Wait });
|
||||
private readonly ConcurrentDictionary<string, CancellationTokenSource> cancellations = new();
|
||||
@@ -18,7 +20,17 @@ public sealed class OperationQueue(OperationStore store, ServiceSecurity securit
|
||||
private bool stopping;
|
||||
|
||||
public OperationRecord Submit(ServiceIdentity identity, string accountId, AgentCapability capability,
|
||||
string? idempotencyKey, string canonicalParameters, Func<CancellationToken, Task> action)
|
||||
string? idempotencyKey, string canonicalParameters, Func<CancellationToken, Task> action) =>
|
||||
SubmitResult(identity, accountId, capability, idempotencyKey, canonicalParameters, null,
|
||||
async cancellationToken =>
|
||||
{
|
||||
await action(cancellationToken).ConfigureAwait(false);
|
||||
return new OperationExecutionResult();
|
||||
});
|
||||
|
||||
public OperationRecord SubmitResult(ServiceIdentity identity, string accountId, AgentCapability capability,
|
||||
string? idempotencyKey, string canonicalParameters, string? initialDetails,
|
||||
Func<CancellationToken, Task<OperationExecutionResult>> action)
|
||||
{
|
||||
security.RequireCurrent(identity, capability.Permission, accountId);
|
||||
if (!capability.Enabled) throw new ServiceException("CapabilityDisabled", 409, capability.DisabledReason ?? "Capability unavailable.");
|
||||
@@ -30,7 +42,7 @@ public sealed class OperationQueue(OperationStore store, ServiceSecurity securit
|
||||
{
|
||||
if (stopping) throw new ServiceException("Unavailable", 503, "Agent is stopping.");
|
||||
var (record, created) = store.Enqueue(identity.PrincipalId, accountId, capability.Operation,
|
||||
idempotencyKey, digest, capability.HasSideEffects, TimeSpan.FromSeconds(capability.TimeoutSeconds), QueueCapacity);
|
||||
idempotencyKey, digest, capability.HasSideEffects, TimeSpan.FromSeconds(capability.TimeoutSeconds), QueueCapacity, initialDetails);
|
||||
if (!created) return record;
|
||||
var cancel = new CancellationTokenSource();
|
||||
cancellations[record.Id] = cancel;
|
||||
@@ -92,10 +104,16 @@ public sealed class OperationQueue(OperationStore store, ServiceSecurity securit
|
||||
started = true;
|
||||
}
|
||||
// Never release this slot with WaitAsync: the actual action must have stopped first.
|
||||
await work.Action(budget.Token);
|
||||
var result = await work.Action(budget.Token);
|
||||
if (result.ErrorCode is not null)
|
||||
{
|
||||
store.Transition(work.Record.Id, result.Unconfirmed ? "Unconfirmed" : "Failed",
|
||||
"execution", result.ErrorCode, result.Details);
|
||||
continue;
|
||||
}
|
||||
budget.Token.ThrowIfCancellationRequested();
|
||||
security.RequireCurrent(work.Identity, work.Permission, work.Record.AccountId);
|
||||
store.Transition(work.Record.Id, "Succeeded", "complete");
|
||||
store.Transition(work.Record.Id, "Succeeded", "complete", details: result.Details);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace WxAgent.Service;
|
||||
|
||||
public sealed record OperationRecord(string Id, string PrincipalId, string AccountId, string Capability,
|
||||
string State, string Stage, string CorrelationId, DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt,
|
||||
string? ErrorCode, bool HasSideEffects);
|
||||
string? ErrorCode, bool HasSideEffects, string? Details = null);
|
||||
|
||||
public sealed record OperationSummary(string Id, string AccountId, string Capability,
|
||||
string State, string Stage, string CorrelationId, DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt,
|
||||
@@ -37,10 +37,12 @@ public sealed class OperationStore : IDisposable
|
||||
stage='restart', error='AgentRestarted' WHERE state='Running';
|
||||
UPDATE operations SET state='Cancelled', stage='restart', error='AgentRestarted' WHERE state='Queued';
|
||||
""");
|
||||
try { Execute("ALTER TABLE operations ADD COLUMN details TEXT"); }
|
||||
catch (SqliteException exception) when (exception.SqliteErrorCode == 1) { }
|
||||
}
|
||||
|
||||
public (OperationRecord Record, bool Created) Enqueue(string principal, string account, string capability,
|
||||
string? idempotency, string digest, bool sideEffects, TimeSpan budget, int capacity)
|
||||
string? idempotency, string digest, bool sideEffects, TimeSpan budget, int capacity, string? details = null)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
@@ -67,10 +69,10 @@ public sealed class OperationStore : IDisposable
|
||||
throw new ServiceException("QueueFull", 429, "Agent queue is full.");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var operation = new OperationRecord(Guid.NewGuid().ToString("N"), principal, account, capability,
|
||||
"Queued", "queued", Guid.NewGuid().ToString("N"), now, now.Add(budget), null, sideEffects);
|
||||
"Queued", "queued", Guid.NewGuid().ToString("N"), now, now.Add(budget), null, sideEffects, details);
|
||||
using var insert = database.CreateCommand();
|
||||
insert.Transaction = transaction;
|
||||
insert.CommandText = "INSERT INTO operations VALUES ($id,$p,$a,$c,'Queued','queued',$correlation,$created,$expires,NULL,$effects,$key,$digest)";
|
||||
insert.CommandText = "INSERT INTO operations (id,principal,account,capability,state,stage,correlation,created,expires,error,side_effects,idempotency,digest,details) VALUES ($id,$p,$a,$c,'Queued','queued',$correlation,$created,$expires,NULL,$effects,$key,$digest,$details)";
|
||||
insert.Parameters.AddWithValue("$id", operation.Id);
|
||||
insert.Parameters.AddWithValue("$p", principal);
|
||||
insert.Parameters.AddWithValue("$a", account);
|
||||
@@ -81,6 +83,7 @@ public sealed class OperationStore : IDisposable
|
||||
insert.Parameters.AddWithValue("$effects", sideEffects ? 1 : 0);
|
||||
insert.Parameters.AddWithValue("$key", (object?)idempotency ?? DBNull.Value);
|
||||
insert.Parameters.AddWithValue("$digest", digest);
|
||||
insert.Parameters.AddWithValue("$details", (object?)details ?? DBNull.Value);
|
||||
insert.ExecuteNonQuery();
|
||||
transaction.Commit();
|
||||
return (operation, true);
|
||||
@@ -122,16 +125,17 @@ public sealed class OperationStore : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public void Transition(string id, string state, string stage, string? error = null)
|
||||
public void Transition(string id, string state, string stage, string? error = null, string? details = null)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
using var command = database.CreateCommand();
|
||||
command.CommandText = "UPDATE operations SET state=$state,stage=$stage,error=$error WHERE id=$id AND state IN ('Queued','Running')";
|
||||
command.CommandText = "UPDATE operations SET state=$state,stage=$stage,error=$error,details=COALESCE($details,details) WHERE id=$id AND state IN ('Queued','Running')";
|
||||
command.Parameters.AddWithValue("$id", id);
|
||||
command.Parameters.AddWithValue("$state", state);
|
||||
command.Parameters.AddWithValue("$stage", stage);
|
||||
command.Parameters.AddWithValue("$error", (object?)error ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("$details", (object?)details ?? DBNull.Value);
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -139,7 +143,8 @@ public sealed class OperationStore : IDisposable
|
||||
private static OperationRecord Read(SqliteDataReader reader) => new(reader.GetString(0), reader.GetString(1), reader.GetString(2),
|
||||
reader.GetString(3), reader.GetString(4), reader.GetString(5), reader.GetString(6),
|
||||
DateTimeOffset.Parse(reader.GetString(7), System.Globalization.CultureInfo.InvariantCulture),
|
||||
DateTimeOffset.Parse(reader.GetString(8), System.Globalization.CultureInfo.InvariantCulture), reader.IsDBNull(9) ? null : reader.GetString(9), reader.GetInt64(10) != 0);
|
||||
DateTimeOffset.Parse(reader.GetString(8), System.Globalization.CultureInfo.InvariantCulture), reader.IsDBNull(9) ? null : reader.GetString(9), reader.GetInt64(10) != 0,
|
||||
reader.IsDBNull(13) ? null : reader.GetString(13));
|
||||
|
||||
private void Execute(string sql)
|
||||
{
|
||||
|
||||
@@ -5,7 +5,20 @@ public sealed record AccountInfo(string AccountId, string? DisplayName, string?
|
||||
public sealed record SessionInfo(string Name, string AutomationId, bool IsCurrent);
|
||||
public sealed record MessageInfo(string Fingerprint, string Type, string? Sender, string? Summary, string? Content);
|
||||
public sealed record ListRequest(int Limit = 50, int Offset = 0, bool IncludeContent = false, string? AccountId = null, string? Session = null);
|
||||
public sealed record OperationSubmitRequest(string? Kind, string? AccountId, string? TargetId, string? Text, string? IdempotencyKey, bool Confirmed);
|
||||
public sealed record OperationSubmitRequest(
|
||||
string? Kind,
|
||||
string? AccountId,
|
||||
string? TargetId,
|
||||
string? Text,
|
||||
string? IdempotencyKey,
|
||||
bool Confirmed,
|
||||
IReadOnlyList<string>? Targets = null,
|
||||
bool StopOnError = true);
|
||||
|
||||
public sealed record BroadcastItemResult(string TargetId, string State, string? ErrorCode);
|
||||
|
||||
public sealed record BroadcastResult(IReadOnlyList<string> TargetIds, IReadOnlyList<BroadcastItemResult> Items,
|
||||
bool Stopped, string? StopReason);
|
||||
|
||||
public sealed class ReadOnlyRequest
|
||||
{
|
||||
|
||||
@@ -137,7 +137,7 @@ public static class ServiceHost
|
||||
return Results.Ok(await service.UploadAsync(form.Files[0], ct));
|
||||
});
|
||||
app.MapGet("/api/v1/files/{id}", (string id, AgentService service) => Results.File(service.Download(id), "application/octet-stream"));
|
||||
app.MapPost("/api/v1/operations", (OperationSubmitRequest request, AgentService service, CancellationToken ct) => service.SubmitOperation(request, ct));
|
||||
app.MapPost("/api/v1/operations", async (OperationSubmitRequest request, AgentService service, CancellationToken ct) => await service.SubmitOperationAsync(request, ct));
|
||||
app.MapGet("/api/v1/operations", (string? accountId, int? limit, int? offset, AgentService service) => service.Operations(accountId, limit ?? 50, offset ?? 0));
|
||||
app.MapGet("/api/v1/operations/{id}", (string id, AgentService service) => service.Operation(id));
|
||||
app.MapPost("/api/v1/operations/{id}/cancel", (string id, AgentService service) => service.CancelOperation(id));
|
||||
|
||||
@@ -22,6 +22,9 @@ public sealed class ServiceOptions
|
||||
public RemoteAgentOptions? Remote { get; init; }
|
||||
public ReportingConfig Reporting { get; init; } = new();
|
||||
public string? RemoteConfigurationFile { get; init; }
|
||||
// Explicitly opt-in for a single, user-authorized Windows validation session.
|
||||
// Production deployments remain read-only unless this local gate is enabled.
|
||||
public bool EnableValidationOperations { get; init; }
|
||||
|
||||
// Kept only so older service.json files can be loaded and rewritten by the tray.
|
||||
[JsonIgnore]
|
||||
|
||||
@@ -136,7 +136,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
};
|
||||
Directory.CreateDirectory(initial.DataDirectory);
|
||||
WriteJson(configPath, initial);
|
||||
WriteCredentials(initial.CredentialFile, initialToken);
|
||||
WriteCredentials(initial.CredentialFile, initialToken, initial.EnableValidationOperations);
|
||||
options = initial;
|
||||
return true;
|
||||
}
|
||||
@@ -154,7 +154,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
if (changed) WriteJson(configPath, normalized);
|
||||
Directory.CreateDirectory(normalized.DataDirectory);
|
||||
// Always rewrite one canonical record so old/malformed/multi-record files self-heal on tray startup.
|
||||
WriteCredentials(normalized.CredentialFile, token);
|
||||
WriteCredentials(normalized.CredentialFile, token, normalized.EnableValidationOperations);
|
||||
options = normalized;
|
||||
return changed;
|
||||
}
|
||||
@@ -177,7 +177,8 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
DataDirectory = dataDirectory,
|
||||
Remote = source.Remote,
|
||||
Reporting = source.Reporting,
|
||||
RemoteConfigurationFile = source.RemoteConfigurationFile
|
||||
RemoteConfigurationFile = source.RemoteConfigurationFile,
|
||||
EnableValidationOperations = source.EnableValidationOperations
|
||||
};
|
||||
|
||||
private ServiceOptions ReadOptions() =>
|
||||
@@ -190,7 +191,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
var loaded = ReadOptions();
|
||||
loaded.Validate();
|
||||
options = loaded;
|
||||
service = ServiceHost.Build(loaded, new WindowsAgentBackend(new AccountBindingStore(loaded)), logPath: GetLogPath());
|
||||
service = ServiceHost.Build(loaded, new WindowsAgentBackend(new AccountBindingStore(loaded), loaded), logPath: GetLogPath());
|
||||
try
|
||||
{
|
||||
await service.StartAsync();
|
||||
@@ -283,7 +284,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
{
|
||||
EnsureConfiguration();
|
||||
if (ServiceSettingsEditor.Show(options!) is not { } edited) return;
|
||||
WriteCredentials(edited.CredentialFile, edited.AccessToken!);
|
||||
WriteCredentials(edited.CredentialFile, edited.AccessToken!, edited.EnableValidationOperations);
|
||||
WriteJson(configPath, edited);
|
||||
_ = ReloadAsync();
|
||||
}
|
||||
@@ -329,12 +330,15 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private static void WriteCredentials(string path, string token)
|
||||
private static void WriteCredentials(string path, string token, bool includeValidationWrite)
|
||||
{
|
||||
var temporary = path + ".tmp";
|
||||
var permissions = includeValidationWrite
|
||||
? new[] { "read", "content", "write", "manage" }
|
||||
: new[] { "read", "content", "manage" };
|
||||
WriteJson(temporary, new[]
|
||||
{
|
||||
new ServiceCredential("local-admin", ServiceOptions.HashToken(token), ["read", "content", "manage"], [])
|
||||
new ServiceCredential("local-admin", ServiceOptions.HashToken(token), permissions, [])
|
||||
});
|
||||
File.Move(temporary, path, true);
|
||||
}
|
||||
|
||||
@@ -500,7 +500,9 @@ public static partial class WechatChatClient
|
||||
var direct = FindByAutomationId(main, $"session_item_{session}");
|
||||
if (direct is not null)
|
||||
{
|
||||
direct.Click();
|
||||
// Use the same visible-center click path as the rest of the UI executor;
|
||||
// FlaUI's Click() can report success without changing WeChat's virtualized chat pane.
|
||||
ClickCenter(direct);
|
||||
await WaitForSessionPageAsync(main, session, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
@@ -509,7 +511,7 @@ public static partial class WechatChatClient
|
||||
|
||||
private static async Task WaitForSessionPageAsync(AutomationElement main, string session, CancellationToken cancellationToken)
|
||||
{
|
||||
for (var attempt = 0; attempt < 25; attempt++)
|
||||
for (var attempt = 0; attempt < 100; attempt++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (IsListeningSession(main, session, independent: false)
|
||||
|
||||
Reference in New Issue
Block a user