fix: authorize verified offline data sync
Build web service image / build (push) Successful in 1m6s

This commit is contained in:
2026-09-30 13:30:48 +08:00
parent edba7d0d38
commit e8abb15f68
12 changed files with 218 additions and 22 deletions
+2 -1
View File
@@ -172,7 +172,8 @@ func (s *Server) nodeDataRoute(w http.ResponseWriter, r *http.Request, nodeID st
if !ok {
return requestError{status: http.StatusConflict, code: "NodeNotRegistered", message: "Register the node before reading sync status."}
}
authorized = nodeHasAccount(node, accountID)
// Offline database sync needs a verified identity, not an active UI session.
authorized = nodeHasVerifiedAccount(node, accountID)
return nil
}); err != nil {
return err
+38
View File
@@ -120,6 +120,44 @@ func TestDataBatchRoundTripAndWebQueriesUseAccountShard(t *testing.T) {
}
}
func TestDataSyncStatusAllowsVerifiedInactiveAccount(t *testing.T) {
server, err := NewServer(ServerConfig{
DataFile: filepath.Join(t.TempDir(), "control-plane.json"),
NodeTokens: map[string]string{"node-a": "secret-a"},
WebUsers: map[string]string{"admin": "web-secret"},
HeartbeatTimeout: time.Minute,
LeaseTTL: time.Minute,
})
if err != nil {
t.Fatal(err)
}
defer server.Close()
httpServer := httptest.NewServer(server.Handler())
defer httpServer.Close()
client := httpServer.Client()
account := AccountSummary{
AccountID: "account-a", Active: false, Verified: true,
AllowedChats: []AllowedChatSummary{{ChatID: "chat-a", ChatType: ChatPrivate}},
}
register := NodeRegistration{
NodeID: "node-a", ConnectionID: "connection-a", AgentVersion: "test", ProtocolVersion: ProtocolVersion,
Capabilities: []string{"heartbeat", "sync-data"}, Accounts: []AccountSummary{account},
}
if response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/register", "Bearer secret-a", register); response.Code != http.StatusOK {
t.Fatalf("register: %d %s", response.Code, response.Body.String())
}
response := doJSON(t, client, http.MethodGet,
httpServer.URL+"/v1/nodes/node-a/data/accounts/account-a/sync-status?stream_key=messages", "Bearer secret-a", nil)
if response.Code != http.StatusOK {
t.Fatalf("inactive verified account sync status = %d: %s", response.Code, response.Body.String())
}
if nodeHasAccount(Node{Accounts: []AccountSummary{account}}, "account-a") {
t.Fatal("inactive account unexpectedly became eligible for UI tasks")
}
}
func TestDataBatchCannotCrossReportingScope(t *testing.T) {
server, err := NewServer(ServerConfig{
DataFile: filepath.Join(t.TempDir(), "control-plane.json"), NodeTokens: map[string]string{"node-a": "secret-a"}, WebUsers: map[string]string{"admin": "web-secret"},
+9
View File
@@ -1620,6 +1620,15 @@ func nodeHasAccount(node Node, accountID string) bool {
return false
}
func nodeHasVerifiedAccount(node Node, accountID string) bool {
for _, account := range node.Accounts {
if account.AccountID == accountID && account.Verified {
return true
}
}
return false
}
func nodeAvailable(node Node, now time.Time, heartbeatTimeout time.Duration) bool {
return node.Status != NodeOffline && node.LastHeartbeatAt != nil && now.Sub(*node.LastHeartbeatAt) <= heartbeatTimeout
}
+7 -2
View File
@@ -2,12 +2,17 @@
本文件记录全局 review 后的用户决定。用户随后确认同时完善数据库读取链路和四项代码问题;以下明确暂缓的扩展与真机验收仍不执行。暂缓不等于通过,安全修复也不等于端到端功能已验收。
## 当前正常功能复核(2026-09-29)
## 当前正常功能复核(更新至 2026-09-30)
- 长文本分段、2–10 个附件批量入口、提及重名 fail-closed 保护和合并聊天混合元数据解析已补代码及离线测试;TaskNotFound 修复保留。
- 已通过 Core/Service .NET 测试、Go 测试与 vet、WebUI 测试/构建及完整 Release solution build。Windows 新版本已部署,`service.json` 未变。
- 已通过 Core 193/193、Service 26/26、Go 测试与 vet、WebUI 7/7 与生产构建及完整 Release solution build(0 warnings/errors)。Windows Host/Tray 新版本已部署,`service.json` 未变。
- Windows `inspect-ui` 成功,但 `doctor`/`smoke` 返回 `WechatNotLoggedIn`,必需聊天控件缺失;本轮未发送任何消息/附件,也未执行任何 M4/M5 可见写操作。上述功能的真机验收保持未完成,不得勾选为通过。
- 可脱离聊天 UI 的只读检查通过:批准的测试群成员页返回 2 个成员、0 空显示名、0 重名且无续页;`chat send-files` 重复 `--path` 解析成功,并在随机缺失路径预校验返回 `InvalidArgument`,未触及 UI。只保留聚合计数,未记录成员姓名。
- Weixin 关闭后再次验证本地数据库能力:`db status` 返回 1 个账号/20 个数据库,`filehelper` 消息读取返回 20 条且不输出正文,批准群成员分页仍返回 2 个唯一显示名,`Hao 豪` 联系人查询返回 1 个精确结果。
- 生产 `DatabaseMessageSyncCollector` 使用现存检查点(序列 4,223、427 个会话游标)离线采集到 1 条新消息,结果 complete、无 unavailable source,消息正文只以布尔值存在性确认;采集期间 Weixin 进程始终为 0,检查点和 `service.json` 均未改变。
- 上述结果只证明离线读取/收集,不等同远程新批次确认。2026-09-30 修复了控制面 sync-status 将“活动 UI 账号”误用于只读数据授权的问题:数据状态查询现在仅要求注册账号身份 verified;UI 任务仍要求 active + verified,并有 Go 回归测试。更新控制面后健康检查 HTTP 200,已验证但无活动 UI 会话的账号可读取消息流状态(complete、确认序列 4,223,与本地 source generation 匹配)。远程最后确认时间仍约 25 小时前。生产全范围 wildcard 离线采集返回 460 个会话、426 条消息、complete、0 unavailable、`hasNewItems=false`;队列为空,没有产生新批次或新的远程 ACK。此前显式 `filehelper` 范围探针报告 1 条候选记录,但这不是全范围会话快照的新增项,也不能证明已上传。`contacts` 流状态仍为 `unknown`,未验证联系人快照上传。保持“新批次上传/确认未复验”,不要把本地探针结果或旧检查点标记为本轮远程同步完成。
- 离线身份验证修复后的 Host SHA-256:`3C7A5628C37CA33AF96944A950E7B81002437010AE34AC9BC81461EFFE7373CA`;Tray SHA-256:`CFABF0BFB3AAB595DF240E75481F51A5DC96B8E9A6A0094A047F83B2BB35CC6D`。控制面更新二进制 SHA-256:`1cb05554056256f12d5ce5a8c6824095d7ca37dbbff282e4661d71b854e24b87`。`service.json` SHA-256 仍为 `5F4F40E040A7D69060CC09F1A390277286FA982D6EFBEF3D825DBCF29B6A56BB`,Windows 备份为 `backup-offline-sync-20260930-122015`。
- Windows MCP 桌面窗口列表未发现 Weixin;Weixin 进程保持 0。新部署后由 SSH 发起的 doctor/inspect-ui/read-only smoke 未形成有效交互会话验收;没有启动客户端或执行写操作,UI 相关项继续 pending。
- 详细范围、哈希、证据及未闭环项见[正常功能复核与验收记录](validation/Normal-Functional-Acceptance-2026-09-29.md)。M6 仍保持未完成。
## 已确定的功能边界
@@ -10,7 +10,7 @@
## 代码与自动化检查
- Core:188/188 测试通过。覆盖长文本 4,000 字符分段、组合字符边界、20,000 字符总上限、附件路径预校验、同名提及候选拒绝、重复 fingerprint 的新增出现计数,以及合并聊天图片/视频/文件/嵌套元数据。
- Core:193/193 测试通过。覆盖长文本 4,000 字符分段、组合字符边界、20,000 字符总上限、附件路径预校验、同名提及候选拒绝、重复 fingerprint 的新增出现计数、合并聊天图片/视频/文件/嵌套元数据,以及离线账号身份/活动 UI 账号选择规则。
- Service:26/26 测试通过。新增验证长 `send-text` 可进入分段发送路径,同时 `broadcast-text` 仍保持单条消息上限。
- Control plane:`gofmt`、`go test ./...`、`go vet ./...` 通过。发送文本按最多 20,000 UTF-16 code units 校验,并拒绝无效控制字符。
- WebUI:7/7 测试通过;`npm run build` 成功。Vite 对大 bundle 的提示不是构建失败。
@@ -23,10 +23,24 @@
- `service.json` SHA-256 部署前后均为 `5F4F40E040A7D69060CC09F1A390277286FA982D6EFBEF3D825DBCF29B6A56BB`,文件未修改。回滚备份:`C:\Users\Rogee\wx-agent01\backup-functional-20260929-163843`。
- Tray 已在交互式 Session 1 重启。通过 Session 1 临时验证任务运行了项目要求的 `doctor`、`inspect-ui`、`smoke`:`inspect-ui` exit 0;`doctor` 和 `smoke` exit 2,错误码 `WechatNotLoggedIn`。`UserInteractive=true`、`InputDesktopAvailable=true`、`WindowFound=true`,但 `MainView`、`session_list`、`chat_message_page`、`chat_message_list`、`chat_input_field`、`tool_bar_accessible` 均未找到。
- 因缺少可用的微信聊天控件,本轮没有发送消息、文件或提及成员,也没有继续对登录界面进行交互。UI 树仅保存于 Windows 主机的 `artifacts/ui-tree.json`,未读取或附入本记录。
- 另对批准的“消息测试专用群组”运行 Windows Host 只读 `db group-members` 查询:2 个成员、2 个非空显示名、0 个重复显示名、`hasMore=false`。脚本只输出并保存聚合计数,没有记录成员姓名或账号标识。
- 在关闭 Weixin 进程的情况下,使用 Windows Host 只读 `db group-members` 查询批准的“消息测试专用群组”:返回 2 个成员、2 个非空且唯一显示名、`hasMore=false`。脚本仅输出聚合计数,没有记录成员姓名或账号标识。
- Windows Host CLI `chat send-files` 用两条随机不存在的路径做无发送预校验:重复 `--path` 解析成功,按预期返回 `InvalidArgument`(附件文件不存在);此分支在进入 UI 自动化前拒绝输入,没有发送文件。
- Windows 只读 schema 探针发现 `contact` 表有 22 列,`contact_label` 有 `label_id_`/`label_name_`/`sort_order_` 三列;未确认联系人与标签的关联。`alias`、`description`、`extra_buffer` 的业务语义也未验证;探针仅读 schema,没有读取联系人行值,不映射字段、不解析不透明 BLOB。
### Weixin 关闭后的数据库与离线收集复核
- Weixin 进程在检查前后均为 0。Windows Host `db status` 成功,识别到 1 个账号及 20 个数据库(7 个消息库、1 个会话库);`db messages --chat filehelper --limit 20` 返回 20 条记录,命令输出未包含消息正文。只读联系人查询 `Hao 豪` 返回 1 条精确匹配;批准测试群成员读取见上方。
- 临时、自包含 Windows x64 探针调用生产 `DatabaseMessageSyncCollector`,读取 `service.json` 中已授权的 `filehelper` 私聊范围,并从持久化检查点继续:序列 4,223、427 个会话游标、coverage `complete`、0 个 unavailable source。采集结果为 1 个会话、1 条新消息,`hasNewItems=true`、complete、正文仅以 `messageTextPresent=true` 布尔值确认。探针未输出消息正文或密钥;Weixin 进程前后均为 0,检查点文件和 `service.json` 的哈希均未改变。
- **范围限制:**这证明关闭客户端时数据库读取及增量收集可运行,不证明控制面已收到数据。本次没有发起远程上传;已存检查点仍是序列 4,223/427 个游标,采集/确认时间约早 22.7 小时,待发送队列为空。因此不把本地采集结果表述为新一轮远程同步完成。
## 补充复核 — 2026-09-30
- 离线同步授权修复:Core/Windows 仅用持久化 binding 与 page-1 HMAC 已验证数据库身份确认只读同步账号;没有 UI 会话时 `ActiveAccountId` 为空,消息/管理写任务仍要求 live UI binding。控制面 `nodeHasAccount` 继续要求 `Active && Verified`,仅 sync-status 使用 `nodeHasVerifiedAccount`。新增 Go 集成测试确认 inactive + verified 账号可读 sync status,但仍不符合 UI 任务活动账号门禁。`go test ./...`、`go vet ./...` 通过。
- 新的 Windows Host/Tray 自包含单文件构建已部署:Host SHA-256 `3C7A5628C37CA33AF96944A950E7B81002437010AE34AC9BC81461EFFE7373CA`;Tray SHA-256 `CFABF0BFB3AAB595DF240E75481F51A5DC96B8E9A6A0094A047F83B2BB35CC6D`。备份目录 `C:\Users\Rogee\wx-agent01\backup-offline-sync-20260930-122015`。控制面 E2E 进程以更新二进制重启,SHA-256 `1cb05554056256f12d5ce5a8c6824095d7ca37dbbff282e4661d71b854e24b87`,`/healthz` HTTP 200;原有控制面数据文件和凭据文件未替换。`service.json` 部署前后 SHA-256 一致:`5F4F40E040A7D69060CC09F1A390277286FA982D6EFBEF3D825DBCF29B6A56BB`。
- 远端 node details 为 `WechatNotRunning`,一条本地 verified 账号已登记但无活动 UI;修复后消息 sync-status 返回 HTTP 200、`complete`、确认序列 4,223,generation 与本地相同。`contacts` stream 仍为 `unknown`,没有联系人快照 ACK。此前的 403 authorization-revoked 警告在控制面更新后记录一次授权恢复。
- Weixin 关闭时完整 wildcard 生产 collector 返回 460 个会话、426 条消息,complete、0 unavailable、`hasNewItems=false`;本地消息检查点 4,223/427 游标,队列为空。显式 `filehelper` 探针曾返回 1 条候选消息,但全范围 collector 未将其判为新增;该结果不视为已排队、上传或 ACK。远端最后成功仍约 25 小时前,因此本轮没有可证明的新批次上传/确认。
- Windows MCP 桌面窗口列表只有任务栏、dummyLayeredWnd 和 Program Manager,未发现 Weixin;进程保持 0。部署后的 doctor/inspect-ui/read-only smoke 通过 SSH 启动且未形成有效交互式 UI 验收;没有发送消息或执行 UI 写操作。长文本/附件/提及、管理、朋友圈等真机验收仍 blocked/pending,需用户在 Session 1 手动登录后进行受限验证。
## 项目待办状态(不得视为通过)
| 项目 | 代码检查 | 真机/样本验收 |
@@ -29,5 +29,15 @@ public static class AccountBindingMatcher
: new AccountMatch(null, nicknameMatches.Length == 0 ? "none" : "nickname", false);
}
public static bool IsUniquelyMatchedToAccount(
string expectedAccountId,
UiAccountIdentity boundIdentity,
IReadOnlyList<DatabaseAccountIdentity> databaseAccounts)
{
if (string.IsNullOrWhiteSpace(expectedAccountId)) return false;
var match = Match(boundIdentity, databaseAccounts);
return match.IsMatched && string.Equals(match.AccountId, expectedAccountId, StringComparison.OrdinalIgnoreCase);
}
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
@@ -17,6 +17,29 @@ public sealed class RemoteAccountContext
get { lock (_gate) return _snapshot; }
}
public static string? SelectActiveAccountId(
bool uiSessionAvailable,
string? preferredAccountId,
IReadOnlyCollection<RemoteAccountIdentity> identities,
IReadOnlyCollection<string> liveBoundAccountIds)
{
ArgumentNullException.ThrowIfNull(identities);
ArgumentNullException.ThrowIfNull(liveBoundAccountIds);
if (!uiSessionAvailable) return null;
var liveBoundIds = liveBoundAccountIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
var eligible = identities
.Where(identity => identity.Verified && liveBoundIds.Contains(identity.AccountId))
.ToArray();
if (!string.IsNullOrWhiteSpace(preferredAccountId))
{
var preferred = eligible.FirstOrDefault(identity =>
string.Equals(identity.AccountId, preferredAccountId, StringComparison.OrdinalIgnoreCase));
if (preferred is not null) return preferred.AccountId;
}
return eligible.Length == 1 ? eligible[0].AccountId : null;
}
public RemoteAccountContextSnapshot SwitchTo(
string accountId,
IReadOnlyCollection<RemoteAccountIdentity> identities,
+14 -1
View File
@@ -98,6 +98,10 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings, ServiceOpt
if (refreshUiIdentity)
await AutoBindMatchesAsync(accounts, targets, cancellationToken);
var current = bindings.ReadAll();
var databaseIdentities = accounts
.Where(account => account.Identity is not null)
.Select(account => account.Identity!)
.ToArray();
var singleBoundTarget = !refreshUiIdentity && current.Count == 1 && targets.Count == 1;
var live = current.Where(binding => targets.Any(target =>
(target.ProcessId == binding.ProcessId && target.WindowHandle == binding.WindowHandle &&
@@ -110,8 +114,17 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings, ServiceOpt
{
var binding = bindings.Get(account.AccountId);
var isLive = binding is not null && live.Contains(binding.AccountId);
var isVerifiedForReadOnlySync = binding is not null &&
string.Equals(binding.AccountId, account.AccountId, StringComparison.OrdinalIgnoreCase) &&
AccountBindingMatcher.IsUniquelyMatchedToAccount(
account.AccountId,
new UiAccountIdentity(binding.WechatId, binding.Nickname),
databaseIdentities);
return new AccountInfo(account.AccountId, account.Identity?.Nickname, account.Identity?.WechatId, null,
account.AccountId, isLive, binding, binding is null ? "Unbound" : isLive ? "Bound" : "Stale");
account.AccountId, isLive, binding, binding is null ? "Unbound" : isLive ? "Bound" : "Stale")
{
IsVerifiedForReadOnlySync = isVerifiedForReadOnlySync
};
}).ToArray();
}
@@ -20,7 +20,10 @@ public sealed record Page<T>(IReadOnlyList<T> Items, int Limit, int Offset, bool
{
public ReadCoverage? Coverage { get; init; }
}
public sealed record AccountInfo(string AccountId, string? DisplayName, string? WechatId, string? Region, string DataFingerprint, bool IsUiBindingKnown, AccountBinding? Binding = null, string BindingStatus = "Unbound");
public sealed record AccountInfo(string AccountId, string? DisplayName, string? WechatId, string? Region, string DataFingerprint, bool IsUiBindingKnown, AccountBinding? Binding = null, string BindingStatus = "Unbound")
{
public bool IsVerifiedForReadOnlySync { get; init; }
}
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);
@@ -880,23 +880,18 @@ public sealed class RemoteAgentHostedService(
var wechatRunning = GetBoolean(element, "wechatAvailable");
var sessionAvailable = GetBoolean(element, "sessionAvailable");
var sessionLocked = GetBoolean(element, "sessionLocked");
// Heartbeats must not refresh UI identity. Binding already contains the verified identity;
// the explicit accounts API remains the only path that may inspect the profile.
// A persisted binding plus a key-verified database identity may authorize read-only sync.
// UI tasks still require a live, available WeChat session and a live window binding.
var accounts = await backend.AccountsAsync(cancellationToken, refreshUiIdentity: false);
var identities = accounts.Select(account => new RemoteAccountIdentity(
account.AccountId, HasLiveWeChatBinding(account))).ToArray();
var activeAccountId = remote.ActiveAccountId;
var boundAccounts = identities
.Where(identity => identity.Verified)
.Select(identity => identity.AccountId)
.Distinct(StringComparer.OrdinalIgnoreCase)
account.AccountId, HasLiveWeChatBinding(account) || account.IsVerifiedForReadOnlySync)).ToArray();
var liveBoundAccountIds = accounts
.Where(HasLiveWeChatBinding)
.Select(account => account.AccountId)
.ToArray();
if ((string.IsNullOrWhiteSpace(activeAccountId) || !identities.Any(identity => identity.Verified && string.Equals(identity.AccountId, activeAccountId, StringComparison.Ordinal)))
&& boundAccounts.Length == 1)
{
// A single verified binding is safe to use when the optional GUI value is empty or a display name.
activeAccountId = boundAccounts[0];
}
var hasLiveUiSession = wechatRunning && sessionAvailable && !sessionLocked;
var activeAccountId = RemoteAccountContext.SelectActiveAccountId(
hasLiveUiSession, remote.ActiveAccountId, identities, liveBoundAccountIds);
var activeAccountVerified = false;
if (activeAccountId is { Length: > 0 })
{
@@ -904,9 +899,15 @@ public sealed class RemoteAgentHostedService(
{
accountContext.SwitchTo(activeAccountId, identities);
activeAccountVerified = accountContext.IsConfirmedFor(activeAccountId);
if (!activeAccountVerified)
{
activeAccountId = null;
accountContext.Invalidate();
}
}
catch (WxAgentException)
{
activeAccountId = null;
accountContext.Invalidate();
}
}
@@ -923,7 +924,7 @@ public sealed class RemoteAgentHostedService(
}
catch
{
return new BackendSnapshot(RemoteNodeStatus.Degraded, false, false, false, remote.ActiveAccountId, 0, false, []);
return new BackendSnapshot(RemoteNodeStatus.Degraded, false, false, false, null, 0, false, []);
}
}
@@ -0,0 +1,36 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class AccountBindingMatcherOfflineSyncTests
{
[Fact]
public void PersistedBindingMustResolveToTheExpectedDatabaseAccount()
{
var databaseAccounts = new[]
{
new DatabaseAccountIdentity("root-a", "wxid-a", "Alice"),
new DatabaseAccountIdentity("root-b", "wxid-b", "Bob")
};
Assert.True(AccountBindingMatcher.IsUniquelyMatchedToAccount(
"root-a", new UiAccountIdentity("wxid-a", "Alice"), databaseAccounts));
Assert.False(AccountBindingMatcher.IsUniquelyMatchedToAccount(
"root-b", new UiAccountIdentity("wxid-a", "Alice"), databaseAccounts));
}
[Fact]
public void AmbiguousOrUnavailableDatabaseIdentityFailsClosed()
{
var ambiguousAccounts = new[]
{
new DatabaseAccountIdentity("root-a", null, "Shared"),
new DatabaseAccountIdentity("root-b", null, "Shared")
};
var boundIdentity = new UiAccountIdentity(null, "Shared");
Assert.False(AccountBindingMatcher.IsUniquelyMatchedToAccount("root-a", boundIdentity, ambiguousAccounts));
Assert.False(AccountBindingMatcher.IsUniquelyMatchedToAccount("root-a", boundIdentity, []));
}
}
@@ -0,0 +1,43 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class RemoteAccountContextOfflineSyncTests
{
[Fact]
public void OfflineSessionNeverSelectsAnActiveUiAccount()
{
var identities = new[] { new RemoteAccountIdentity("root-a", true) };
Assert.Null(RemoteAccountContext.SelectActiveAccountId(
false, "root-a", identities, ["root-a"]));
}
[Fact]
public void ActiveSelectionRequiresLiveAndVerifiedBinding()
{
var identities = new[]
{
new RemoteAccountIdentity("offline", true),
new RemoteAccountIdentity("unverified", false),
new RemoteAccountIdentity("live", true)
};
Assert.Equal("live", RemoteAccountContext.SelectActiveAccountId(
true, "offline", identities, ["unverified", "live"]));
}
[Fact]
public void MultipleLiveBindingsRequireAnExplicitAccount()
{
var identities = new[]
{
new RemoteAccountIdentity("root-a", true),
new RemoteAccountIdentity("root-b", true)
};
Assert.Null(RemoteAccountContext.SelectActiveAccountId(true, null, identities, ["root-a", "root-b"]));
Assert.Equal("root-b", RemoteAccountContext.SelectActiveAccountId(true, "root-b", identities, ["root-a", "root-b"]));
}
}