diff --git a/docs/WebUI-MCP-使用说明.md b/docs/WebUI-MCP-使用说明.md index 6449c38..8f507c7 100644 --- a/docs/WebUI-MCP-使用说明.md +++ b/docs/WebUI-MCP-使用说明.md @@ -6,7 +6,7 @@ 1. 在已登录、未锁定的 Windows 用户会话中运行发布包中的 `WxAgent-Setup.exe`。安装器会创建桌面/开始菜单快捷方式和可选的登录启动项,不需要 PowerShell 或管理员权限。 2. 启动托盘程序后,首次运行会生成唯一的远程访问 Token,并自动打开“服务设置...”窗口。Token 在该窗口中明文展示,可复制或重新生成;不需要单独的凭据窗口,也不支持多 Token。 -3. Windows Agent 不支持无托盘运行、`serve --config` 或其他命令行配置。请双击 `WxAgent.Tray.exe`,在托盘菜单的“服务设置...”窗口中配置监听地址、凭据、验证模式和自动锁屏选项。 +3. Windows Agent 不支持无托盘运行、`serve --config` 或其他命令行配置。请双击 `WxAgent.Tray.exe`,在托盘菜单的“服务设置...”窗口中配置监听地址、凭据、验证模式、后台消息监听和自动锁屏选项。 `credentials.json` 只保存 SHA-256 大写十六进制摘要,例如: @@ -16,7 +16,7 @@ `AccountIds` 仅为旧凭据格式保留,不再作为账号级数据授权边界;`[]` 可以保留。凭据文件应使用当前用户 ACL,禁止提交仓库。调用方仍必须显式携带 account fingerprint 选择数据和目标,Agent 会验证绑定、窗口和目标一致性。托盘模式的 `service.json` 会保存 `AccessToken` 明文,以便服务设置窗口随时展示;该文件同样只应保存在本机。 -服务运行后通过托盘菜单或双击托盘图标打开“服务设置...”配置监听地址、端口、远程访问、唯一 Token、验证模式和自动锁屏选项,不需要手工编辑 `service.json`/`credentials.json`。不提供命令行配置入口。队列容量、监听会话和监听事件开关属于内部实现/验收策略,不作为用户配置项。自动锁屏设置由服务设置窗口保存;该设置不阻止用户手动锁定。默认只监听 `127.0.0.1:5088`,本机访问控制台不需要 Token,会自动进入。外部监听必须启用远程访问并自行配置防火墙,服务不会自动开放端口;外部监听使用具体 IP,不能使用 `0.0.0.0`/`::`。服务校验可信 Host/Origin;远程浏览器写请求需要 Cookie、CSRF 和可信 Origin,本机浏览器写请求还需要 `X-WxAgent-Local: 1`。HTTP 不加密 Token、Cookie、消息或附件,不直接暴露公网。 +服务运行后通过托盘菜单或双击托盘图标打开“服务设置...”配置监听地址、端口、远程访问、唯一 Token、验证模式、后台消息监听和自动锁屏选项,不需要手工编辑 `service.json`/`credentials.json`。不提供命令行配置入口。队列容量、监听会话和监听事件开关属于内部实现/验收策略,不作为用户配置项。自动锁屏设置由服务设置窗口保存;该设置不阻止用户手动锁定。默认只监听 `127.0.0.1:5088`,本机访问控制台不需要 Token,会自动进入。外部监听必须启用远程访问并自行配置防火墙,服务不会自动开放端口;外部监听使用具体 IP,不能使用 `0.0.0.0`/`::`。服务校验可信 Host/Origin;远程浏览器写请求需要 Cookie、CSRF 和可信 Origin,本机浏览器写请求还需要 `X-WxAgent-Local: 1`。HTTP 不加密 Token、Cookie、消息或附件,不直接暴露公网。 远程浏览器访问 `/` 后输入 Token 登录。HTTP/MCP 客户端使用: @@ -24,7 +24,7 @@ Authorization: Bearer ``` -MCP Streamable HTTP 地址为 `/mcp`。不要把 Token 放在 URL、MCP session ID、浏览器持久存储或日志中。事件流默认关闭,是否启用属于内部验收开关,不作为用户配置项;真机消息验证最多发送 3 条。 +MCP Streamable HTTP 地址为 `/mcp`。不要把 Token 放在 URL、MCP session ID、浏览器持久存储或日志中。后台消息监听默认关闭;启用后会在微信界面上进行消息观察,可在“服务设置...”中显式打开。真机消息验证最多发送 3 条。 ## 凭据更换与撤销 diff --git a/docs/WxAgent-远程控制面架构与部署说明-v1.0.md b/docs/WxAgent-远程控制面架构与部署说明-v1.0.md index 3013a63..4ddb489 100644 --- a/docs/WxAgent-远程控制面架构与部署说明-v1.0.md +++ b/docs/WxAgent-远程控制面架构与部署说明-v1.0.md @@ -578,7 +578,7 @@ scp -r node-agent/WxAgent.Tray/bin/Release/net8.0-windows10.0.19041.0/win-x64/pu } ``` -当前版本不提供任何命令行配置入口,也不接受通过 CLI 修改 `remote.json`、Token、节点 ID 或上报白名单。Windows Agent 只通过双击 `WxAgent.Tray.exe` 启动;本机监听、访问凭据、验证写操作和自动锁屏在“服务设置...”配置窗口中维护。远程上报配置尚未提供用户配置容器时保持已有部署配置或禁用,不得用命令行绕过该边界。 +当前版本不提供任何命令行配置入口,也不接受通过 CLI 修改 `remote.json`、Token、节点 ID 或上报白名单。Windows Agent 只通过双击 `WxAgent.Tray.exe` 启动;本机监听、访问凭据、验证写操作、后台消息监听和自动锁屏在“服务设置...”配置窗口中维护。后台消息监听默认关闭,启用后会操作微信界面;远程上报配置尚未提供用户配置容器时保持已有部署配置或禁用,不得用命令行绕过该边界。 ### 9.3 诊断与只读确认 diff --git a/docs/validation/WebUI-MCP-single-client-2026-09-19.md b/docs/validation/WebUI-MCP-single-client-2026-09-19.md index 572b701..f7b6feb 100644 --- a/docs/validation/WebUI-MCP-single-client-2026-09-19.md +++ b/docs/validation/WebUI-MCP-single-client-2026-09-19.md @@ -57,7 +57,7 @@ - `cd control-plane && go test ./...`:通过。 - `cd control-plane/web && npm run build`:通过。 - `dotnet test tests/node-agent/WxAgent.Core.Tests -c Release --no-restore`:168 tests 通过。 -- `dotnet test tests/node-agent/WxAgent.Service.Tests -c Release --no-restore`:22 tests 通过,包含 `broadcast-text` 冻结、顺序、停止和幂等覆盖。 +- `dotnet test tests/node-agent/WxAgent.Service.Tests -c Release --no-restore`:23 tests 通过,包含 `broadcast-text` 冻结、顺序、停止、幂等和后台监听默认关闭覆盖。 - `dotnet build WxAgent.sln -c Release -p:EnableWindowsTargeting=true --no-restore`:0 warnings、0 errors。 - `git diff --check`、Go `gofmt`:通过。 diff --git a/node-agent/WxAgent.Host/WindowsAgentBackend.cs b/node-agent/WxAgent.Host/WindowsAgentBackend.cs index 83aa7a3..ac177d6 100644 --- a/node-agent/WxAgent.Host/WindowsAgentBackend.cs +++ b/node-agent/WxAgent.Host/WindowsAgentBackend.cs @@ -6,8 +6,8 @@ namespace WxAgent.Host; public sealed class WindowsAgentBackend(AccountBindingStore bindings, ServiceOptions options) : IAgentBackend, IAgentEventSource { - private const bool ListenerEventsEnabled = true; private readonly bool validationOperationsEnabled = options.EnableValidationOperations; + private readonly bool listenerEventsEnabled = options.EnableListenerEvents; private readonly SemaphoreSlim bindingGate = new(1, 1); public IReadOnlyList Capabilities => @@ -28,8 +28,8 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings, ServiceOpt 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-single-client-2026-09-19.md"], "4.1.13.65"), + new("listener-events", true, listenerEventsEnabled, listenerEventsEnabled, true, false, "read", false, 30, + listenerEventsEnabled ? null : "Background UI listener is disabled; enable it explicitly in the Tray settings window.", ["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, @@ -42,6 +42,12 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings, ServiceOpt public async IAsyncEnumerable ListenAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { + if (!listenerEventsEnabled) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + yield break; + } + var active = bindings.ReadAll(); if (active.Count != 1) { diff --git a/node-agent/WxAgent.Service/ServiceOptions.cs b/node-agent/WxAgent.Service/ServiceOptions.cs index 903e71c..a4b01fc 100644 --- a/node-agent/WxAgent.Service/ServiceOptions.cs +++ b/node-agent/WxAgent.Service/ServiceOptions.cs @@ -25,6 +25,7 @@ public sealed class ServiceOptions // 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; } + public bool EnableListenerEvents { get; init; } public bool PreventAutoLock { get; init; } // Kept only so older service.json files can be loaded and rewritten by the tray. @@ -34,9 +35,6 @@ public sealed class ServiceOptions [JsonIgnore] [Obsolete("Internal compatibility field; the value is ignored.")] public string ListenerSession { get; init; } = "文件传输助手"; - [JsonIgnore] - [Obsolete("Internal compatibility field; the value is ignored.")] - public bool EnableListenerEvents { get; init; } public void Validate() { diff --git a/node-agent/WxAgent.Tray/Program.cs b/node-agent/WxAgent.Tray/Program.cs index d985987..b71765d 100644 --- a/node-agent/WxAgent.Tray/Program.cs +++ b/node-agent/WxAgent.Tray/Program.cs @@ -359,7 +359,7 @@ internal static class ServiceSettingsEditor { Text = "WxAgent 服务设置", Width = 620, - Height = 405, + Height = 435, StartPosition = FormStartPosition.CenterScreen, MinimizeBox = false, MaximizeBox = false, @@ -385,18 +385,24 @@ internal static class ServiceSettingsEditor Text = "防止自动息屏、睡眠和锁屏", Checked = current.PreventAutoLock }; + var listenerEvents = new CheckBox + { + Left = 145, Top = 216, Width = 430, + Text = "启用后台消息监听(会操作微信界面,默认关闭)", + Checked = current.EnableListenerEvents + }; var note = new Label { - Left = 18, Top = 220, Width = 570, Height = 48, + Left = 18, Top = 250, Width = 570, Height = 48, Text = "仅保留一个访问凭据,可直接输入自定义内容(不能为空或包含空白字符)。\n验证写操作只应在明确授权的测试机启用;本机回环访问不需要凭据。" }; var data = new Label { - Left = 18, Top = 278, Width = 570, Height = 24, + Left = 18, Top = 308, Width = 570, Height = 24, Text = $"数据目录:{current.DataDirectory}", AutoEllipsis = true }; - var save = new Button { Left = 370, Top = 330, Width = 105, Text = "保存" }; - var cancel = new Button { Left = 485, Top = 330, Width = 105, Text = "取消" }; + var save = new Button { Left = 370, Top = 360, Width = 105, Text = "保存" }; + var cancel = new Button { Left = 485, Top = 360, Width = 105, Text = "取消" }; copy.Click += (_, _) => { Clipboard.SetText(tokenBox.Text); copy.Text = "已复制"; }; regenerate.Click += (_, _) => { @@ -425,6 +431,7 @@ internal static class ServiceSettingsEditor Reporting = current.Reporting, RemoteConfigurationFile = current.RemoteConfigurationFile, EnableValidationOperations = validation.Checked, + EnableListenerEvents = listenerEvents.Checked, PreventAutoLock = preventAutoLock.Checked }; try { edited.Validate(); } @@ -437,7 +444,7 @@ internal static class ServiceSettingsEditor form.Close(); }; cancel.Click += (_, _) => form.Close(); - form.Controls.AddRange([listenLabel, hostBox, portBox, external, tokenLabel, tokenBox, copy, regenerate, validation, preventAutoLock, note, data, save, cancel]); + form.Controls.AddRange([listenLabel, hostBox, portBox, external, tokenLabel, tokenBox, copy, regenerate, validation, preventAutoLock, listenerEvents, note, data, save, cancel]); form.AcceptButton = save; form.CancelButton = cancel; form.ShowDialog(); diff --git a/tests/node-agent/WxAgent.Service.Tests/EventPumpTests.cs b/tests/node-agent/WxAgent.Service.Tests/EventPumpTests.cs index fd6eb46..f36159c 100644 --- a/tests/node-agent/WxAgent.Service.Tests/EventPumpTests.cs +++ b/tests/node-agent/WxAgent.Service.Tests/EventPumpTests.cs @@ -15,6 +15,34 @@ public sealed class EventPumpTests { yield return new AgentEvent("evt", "ui-current", "session", "message", "summary", DateTimeOffset.UtcNow); await Task.Delay(Timeout.Infinite, ct); } } + private sealed class DisabledSourceBackend : IAgentBackend, IAgentEventSource + { + public bool ListenCalled { get; private set; } + public IReadOnlyList Capabilities => [new("listener-events", true, true, false, true, false, "read", false, 30, "disabled", [])]; + public Task StatusAsync(CancellationToken ct) => Task.FromResult(new { ok = true }); + public async IAsyncEnumerable ListenAsync([EnumeratorCancellation] CancellationToken ct) + { + ListenCalled = true; + await Task.CompletedTask; + yield break; + } + } + + [Fact] + public async Task DisabledListenerDoesNotStartUiSource() + { + var source = new DisabledSourceBackend(); + using var pump = new EventPump(source, new EventHub(), new ServiceOptions + { + DataDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), + CredentialFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "credentials.json") + }); + await pump.StartAsync(default); + await Task.Delay(100); + await pump.StopAsync(default); + Assert.False(source.ListenCalled); + } + [Fact] public async Task PumpForwardsToTheConfiguredPrincipal() {