From 24836a3826511d26509191863ae7af21e0ee159a Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Thu, 12 Mar 2026 00:54:28 +0800 Subject: [PATCH] feat: add gateway device pairing controls --- lib/app/app_controller.dart | 60 ++- lib/features/assistant/assistant_page.dart | 10 + lib/features/settings/settings_page.dart | 485 ++++++++++++++++++++- lib/runtime/gateway_runtime.dart | 269 +++++++++++- lib/runtime/runtime_controllers.dart | 130 +++++- lib/runtime/runtime_models.dart | 135 ++++++ lib/runtime/secure_config_store.dart | 18 + lib/widgets/gateway_connect_dialog.dart | 19 + test/features/settings_page_test.dart | 17 + test/runtime/gateway_runtime_test.dart | 313 ++++++++++--- 10 files changed, 1382 insertions(+), 74 deletions(-) diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 3c64a919..444d5572 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -26,6 +26,7 @@ class AppController extends ChangeNotifier { _connectorsController = ConnectorsController(_runtime); _modelsController = ModelsController(_runtime); _cronJobsController = CronJobsController(_runtime); + _devicesController = DevicesController(_runtime); _tasksController = DerivedTasksController(); _attachChildListeners(); unawaited(_initialize()); @@ -43,6 +44,7 @@ class AppController extends ChangeNotifier { late final ConnectorsController _connectorsController; late final ModelsController _modelsController; late final CronJobsController _cronJobsController; + late final DevicesController _devicesController; late final DerivedTasksController _tasksController; WorkspaceDestination _destination = WorkspaceDestination.assistant; @@ -70,6 +72,7 @@ class AppController extends ChangeNotifier { ConnectorsController get connectorsController => _connectorsController; ModelsController get modelsController => _modelsController; CronJobsController get cronJobsController => _cronJobsController; + DevicesController get devicesController => _devicesController; DerivedTasksController get tasksController => _tasksController; GatewayConnectionSnapshot get connection => _runtime.snapshot; @@ -81,6 +84,7 @@ class AppController extends ChangeNotifier { List get connectors => _connectorsController.items; List get models => _modelsController.items; List get cronJobs => _cronJobsController.items; + GatewayDevicePairingList get devices => _devicesController.items; String get selectedAgentId => _agentsController.selectedAgentId; String get activeAgentName => _agentsController.activeAgentName; String get currentSessionKey => _sessionsController.currentSessionKey; @@ -92,7 +96,10 @@ class AppController extends ChangeNotifier { settings.assistantPermissionLevel; bool get hasStoredGatewayCredential => _settingsController.secureRefs.containsKey('gateway_token') || - _settingsController.secureRefs.containsKey('gateway_password'); + _settingsController.secureRefs.containsKey('gateway_password') || + _settingsController.secureRefs.containsKey( + 'gateway_device_token_operator', + ); bool get canQuickConnectGateway { final profile = settings.gateway; if (profile.useSetupCode && profile.setupCode.trim().isNotEmpty) { @@ -277,6 +284,7 @@ class AppController extends ChangeNotifier { Future disconnectGateway() async { await _runtime.disconnect(clearDesiredProfile: false); + await _settingsController.refreshDerivedState(); await _agentsController.refresh(); await _sessionsController.refresh(); _chatController.clear(); @@ -285,6 +293,7 @@ class AppController extends ChangeNotifier { await _connectorsController.refresh(); await _modelsController.refresh(); await _cronJobsController.refresh(); + _devicesController.clear(); _recomputeTasks(); } @@ -305,6 +314,46 @@ class AppController extends ChangeNotifier { notifyListeners(); } + Future refreshDevices({bool quiet = false}) async { + await _devicesController.refresh(quiet: quiet); + } + + Future approveDevicePairing(String requestId) async { + await _devicesController.approve(requestId); + await _settingsController.refreshDerivedState(); + } + + Future rejectDevicePairing(String requestId) async { + await _devicesController.reject(requestId); + } + + Future removePairedDevice(String deviceId) async { + await _devicesController.remove(deviceId); + await _settingsController.refreshDerivedState(); + } + + Future rotateDeviceRoleToken({ + required String deviceId, + required String role, + List scopes = const [], + }) async { + final token = await _devicesController.rotateToken( + deviceId: deviceId, + role: role, + scopes: scopes, + ); + await _settingsController.refreshDerivedState(); + return token; + } + + Future revokeDeviceRoleToken({ + required String deviceId, + required String role, + }) async { + await _devicesController.revokeToken(deviceId: deviceId, role: role); + await _settingsController.refreshDerivedState(); + } + Future refreshAgents() async { await _agentsController.refresh(); _sessionsController.configure( @@ -445,6 +494,7 @@ class AppController extends ChangeNotifier { _connectorsController.dispose(); _modelsController.dispose(); _cronJobsController.dispose(); + _devicesController.dispose(); _tasksController.dispose(); super.dispose(); } @@ -506,6 +556,8 @@ class AppController extends ChangeNotifier { await _connectorsController.refresh(); await _modelsController.refresh(); await _cronJobsController.refresh(); + await _devicesController.refresh(quiet: true); + await _settingsController.refreshDerivedState(); _recomputeTasks(); } @@ -521,6 +573,10 @@ class AppController extends ChangeNotifier { if (event.event == 'seqGap') { unawaited(refreshSessions()); } + if (event.event == 'device.pair.requested' || + event.event == 'device.pair.resolved') { + unawaited(refreshDevices(quiet: true)); + } } void _recomputeTasks() { @@ -544,6 +600,7 @@ class AppController extends ChangeNotifier { _connectorsController.addListener(_relayChildChange); _modelsController.addListener(_relayChildChange); _cronJobsController.addListener(_relayChildChange); + _devicesController.addListener(_relayChildChange); _tasksController.addListener(_relayChildChange); } @@ -558,6 +615,7 @@ class AppController extends ChangeNotifier { _connectorsController.removeListener(_relayChildChange); _modelsController.removeListener(_relayChildChange); _cronJobsController.removeListener(_relayChildChange); + _devicesController.removeListener(_relayChildChange); _tasksController.removeListener(_relayChildChange); } diff --git a/lib/features/assistant/assistant_page.dart b/lib/features/assistant/assistant_page.dart index 5096669b..8f9136c5 100644 --- a/lib/features/assistant/assistant_page.dart +++ b/lib/features/assistant/assistant_page.dart @@ -799,6 +799,16 @@ class _AssistantEmptyState extends StatelessWidget { '输入需求后即可开始执行,结果会回到当前会话并同步到任务页。', 'Type a request to start execution. Results return to this session and the Tasks page.', ) + : connection.pairingRequired + ? appText( + '当前设备还没通过 Gateway 配对审批。请先在已授权设备上批准该 pairing request,再重新连接。', + 'This device has not been approved yet. Approve the pairing request from an authorized device, then reconnect.', + ) + : connection.gatewayTokenMissing + ? appText( + '首次连接需要共享 Token;配对完成后可继续使用本机的 device token。', + 'The first connection requires a shared token; after pairing, this device can continue with its device token.', + ) : (connection.lastError?.trim().isNotEmpty == true ? connection.lastError!.trim() : appText( diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index d824ea15..8c42bd65 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -165,7 +165,6 @@ class _SettingsPageState extends State { ], ), ), - const SizedBox(height: 16), SurfaceCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -261,7 +260,6 @@ class _SettingsPageState extends State { ], ), ), - const SizedBox(height: 16), SurfaceCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -458,6 +456,8 @@ class _SettingsPageState extends State { ), ), const SizedBox(height: 16), + _buildDeviceSecurityCard(context, controller), + const SizedBox(height: 16), SurfaceCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -821,6 +821,487 @@ class _SettingsPageState extends State { ) { return controller.saveSettings(snapshot); } + + Widget _buildDeviceSecurityCard( + BuildContext context, + AppController controller, + ) { + final theme = Theme.of(context); + final connection = controller.connection; + final devices = controller.devices; + final pending = devices.pending; + final paired = devices.paired; + final authScopes = connection.authScopes.isEmpty + ? appText('未协商', 'Not negotiated') + : connection.authScopes.join(', '); + return SurfaceCard( + key: const ValueKey('gateway-device-security-card'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('设备配对与角色令牌', 'Device Pairing & Role Tokens'), + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 6), + Text( + appText( + '对齐 OpenClaw 的 Devices 安全机制,处理 pairing requests 和按角色下发的 device token。', + 'Match OpenClaw device security: pairing requests and per-role device tokens.', + ), + style: theme.textTheme.bodyMedium, + ), + ], + ), + ), + const SizedBox(width: 12), + OutlinedButton( + onPressed: controller.runtime.isConnected + ? () => controller.refreshDevices() + : null, + child: Text(appText('刷新', 'Refresh')), + ), + ], + ), + const SizedBox(height: 16), + _InfoRow( + label: appText('本机 Device ID', 'Local Device ID'), + value: connection.deviceId ?? appText('未初始化', 'Not initialized'), + ), + _InfoRow( + label: appText('当前角色', 'Current Role'), + value: connection.authRole ?? 'operator', + ), + _InfoRow(label: appText('授权范围', 'Granted Scopes'), value: authScopes), + if (connection.pairingRequired) ...[ + const SizedBox(height: 8), + _buildNotice( + context, + tone: theme.colorScheme.tertiaryContainer, + title: appText('需要设备审批', 'Pairing Required'), + message: appText( + '当前设备已经向 Gateway 发起配对。请在已授权的 operator 设备上审批该请求,然后重新连接。', + 'This device has requested pairing. Approve it from an authorized operator device, then reconnect.', + ), + ), + ] else if (connection.gatewayTokenMissing) ...[ + const SizedBox(height: 8), + _buildNotice( + context, + tone: theme.colorScheme.errorContainer, + title: appText('缺少共享 Token', 'Shared Token Missing'), + message: appText( + '当前连接没有通过共享 token 或已配对 device token 完成鉴权。先输入共享 Token 建立首次配对,后续可切换为 device token。', + 'The current connection is missing shared-token or paired device-token auth. Use a shared token for the first pairing, then continue with the device token.', + ), + ), + ], + if ((controller.devicesController.error ?? '').isNotEmpty) ...[ + const SizedBox(height: 8), + _buildNotice( + context, + tone: theme.colorScheme.errorContainer, + title: appText('设备列表错误', 'Devices Error'), + message: controller.devicesController.error!, + ), + ], + const SizedBox(height: 16), + if (!controller.runtime.isConnected) ...[ + Text( + appText( + '连接 Gateway 后,这里会显示待审批设备、已配对设备和角色令牌。', + 'Connect the gateway to load pending devices, paired devices, and role tokens.', + ), + style: theme.textTheme.bodyMedium, + ), + ] else ...[ + Text( + appText('待审批请求', 'Pending Requests'), + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 10), + if (pending.isEmpty) + Text( + appText('当前没有待审批设备。', 'No pending pairing requests.'), + style: theme.textTheme.bodyMedium, + ) + else + ...pending.map( + (item) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _buildPendingDeviceCard(context, controller, item), + ), + ), + const SizedBox(height: 20), + Text( + appText('已配对设备', 'Paired Devices'), + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 10), + if (paired.isEmpty) + Text( + appText('当前没有已配对设备。', 'No paired devices yet.'), + style: theme.textTheme.bodyMedium, + ) + else + ...paired.map( + (item) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _buildPairedDeviceCard(context, controller, item), + ), + ), + ], + ], + ), + ); + } + + Widget _buildPendingDeviceCard( + BuildContext context, + AppController controller, + GatewayPendingDevice item, + ) { + final theme = Theme.of(context); + final metadata = [ + if ((item.role ?? '').isNotEmpty) 'role: ${item.role}', + if (item.scopes.isNotEmpty) item.scopes.join(', '), + if ((item.remoteIp ?? '').isNotEmpty) item.remoteIp!, + _relativeTime(item.requestedAtMs), + if (item.isRepair) appText('修复请求', 'repair'), + ]; + return DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(18), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.label, style: theme.textTheme.titleMedium), + const SizedBox(height: 4), + SelectableText( + item.deviceId, + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 8), + Text(metadata.join(' · '), style: theme.textTheme.bodySmall), + ], + ), + ), + const SizedBox(width: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.tonal( + onPressed: () => + controller.approveDevicePairing(item.requestId), + child: Text(appText('批准', 'Approve')), + ), + OutlinedButton( + onPressed: () async { + final confirmed = await _confirmDeviceAction( + context, + title: appText('拒绝配对请求', 'Reject Pairing Request'), + message: appText( + '确定拒绝 ${item.label} 的配对请求吗?', + 'Reject the pairing request from ${item.label}?', + ), + ); + if (confirmed == true) { + await controller.rejectDevicePairing(item.requestId); + } + }, + child: Text(appText('拒绝', 'Reject')), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildPairedDeviceCard( + BuildContext context, + AppController controller, + GatewayPairedDevice item, + ) { + final theme = Theme.of(context); + final meta = [ + if (item.roles.isNotEmpty) 'roles: ${item.roles.join(', ')}', + if (item.scopes.isNotEmpty) 'scopes: ${item.scopes.join(', ')}', + if ((item.remoteIp ?? '').isNotEmpty) item.remoteIp!, + if (item.currentDevice) appText('当前设备', 'current device'), + ]; + return DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(18), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.label, style: theme.textTheme.titleMedium), + const SizedBox(height: 4), + SelectableText( + item.deviceId, + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 8), + Text(meta.join(' · '), style: theme.textTheme.bodySmall), + ], + ), + ), + const SizedBox(width: 12), + OutlinedButton( + onPressed: () async { + final confirmed = await _confirmDeviceAction( + context, + title: appText('移除已配对设备', 'Remove Paired Device'), + message: appText( + '确定移除 ${item.label} 吗?这会使该设备需要重新配对。', + 'Remove ${item.label}? The device will need pairing again.', + ), + ); + if (confirmed == true) { + await controller.removePairedDevice(item.deviceId); + } + }, + child: Text(appText('移除', 'Remove')), + ), + ], + ), + const SizedBox(height: 12), + if (item.tokens.isEmpty) + Text( + appText('当前没有角色令牌。', 'No role tokens.'), + style: theme.textTheme.bodySmall, + ) + else + ...item.tokens.map( + (token) => Padding( + padding: const EdgeInsets.only(top: 10), + child: _buildTokenRow(context, controller, item, token), + ), + ), + ], + ), + ), + ); + } + + Widget _buildTokenRow( + BuildContext context, + AppController controller, + GatewayPairedDevice device, + GatewayDeviceTokenSummary token, + ) { + final theme = Theme.of(context); + final details = [ + token.revoked ? appText('已撤销', 'revoked') : appText('有效', 'active'), + if (token.scopes.isNotEmpty) token.scopes.join(', '), + _relativeTime( + token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs, + ), + ]; + return DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(14), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(token.role, style: theme.textTheme.titleSmall), + const SizedBox(height: 4), + Text(details.join(' · '), style: theme.textTheme.bodySmall), + ], + ), + ), + const SizedBox(width: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.tonal( + onPressed: () async { + final nextToken = await controller.rotateDeviceRoleToken( + deviceId: device.deviceId, + role: token.role, + scopes: token.scopes, + ); + if (!context.mounted || + nextToken == null || + nextToken.isEmpty) { + return; + } + await _showRotatedTokenDialog( + context, + device: device, + role: token.role, + token: nextToken, + ); + }, + child: Text(appText('轮换', 'Rotate')), + ), + if (!token.revoked) + OutlinedButton( + onPressed: () async { + final confirmed = await _confirmDeviceAction( + context, + title: appText('撤销角色令牌', 'Revoke Role Token'), + message: appText( + '确定撤销 ${device.label} 的 ${token.role} 令牌吗?', + 'Revoke the ${token.role} token for ${device.label}?', + ), + ); + if (confirmed == true) { + await controller.revokeDeviceRoleToken( + deviceId: device.deviceId, + role: token.role, + ); + } + }, + child: Text(appText('撤销', 'Revoke')), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildNotice( + BuildContext context, { + required Color tone, + required String title, + required String message, + }) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: tone, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleMedium), + const SizedBox(height: 6), + SelectableText(message, style: theme.textTheme.bodyMedium), + ], + ), + ); + } + + Future _confirmDeviceAction( + BuildContext context, { + required String title, + required String message, + }) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(appText('取消', 'Cancel')), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(appText('确认', 'Confirm')), + ), + ], + ), + ); + } + + Future _showRotatedTokenDialog( + BuildContext context, { + required GatewayPairedDevice device, + required String role, + required String token, + }) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(appText('新的角色令牌', 'New Role Token')), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText( + '${device.label} 的 $role 令牌已轮换,请立即安全保存。', + 'Rotated the $role token for ${device.label}. Store it securely now.', + ), + ), + const SizedBox(height: 12), + SelectableText(token), + ], + ), + actions: [ + FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(appText('关闭', 'Close')), + ), + ], + ), + ); + } + + String _relativeTime(int? timestampMs) { + if (timestampMs == null || timestampMs <= 0) { + return appText('时间未知', 'time unknown'); + } + final delta = DateTime.now().difference( + DateTime.fromMillisecondsSinceEpoch(timestampMs), + ); + if (delta.inMinutes < 1) { + return appText('刚刚', 'just now'); + } + if (delta.inHours < 1) { + return appText('${delta.inMinutes} 分钟前', '${delta.inMinutes}m ago'); + } + if (delta.inDays < 1) { + return appText('${delta.inHours} 小时前', '${delta.inHours}h ago'); + } + return appText('${delta.inDays} 天前', '${delta.inDays}d ago'); + } } class _EditableField extends StatelessWidget { diff --git a/lib/runtime/gateway_runtime.dart b/lib/runtime/gateway_runtime.dart index 07eed27a..f70c8bce 100644 --- a/lib/runtime/gateway_runtime.dart +++ b/lib/runtime/gateway_runtime.dart @@ -35,10 +35,13 @@ class GatewayPushEvent { } class GatewayRuntimeException implements Exception { - GatewayRuntimeException(this.message, {this.code}); + GatewayRuntimeException(this.message, {this.code, this.details}); final String message; final String? code; + final Object? details; + + String? get detailCode => stringValue(asMap(details)['code']); @override String toString() => code == null ? message : '$code: $message'; @@ -109,7 +112,7 @@ class GatewayRuntime extends ChangeNotifier { final storedPassword = (await _store.loadGatewayPassword())?.trim() ?? ''; final explicitToken = authTokenOverride.trim(); final explicitPassword = authPasswordOverride.trim(); - final token = explicitToken.isNotEmpty + final sharedToken = explicitToken.isNotEmpty ? explicitToken : storedToken.isNotEmpty ? storedToken @@ -119,12 +122,28 @@ class GatewayRuntime extends ChangeNotifier { : storedPassword.isNotEmpty ? storedPassword : (setupPayload?.password.trim() ?? ''); + final identity = await _identityStore.loadOrCreate(); + final storedDeviceToken = + (await _store.loadDeviceToken( + deviceId: identity.deviceId, + role: 'operator', + ))?.trim() ?? + ''; + final explicitDeviceToken = ''; + final deviceToken = explicitDeviceToken.isNotEmpty + ? explicitDeviceToken + : sharedToken.isEmpty + ? storedDeviceToken + : ''; + final authToken = sharedToken.isNotEmpty ? sharedToken : deviceToken; if (endpoint == null) { _snapshot = GatewayConnectionSnapshot.initial(mode: profile.mode) .copyWith( statusText: 'Missing gateway endpoint', lastError: 'Configure setup code or manual host / port first.', + lastErrorCode: 'MISSING_ENDPOINT', + deviceId: identity.deviceId, ); notifyListeners(); return; @@ -134,8 +153,14 @@ class GatewayRuntime extends ChangeNotifier { status: RuntimeConnectionStatus.connecting, statusText: 'Connecting…', remoteAddress: '${endpoint.$1}:${endpoint.$2}', - hasSharedAuth: token.isNotEmpty || password.isNotEmpty, + deviceId: identity.deviceId, + authRole: 'operator', + authScopes: kDefaultOperatorConnectScopes, + hasSharedAuth: sharedToken.isNotEmpty || password.isNotEmpty, + hasDeviceToken: deviceToken.isNotEmpty, clearLastError: true, + clearLastErrorCode: true, + clearLastErrorDetailCode: true, ); notifyListeners(); @@ -165,15 +190,6 @@ class GatewayRuntime extends ChangeNotifier { code: 'CONNECT_CHALLENGE_TIMEOUT', ), ); - - final identity = await _identityStore.loadOrCreate(); - final deviceToken = - (await _store.loadDeviceToken( - deviceId: identity.deviceId, - role: 'operator', - ))?.trim() ?? - ''; - final authToken = token.isNotEmpty ? token : deviceToken; final connectResult = await _requestRaw( 'connect', params: await _buildConnectParams( @@ -181,6 +197,7 @@ class GatewayRuntime extends ChangeNotifier { identity: identity, nonce: nonce, authToken: authToken, + authDeviceToken: deviceToken, authPassword: password, ), timeout: const Duration(seconds: 12), @@ -207,21 +224,41 @@ class GatewayRuntime extends ChangeNotifier { mainSessionKey: stringValue(sessionDefaults['mainSessionKey']) ?? 'main', lastConnectedAtMs: DateTime.now().millisecondsSinceEpoch, - hasSharedAuth: token.isNotEmpty || password.isNotEmpty, + authRole: stringValue(auth['role']) ?? 'operator', + authScopes: stringList(auth['scopes']), + hasSharedAuth: sharedToken.isNotEmpty || password.isNotEmpty, hasDeviceToken: - returnedDeviceToken != null && returnedDeviceToken.isNotEmpty, + (returnedDeviceToken != null && returnedDeviceToken.isNotEmpty) || + deviceToken.isNotEmpty, clearLastError: true, + clearLastErrorCode: true, + clearLastErrorDetailCode: true, ); notifyListeners(); } catch (error) { + final runtimeError = error is GatewayRuntimeException ? error : null; + if (runtimeError?.detailCode == 'AUTH_DEVICE_TOKEN_MISMATCH' && + deviceToken.isNotEmpty && + sharedToken.isEmpty) { + await _store.clearDeviceToken( + deviceId: identity.deviceId, + role: 'operator', + ); + } await _closeSocket(); _snapshot = _snapshot.copyWith( status: RuntimeConnectionStatus.error, statusText: 'Connection failed', lastError: error.toString(), + lastErrorCode: runtimeError?.code, + lastErrorDetailCode: runtimeError?.detailCode, + hasSharedAuth: sharedToken.isNotEmpty || password.isNotEmpty, + hasDeviceToken: deviceToken.isNotEmpty, ); notifyListeners(); - _scheduleReconnect(); + if (_shouldAutoReconnect(runtimeError)) { + _scheduleReconnect(); + } rethrow; } } @@ -236,6 +273,9 @@ class GatewayRuntime extends ChangeNotifier { _snapshot = GatewayConnectionSnapshot.initial(mode: _snapshot.mode) .copyWith( statusText: 'Offline', + deviceId: _snapshot.deviceId, + authRole: _snapshot.authRole, + authScopes: _snapshot.authScopes, hasSharedAuth: _snapshot.hasSharedAuth, hasDeviceToken: _snapshot.hasDeviceToken, ); @@ -611,6 +651,108 @@ class GatewayRuntime extends ChangeNotifier { .toList(growable: false); } + Future listDevicePairing() async { + final payload = asMap( + await request( + 'device.pair.list', + params: const {}, + timeout: const Duration(seconds: 12), + ), + ); + final identity = await _store.loadDeviceIdentity(); + return GatewayDevicePairingList( + pending: asList( + payload['pending'], + ).map((item) => _parsePendingDevice(asMap(item))).toList(growable: false), + paired: asList(payload['paired']) + .map( + (item) => _parsePairedDevice( + asMap(item), + currentDeviceId: identity?.deviceId, + ), + ) + .toList(growable: false), + ); + } + + Future approveDevicePairing(String requestId) async { + final payload = asMap( + await request( + 'device.pair.approve', + params: {'requestId': requestId}, + timeout: const Duration(seconds: 12), + ), + ); + final identity = await _store.loadDeviceIdentity(); + final device = asMap(payload['device']); + if (device.isEmpty) { + return null; + } + return _parsePairedDevice(device, currentDeviceId: identity?.deviceId); + } + + Future rejectDevicePairing(String requestId) async { + await request( + 'device.pair.reject', + params: {'requestId': requestId}, + timeout: const Duration(seconds: 12), + ); + } + + Future removePairedDevice(String deviceId) async { + await request( + 'device.pair.remove', + params: {'deviceId': deviceId}, + timeout: const Duration(seconds: 12), + ); + } + + Future rotateDeviceToken({ + required String deviceId, + required String role, + List scopes = const [], + }) async { + final payload = asMap( + await request( + 'device.token.rotate', + params: { + 'deviceId': deviceId, + 'role': role, + if (scopes.isNotEmpty) 'scopes': scopes, + }, + timeout: const Duration(seconds: 12), + ), + ); + final token = stringValue(payload['token']) ?? ''; + final identity = await _store.loadDeviceIdentity(); + final resolvedRole = stringValue(payload['role']) ?? role; + if (token.isNotEmpty && + identity != null && + (stringValue(payload['deviceId']) ?? deviceId) == identity.deviceId) { + await _store.saveDeviceToken( + deviceId: identity.deviceId, + role: resolvedRole, + token: token, + ); + } + return token; + } + + Future revokeDeviceToken({ + required String deviceId, + required String role, + }) async { + await request( + 'device.token.revoke', + params: {'deviceId': deviceId, 'role': role}, + timeout: const Duration(seconds: 12), + ); + final identity = await _store.loadDeviceIdentity(); + if (identity != null && deviceId == identity.deviceId) { + await _store.clearDeviceToken(deviceId: identity.deviceId, role: role); + } + } + Future request( String method, { Map? params, @@ -663,11 +805,58 @@ class GatewayRuntime extends ChangeNotifier { } } + GatewayPendingDevice _parsePendingDevice(Map map) { + return GatewayPendingDevice( + requestId: stringValue(map['requestId']) ?? _randomId(), + deviceId: stringValue(map['deviceId']) ?? 'unknown-device', + displayName: stringValue(map['displayName']), + role: stringValue(map['role']), + scopes: stringList(map['scopes']), + remoteIp: stringValue(map['remoteIp']), + isRepair: boolValue(map['isRepair']) ?? false, + requestedAtMs: intValue(map['ts']), + ); + } + + GatewayPairedDevice _parsePairedDevice( + Map map, { + String? currentDeviceId, + }) { + return GatewayPairedDevice( + deviceId: stringValue(map['deviceId']) ?? 'unknown-device', + displayName: stringValue(map['displayName']), + roles: stringList(map['roles']), + scopes: stringList(map['scopes']), + remoteIp: stringValue(map['remoteIp']), + tokens: asList( + map['tokens'], + ).map((item) => _parseTokenSummary(asMap(item))).toList(growable: false), + createdAtMs: intValue(map['createdAtMs']), + approvedAtMs: intValue(map['approvedAtMs']), + currentDevice: + currentDeviceId != null && + currentDeviceId.isNotEmpty && + currentDeviceId == stringValue(map['deviceId']), + ); + } + + GatewayDeviceTokenSummary _parseTokenSummary(Map map) { + return GatewayDeviceTokenSummary( + role: stringValue(map['role']) ?? 'operator', + scopes: stringList(map['scopes']), + createdAtMs: intValue(map['createdAtMs']), + rotatedAtMs: intValue(map['rotatedAtMs']), + revokedAtMs: intValue(map['revokedAtMs']), + lastUsedAtMs: intValue(map['lastUsedAtMs']), + ); + } + Future> _buildConnectParams({ required GatewayConnectionProfile profile, required LocalDeviceIdentity identity, required String nonce, required String authToken, + required String authDeviceToken, required String authPassword, }) async { final clientId = _resolveClientId(); @@ -709,10 +898,14 @@ class GatewayRuntime extends ChangeNotifier { 'permissions': const {}, 'role': 'operator', 'scopes': kDefaultOperatorConnectScopes, - if (authToken.isNotEmpty) - 'auth': {'token': authToken} - else if (authPassword.isNotEmpty) - 'auth': {'password': authPassword}, + if (authToken.isNotEmpty || + authDeviceToken.isNotEmpty || + authPassword.isNotEmpty) + 'auth': { + if (authToken.isNotEmpty) 'token': authToken, + if (authDeviceToken.isNotEmpty) 'deviceToken': authDeviceToken, + if (authPassword.isNotEmpty) 'password': authPassword, + }, 'locale': Platform.localeName, 'userAgent': '$kSystemAppName/$_packageInfo.version', 'device': { @@ -783,6 +976,7 @@ class GatewayRuntime extends ChangeNotifier { GatewayRuntimeException( stringValue(error['message']) ?? 'gateway request failed', code: stringValue(error['code']), + details: error['details'], ), ); return; @@ -799,6 +993,8 @@ class GatewayRuntime extends ChangeNotifier { status: RuntimeConnectionStatus.error, statusText: 'Gateway error', lastError: message, + lastErrorCode: 'SOCKET_FAILURE', + lastErrorDetailCode: null, ); notifyListeners(); _scheduleReconnect(); @@ -815,6 +1011,8 @@ class GatewayRuntime extends ChangeNotifier { status: RuntimeConnectionStatus.error, statusText: 'Disconnected', lastError: 'Gateway connection closed', + lastErrorCode: 'SOCKET_CLOSED', + lastErrorDetailCode: null, ); notifyListeners(); _scheduleReconnect(); @@ -841,6 +1039,39 @@ class GatewayRuntime extends ChangeNotifier { }); } + bool _shouldAutoReconnect(GatewayRuntimeException? error) { + if (error == null) { + return true; + } + final code = error.code?.trim().toUpperCase(); + final detailCode = error.detailCode?.trim().toUpperCase(); + const nonRetryableCodes = { + 'INVALID_REQUEST', + 'UNAUTHORIZED', + 'NOT_PAIRED', + 'AUTH_REQUIRED', + }; + const nonRetryableDetailCodes = { + 'AUTH_REQUIRED', + 'AUTH_UNAUTHORIZED', + 'AUTH_TOKEN_MISSING', + 'AUTH_TOKEN_MISMATCH', + 'AUTH_PASSWORD_MISSING', + 'AUTH_PASSWORD_MISMATCH', + 'AUTH_DEVICE_TOKEN_MISMATCH', + 'PAIRING_REQUIRED', + 'DEVICE_IDENTITY_REQUIRED', + 'CONTROL_UI_DEVICE_IDENTITY_REQUIRED', + }; + if (code != null && nonRetryableCodes.contains(code)) { + return false; + } + if (detailCode != null && nonRetryableDetailCodes.contains(detailCode)) { + return false; + } + return true; + } + Future _closeSocket() async { _reconnectTimer?.cancel(); final subscription = _socketSubscription; diff --git a/lib/runtime/runtime_controllers.dart b/lib/runtime/runtime_controllers.dart index 577f3f88..ad67cbaa 100644 --- a/lib/runtime/runtime_controllers.dart +++ b/lib/runtime/runtime_controllers.dart @@ -32,6 +32,11 @@ class SettingsController extends ChangeNotifier { notifyListeners(); } + Future refreshDerivedState() async { + await _reloadDerivedState(); + notifyListeners(); + } + Future saveSnapshot(SettingsSnapshot snapshot) async { _snapshot = snapshot; await _store.saveSettingsSnapshot(snapshot); @@ -297,7 +302,7 @@ class SettingsController extends ChangeNotifier { String _moduleForSecret(String key) { if (key.contains('gateway')) { - return 'Assistant'; + return key.contains('device_token') ? 'Devices' : 'Assistant'; } if (key.contains('ollama')) { return 'Settings'; @@ -832,6 +837,129 @@ class CronJobsController extends ChangeNotifier { } } +class DevicesController extends ChangeNotifier { + DevicesController(this._runtime); + + final GatewayRuntime _runtime; + + GatewayDevicePairingList _items = const GatewayDevicePairingList.empty(); + bool _loading = false; + String? _error; + + GatewayDevicePairingList get items => _items; + bool get loading => _loading; + String? get error => _error; + + Future refresh({bool quiet = false}) async { + if (!_runtime.isConnected) { + _items = const GatewayDevicePairingList.empty(); + if (!quiet) { + _error = null; + } + notifyListeners(); + return; + } + if (_loading) { + return; + } + _loading = true; + if (!quiet) { + _error = null; + } + notifyListeners(); + try { + _items = await _runtime.listDevicePairing(); + } catch (error) { + if (!quiet) { + _error = error.toString(); + } + } finally { + _loading = false; + notifyListeners(); + } + } + + Future approve(String requestId) async { + _error = null; + notifyListeners(); + try { + await _runtime.approveDevicePairing(requestId); + await refresh(quiet: true); + } catch (error) { + _error = error.toString(); + notifyListeners(); + } + } + + Future reject(String requestId) async { + _error = null; + notifyListeners(); + try { + await _runtime.rejectDevicePairing(requestId); + await refresh(quiet: true); + } catch (error) { + _error = error.toString(); + notifyListeners(); + } + } + + Future remove(String deviceId) async { + _error = null; + notifyListeners(); + try { + await _runtime.removePairedDevice(deviceId); + await refresh(quiet: true); + } catch (error) { + _error = error.toString(); + notifyListeners(); + } + } + + Future rotateToken({ + required String deviceId, + required String role, + List scopes = const [], + }) async { + _error = null; + notifyListeners(); + try { + final token = await _runtime.rotateDeviceToken( + deviceId: deviceId, + role: role, + scopes: scopes, + ); + await refresh(quiet: true); + return token; + } catch (error) { + _error = error.toString(); + notifyListeners(); + return null; + } + } + + Future revokeToken({ + required String deviceId, + required String role, + }) async { + _error = null; + notifyListeners(); + try { + await _runtime.revokeDeviceToken(deviceId: deviceId, role: role); + await refresh(quiet: true); + } catch (error) { + _error = error.toString(); + notifyListeners(); + } + } + + void clear() { + _items = const GatewayDevicePairingList.empty(); + _error = null; + _loading = false; + notifyListeners(); + } +} + class DerivedTasksController extends ChangeNotifier { List _queue = const []; List _running = const []; diff --git a/lib/runtime/runtime_models.dart b/lib/runtime/runtime_models.dart index 70250e13..adc82e27 100644 --- a/lib/runtime/runtime_models.dart +++ b/lib/runtime/runtime_models.dart @@ -640,7 +640,12 @@ class GatewayConnectionSnapshot { required this.remoteAddress, required this.mainSessionKey, required this.lastError, + required this.lastErrorCode, + required this.lastErrorDetailCode, required this.lastConnectedAtMs, + required this.deviceId, + required this.authRole, + required this.authScopes, required this.hasSharedAuth, required this.hasDeviceToken, required this.healthPayload, @@ -654,7 +659,12 @@ class GatewayConnectionSnapshot { final String? remoteAddress; final String? mainSessionKey; final String? lastError; + final String? lastErrorCode; + final String? lastErrorDetailCode; final int? lastConnectedAtMs; + final String? deviceId; + final String? authRole; + final List authScopes; final bool hasSharedAuth; final bool hasDeviceToken; final Map? healthPayload; @@ -671,7 +681,12 @@ class GatewayConnectionSnapshot { remoteAddress: null, mainSessionKey: null, lastError: null, + lastErrorCode: null, + lastErrorDetailCode: null, lastConnectedAtMs: null, + deviceId: null, + authRole: null, + authScopes: const [], hasSharedAuth: false, hasDeviceToken: false, healthPayload: null, @@ -687,7 +702,12 @@ class GatewayConnectionSnapshot { String? remoteAddress, String? mainSessionKey, String? lastError, + String? lastErrorCode, + String? lastErrorDetailCode, int? lastConnectedAtMs, + String? deviceId, + String? authRole, + List? authScopes, bool? hasSharedAuth, bool? hasDeviceToken, Map? healthPayload, @@ -696,6 +716,8 @@ class GatewayConnectionSnapshot { bool clearRemoteAddress = false, bool clearMainSessionKey = false, bool clearLastError = false, + bool clearLastErrorCode = false, + bool clearLastErrorDetailCode = false, }) { return GatewayConnectionSnapshot( status: status ?? this.status, @@ -709,13 +731,39 @@ class GatewayConnectionSnapshot { ? null : (mainSessionKey ?? this.mainSessionKey), lastError: clearLastError ? null : (lastError ?? this.lastError), + lastErrorCode: clearLastErrorCode + ? null + : (lastErrorCode ?? this.lastErrorCode), + lastErrorDetailCode: clearLastErrorDetailCode + ? null + : (lastErrorDetailCode ?? this.lastErrorDetailCode), lastConnectedAtMs: lastConnectedAtMs ?? this.lastConnectedAtMs, + deviceId: deviceId ?? this.deviceId, + authRole: authRole ?? this.authRole, + authScopes: authScopes ?? this.authScopes, hasSharedAuth: hasSharedAuth ?? this.hasSharedAuth, hasDeviceToken: hasDeviceToken ?? this.hasDeviceToken, healthPayload: healthPayload ?? this.healthPayload, statusPayload: statusPayload ?? this.statusPayload, ); } + + bool get pairingRequired { + final detailCode = lastErrorDetailCode?.trim().toUpperCase(); + final errorCode = lastErrorCode?.trim().toUpperCase(); + final errorText = lastError?.toLowerCase() ?? ''; + return status != RuntimeConnectionStatus.connected && + (detailCode == 'PAIRING_REQUIRED' || + errorCode == 'NOT_PAIRED' || + errorText.contains('pairing required')); + } + + bool get gatewayTokenMissing { + final detailCode = lastErrorDetailCode?.trim().toUpperCase(); + final errorText = lastError?.toLowerCase() ?? ''; + return detailCode == 'AUTH_TOKEN_MISSING' || + errorText.contains('gateway token missing'); + } } class RuntimePackageInfo { @@ -1019,6 +1067,93 @@ class GatewayCronJobSummary { final String? lastError; } +class GatewayDevicePairingList { + const GatewayDevicePairingList({required this.pending, required this.paired}); + + final List pending; + final List paired; + + const GatewayDevicePairingList.empty() + : pending = const [], + paired = const []; +} + +class GatewayPendingDevice { + const GatewayPendingDevice({ + required this.requestId, + required this.deviceId, + required this.displayName, + required this.role, + required this.scopes, + required this.remoteIp, + required this.isRepair, + required this.requestedAtMs, + }); + + final String requestId; + final String deviceId; + final String? displayName; + final String? role; + final List scopes; + final String? remoteIp; + final bool isRepair; + final int? requestedAtMs; + + String get label { + final display = displayName?.trim() ?? ''; + return display.isEmpty ? deviceId : display; + } +} + +class GatewayPairedDevice { + const GatewayPairedDevice({ + required this.deviceId, + required this.displayName, + required this.roles, + required this.scopes, + required this.remoteIp, + required this.tokens, + required this.createdAtMs, + required this.approvedAtMs, + required this.currentDevice, + }); + + final String deviceId; + final String? displayName; + final List roles; + final List scopes; + final String? remoteIp; + final List tokens; + final int? createdAtMs; + final int? approvedAtMs; + final bool currentDevice; + + String get label { + final display = displayName?.trim() ?? ''; + return display.isEmpty ? deviceId : display; + } +} + +class GatewayDeviceTokenSummary { + const GatewayDeviceTokenSummary({ + required this.role, + required this.scopes, + required this.createdAtMs, + required this.rotatedAtMs, + required this.revokedAtMs, + required this.lastUsedAtMs, + }); + + final String role; + final List scopes; + final int? createdAtMs; + final int? rotatedAtMs; + final int? revokedAtMs; + final int? lastUsedAtMs; + + bool get revoked => revokedAtMs != null; +} + class SecretReferenceEntry { const SecretReferenceEntry({ required this.name, diff --git a/lib/runtime/secure_config_store.dart b/lib/runtime/secure_config_store.dart index de00da49..a6e27209 100644 --- a/lib/runtime/secure_config_store.dart +++ b/lib/runtime/secure_config_store.dart @@ -151,10 +151,25 @@ class SecureConfigStore { await _writeSecure(_deviceTokenKey(deviceId, role), token); } + Future clearDeviceToken({ + required String deviceId, + required String role, + }) async { + await initialize(); + await _deleteSecure(_deviceTokenKey(deviceId, role)); + } + Future> loadSecureRefs() async { await initialize(); final gatewayToken = await loadGatewayToken(); final gatewayPassword = await loadGatewayPassword(); + final deviceIdentity = await loadDeviceIdentity(); + final deviceToken = deviceIdentity == null + ? null + : await loadDeviceToken( + deviceId: deviceIdentity.deviceId, + role: 'operator', + ); final ollamaKey = await loadOllamaCloudApiKey(); final vaultToken = await loadVaultToken(); return { @@ -164,6 +179,9 @@ class SecureConfigStore { ...?gatewayPassword == null ? null : {'gateway_password': gatewayPassword}, + ...?deviceToken == null + ? null + : {'gateway_device_token_operator': deviceToken}, ...?ollamaKey == null ? null : {'ollama_cloud_api_key': ollamaKey}, diff --git a/lib/widgets/gateway_connect_dialog.dart b/lib/widgets/gateway_connect_dialog.dart index 80d35ac8..a2fe135d 100644 --- a/lib/widgets/gateway_connect_dialog.dart +++ b/lib/widgets/gateway_connect_dialog.dart @@ -320,6 +320,25 @@ class _StatusBanner extends StatelessWidget { connection.remoteAddress ?? 'No active gateway target', style: theme.textTheme.bodyMedium, ), + if (connection.pairingRequired) ...[ + const SizedBox(height: 8), + Text( + appText( + '当前设备需要先完成配对审批。请在已授权设备上批准该请求后重试。', + 'This device must be approved first. Approve the pairing request from an authorized device and try again.', + ), + style: theme.textTheme.bodySmall, + ), + ] else if (connection.gatewayTokenMissing) ...[ + const SizedBox(height: 8), + Text( + appText( + '首次连接请提供共享 Token;配对完成后可继续使用本机 device token。', + 'Provide a shared token for the first connection; after pairing, this device can continue with its device token.', + ), + style: theme.textTheme.bodySmall, + ), + ], if ((connection.lastError ?? '').isNotEmpty) ...[ const SizedBox(height: 8), Text(connection.lastError!, style: theme.textTheme.bodySmall), diff --git a/test/features/settings_page_test.dart b/test/features/settings_page_test.dart index 8cc19884..acd828ea 100644 --- a/test/features/settings_page_test.dart +++ b/test/features/settings_page_test.dart @@ -23,4 +23,21 @@ void main() { await tester.pumpAndSettle(); expect(controller.themeMode, ThemeMode.light); }); + + testWidgets('SettingsPage gateway tab exposes device pairing controls', ( + WidgetTester tester, + ) async { + final controller = await createTestController(tester); + + await pumpPage(tester, child: SettingsPage(controller: controller)); + + await tester.tap(find.text('集成')); + await tester.pumpAndSettle(); + + expect(find.text('打开连接面板'), findsOneWidget); + expect( + find.byKey(const ValueKey('gateway-device-security-card')), + findsOneWidget, + ); + }); } diff --git a/test/runtime/gateway_runtime_test.dart b/test/runtime/gateway_runtime_test.dart index b41b4686..8d5bed3b 100644 --- a/test/runtime/gateway_runtime_test.dart +++ b/test/runtime/gateway_runtime_test.dart @@ -11,7 +11,7 @@ import 'package:xworkmate/runtime/secure_config_store.dart'; void main() { test( - 'GatewayRuntime uses explicit auth override for the initial connect handshake', + 'GatewayRuntime uses explicit shared token override for the initial connect handshake', () async { SharedPreferences.setMockInitialValues({}); final store = SecureConfigStore(); @@ -19,33 +19,181 @@ void main() { store: store, identityStore: DeviceIdentityStore(store), ); - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - final handshakeSeen = Completer(); - Map? receivedAuth; + final server = await _FakeGatewayRuntimeServer.start(); + addTearDown(runtime.dispose); + addTearDown(server.close); - unawaited(() async { - await for (final request in server) { - final socket = await WebSocketTransformer.upgrade(request); - socket.add( - jsonEncode({ - 'type': 'event', - 'event': 'connect.challenge', - 'payload': {'nonce': 'nonce-1'}, - }), - ); + await runtime.connectProfile( + GatewayConnectionProfile.defaults().copyWith( + mode: RuntimeConnectionMode.local, + host: '127.0.0.1', + port: server.port, + tls: false, + useSetupCode: false, + ), + authTokenOverride: 'shared-token-from-form', + ); - await for (final raw in socket) { - final frame = jsonDecode(raw as String) as Map; - if (frame['type'] != 'req' || frame['method'] != 'connect') { - continue; - } - receivedAuth = - (frame['params'] as Map)['auth'] - as Map?; + expect(server.connectAuth?['token'], 'shared-token-from-form'); + expect(server.connectAuth?['deviceToken'], isNull); + expect(runtime.snapshot.status, RuntimeConnectionStatus.connected); + }, + ); + + test( + 'GatewayRuntime sends stored operator device token using auth.deviceToken', + () async { + SharedPreferences.setMockInitialValues({}); + final store = SecureConfigStore(); + final identityStore = DeviceIdentityStore(store); + final identity = await identityStore.loadOrCreate(); + await store.saveDeviceToken( + deviceId: identity.deviceId, + role: 'operator', + token: 'stored-device-token', + ); + final runtime = GatewayRuntime( + store: store, + identityStore: identityStore, + ); + final server = await _FakeGatewayRuntimeServer.start(); + addTearDown(runtime.dispose); + addTearDown(server.close); + + await runtime.connectProfile( + GatewayConnectionProfile.defaults().copyWith( + mode: RuntimeConnectionMode.local, + host: '127.0.0.1', + port: server.port, + tls: false, + useSetupCode: false, + ), + ); + + expect(server.connectAuth?['token'], 'stored-device-token'); + expect(server.connectAuth?['deviceToken'], 'stored-device-token'); + expect(runtime.snapshot.hasDeviceToken, isTrue); + expect(runtime.snapshot.deviceId, identity.deviceId); + }, + ); + + test( + 'GatewayRuntime parses device pairing state and syncs rotated local role tokens', + () async { + SharedPreferences.setMockInitialValues({}); + final store = SecureConfigStore(); + final identityStore = DeviceIdentityStore(store); + final identity = await identityStore.loadOrCreate(); + final runtime = GatewayRuntime( + store: store, + identityStore: identityStore, + ); + final server = await _FakeGatewayRuntimeServer.start( + currentDeviceId: identity.deviceId, + ); + addTearDown(runtime.dispose); + addTearDown(server.close); + + await runtime.connectProfile( + GatewayConnectionProfile.defaults().copyWith( + mode: RuntimeConnectionMode.local, + host: '127.0.0.1', + port: server.port, + tls: false, + useSetupCode: false, + ), + authTokenOverride: 'shared-token-from-form', + ); + + final devices = await runtime.listDevicePairing(); + expect(devices.pending.single.requestId, 'req-1'); + expect(devices.paired.single.currentDevice, isTrue); + expect(devices.paired.single.tokens.single.role, 'operator'); + + final rotated = await runtime.rotateDeviceToken( + deviceId: identity.deviceId, + role: 'operator', + scopes: const ['operator.admin', 'operator.pairing'], + ); + expect(rotated, 'rotated-local-device-token'); + expect( + await store.loadDeviceToken( + deviceId: identity.deviceId, + role: 'operator', + ), + 'rotated-local-device-token', + ); + + await runtime.revokeDeviceToken( + deviceId: identity.deviceId, + role: 'operator', + ); + expect( + await store.loadDeviceToken( + deviceId: identity.deviceId, + role: 'operator', + ), + isNull, + ); + }, + ); +} + +class _FakeGatewayRuntimeServer { + _FakeGatewayRuntimeServer._(this._server, {required this.currentDeviceId}); + + final HttpServer _server; + final String? currentDeviceId; + Map? connectAuth; + + int get port => _server.port; + + static Future<_FakeGatewayRuntimeServer> start({ + String? currentDeviceId, + }) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final fake = _FakeGatewayRuntimeServer._( + server, + currentDeviceId: currentDeviceId, + ); + unawaited(fake._serve()); + return fake; + } + + Future close() async { + await _server.close(force: true); + } + + Future _serve() async { + await for (final request in _server) { + final socket = await WebSocketTransformer.upgrade(request); + socket.add( + jsonEncode({ + 'type': 'event', + 'event': 'connect.challenge', + 'payload': {'nonce': 'nonce-1'}, + }), + ); + + await for (final raw in socket) { + final frame = jsonDecode(raw as String) as Map; + if (frame['type'] != 'req') { + continue; + } + final method = frame['method'] as String? ?? ''; + final id = frame['id'] as String? ?? 'req-id'; + final params = + (frame['params'] as Map?)?.cast() ?? + const {}; + switch (method) { + case 'connect': + connectAuth = + (params['auth'] as Map?)?.cast() ?? + const {}; socket.add( jsonEncode({ 'type': 'res', - 'id': frame['id'], + 'id': id, 'ok': true, 'payload': { 'server': {'host': '127.0.0.1'}, @@ -54,37 +202,100 @@ void main() { 'mainSessionKey': 'main', }, }, + 'auth': { + 'role': 'operator', + 'scopes': const [ + 'operator.admin', + 'operator.pairing', + ], + }, }, }), ); - if (!handshakeSeen.isCompleted) { - handshakeSeen.complete(); - } break; - } + case 'device.pair.list': + socket.add( + jsonEncode({ + 'type': 'res', + 'id': id, + 'ok': true, + 'payload': { + 'pending': >[ + { + 'requestId': 'req-1', + 'deviceId': 'device-pending', + 'displayName': 'Pending Device', + 'role': 'operator', + 'scopes': const ['operator.read'], + 'remoteIp': '10.0.0.8', + 'ts': 1700000000000, + }, + ], + 'paired': >[ + { + 'deviceId': currentDeviceId ?? 'device-current', + 'displayName': 'Current Device', + 'roles': const ['operator'], + 'scopes': const [ + 'operator.admin', + 'operator.pairing', + ], + 'tokens': >[ + { + 'role': 'operator', + 'scopes': const [ + 'operator.admin', + 'operator.pairing', + ], + 'createdAtMs': 1700000001000, + }, + ], + }, + ], + }, + }), + ); + break; + case 'device.token.rotate': + socket.add( + jsonEncode({ + 'type': 'res', + 'id': id, + 'ok': true, + 'payload': { + 'deviceId': params['deviceId'], + 'role': params['role'], + 'token': 'rotated-local-device-token', + 'scopes': params['scopes'] ?? const [], + }, + }), + ); + break; + case 'device.token.revoke': + socket.add( + jsonEncode({ + 'type': 'res', + 'id': id, + 'ok': true, + 'payload': { + 'deviceId': params['deviceId'], + 'role': params['role'], + }, + }), + ); + break; + default: + socket.add( + jsonEncode({ + 'type': 'res', + 'id': id, + 'ok': true, + 'payload': const {}, + }), + ); + break; } - }()); - - final profile = GatewayConnectionProfile.defaults().copyWith( - mode: RuntimeConnectionMode.local, - host: '127.0.0.1', - port: server.port, - tls: false, - useSetupCode: false, - ); - - await runtime.connectProfile( - profile, - authTokenOverride: 'shared-token-from-form', - ); - await handshakeSeen.future.timeout(const Duration(seconds: 2)); - - expect(receivedAuth?['token'], 'shared-token-from-form'); - expect(runtime.snapshot.status, RuntimeConnectionStatus.connected); - - await runtime.disconnect(); - runtime.dispose(); - await server.close(force: true); - }, - ); + } + } + } }