diff --git a/docs/runbooks/gateway-dev-runbook.md b/docs/runbooks/gateway-dev-runbook.md new file mode 100644 index 00000000..c16bdc9d --- /dev/null +++ b/docs/runbooks/gateway-dev-runbook.md @@ -0,0 +1,226 @@ +# Gateway Dev Runbook + +This runbook covers the `XWorkmate.svc.plus` client when it connects directly to an OpenClaw gateway for local and remote development, pairing approval, and release verification. + +## Scope + +- UI repo: `/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus` +- Gateway repo: `/Users/shenlan/workspaces/cloud-neutral-toolkit/openclaw.svc.plus` +- macOS reference implementation: + - `/Users/shenlan/workspaces/cloud-neutral-toolkit/openclaw.svc.plus/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift` + - `/Users/shenlan/workspaces/cloud-neutral-toolkit/openclaw.svc.plus/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift` + - `/Users/shenlan/workspaces/cloud-neutral-toolkit/openclaw.svc.plus/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift` + +## Security Boundary + +- `.env` is development prefill only. It must not become the persisted source of truth and must not auto-connect the gateway. +- Shared tokens and passwords are user-entered auth inputs. Never hardcode them in Dart, native code, tests, or scripts. +- Long-lived secrets belong in secure storage. XWorkmate also keeps a file-backed fallback for device identity and operator device token so release builds keep a stable paired identity. +- Local mode may use plain `ws://127.0.0.1:18789`. +- Remote mode must use TLS, for example `wss://openclaw.svc.plus:443`. + +## Endpoint Matrix + +- XWorkmate direct local gateway auth: + - `ws://127.0.0.1:18789` +- XWorkmate direct remote gateway auth: + - `wss://openclaw.svc.plus:443` +- OpenClaw operator control page for pairing approval: + - [https://openclaw.svc.plus/nodes](https://openclaw.svc.plus/nodes) +- Local web console style endpoint: + - `http://127.0.0.1:18789` + +Do not enter `http://` or `https://` into the XWorkmate gateway dialog unless the code explicitly expects a browser console URL. The app-level gateway connection is `ws://` or `wss://`. + +## Config Sources + +- Development prefill file: + - `/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/.env` +- Persisted settings snapshot: + - `~/Library/Containers/plus.svc.xworkmate/Data/Library/Preferences/plus.svc.xworkmate.plist` + - key: `flutter.xworkmate.settings.snapshot` +- File-backed stable device identity fallback: + - `~/Library/Containers/plus.svc.xworkmate/Data/Library/Application Support/plus.svc.xworkmate/xworkmate/gateway-auth/gateway-device-identity.json` +- File-backed operator device token fallback: + - `~/Library/Containers/plus.svc.xworkmate/Data/Library/Application Support/plus.svc.xworkmate/xworkmate/gateway-auth/gateway-device-token..operator.txt` + +## Expected Remote Pairing Flow + +1. Open `设置 -> 集成 -> Gateway`. +2. Choose `远程`. +3. Enter host `openclaw.svc.plus`, port `443`, TLS on. +4. Enter a valid shared token. +5. Click `连接`. +6. First successful auth should return `NOT_PAIRED: pairing required`. +7. Open [https://openclaw.svc.plus/nodes](https://openclaw.svc.plus/nodes) and approve the pending `XWorkmate Mac` device. +8. Return to XWorkmate and reconnect. +9. The second connect should succeed and the gateway should return an operator `deviceToken`. +10. Later reconnects should reuse the same `deviceId` and move to `device-token` auth instead of creating a fresh pairing request. + +## Root Cause Analysis: Repeating `pairing required` + +### Symptom + +- XWorkmate could connect locally and chat normally. +- Remote shared-token auth reached the gateway, but remote connect repeatedly ended with `NOT_PAIRED: pairing required`. +- The operator page showed one `Pending` `XWorkmate Mac` entry and one older `Paired` `XWorkmate Mac` entry at the same time. + +### Evidence Pattern + +- `Pending.deviceId != Paired.deviceId` +- Reconnecting from the same installed app generated a fresh pending request instead of reusing the already paired device. +- This proves the failure was not “approval missing” alone. The client was presenting a different device identity on later remote connects. + +### Why This Happened + +OpenClaw pairing is keyed to the device identity: + +- `device.id` +- `device.publicKey` +- signed device-auth payload +- pinned client metadata such as platform and device family + +If the client does not persist and reload the same identity, the gateway must treat the connect as a new device and request pairing again. + +The problematic path in XWorkmate was: + +1. Remote connect created or loaded a device identity. +2. The identity and operator device token relied on secure storage only. +3. In the installed app path, that persistence was not stable enough for repeated remote reconnect debugging. +4. The next remote connect surfaced a different `deviceId`, so the gateway created another pending pairing request. + +### Fix Strategy + +Align XWorkmate with the OpenClaw macOS reference: + +- Keep shared token and password in secure storage. +- Keep a stable file-backed fallback for: + - device identity + - operator device token +- Prefer secure storage on read, but hydrate from the fallback file when secure storage does not produce the identity/token. +- Show the current `deviceId` in the pairing-required UI so the operator can match it against the control page immediately. + +### Code Locations + +- XWorkmate stable device identity and device token fallback: + - `/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/lib/runtime/secure_config_store.dart` +- XWorkmate local device identity model: + - `/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/lib/runtime/runtime_models.dart` +- XWorkmate pairing diagnostics banner: + - `/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/lib/widgets/gateway_connect_dialog.dart` +- OpenClaw macOS reference: + - `/Users/shenlan/workspaces/cloud-neutral-toolkit/openclaw.svc.plus/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift` + - `/Users/shenlan/workspaces/cloud-neutral-toolkit/openclaw.svc.plus/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift` + +### Fix Validation + +After the fix: + +- The app keeps a stable local `deviceId`. +- The first remote shared-token connect may still need one approval if the previously paired record belongs to an older rotating identity. +- After that approval, reconnect must reuse the same `deviceId`. +- The operator page should stop accumulating a new `Pending` request for each reconnect. + +## Pairing Loop Diagnosis + +The critical check is whether `Pending` and `Paired` show the same `deviceId`. + +- Healthy: + - `Pending` appears once on first shared-token connect. + - After approval, reconnect succeeds. + - The same `deviceId` becomes `Paired`. +- Broken: + - `Pending` keeps showing a new `deviceId`. + - `Paired` already contains an older `deviceId`. + - XWorkmate reconnects as a different device each time and loops on `pairing required`. + +### Fast Diagnosis Steps + +1. In XWorkmate, open the gateway dialog and read the error banner. +2. Note the `当前设备 ID` shown under pairing-required guidance. +3. In [https://openclaw.svc.plus/nodes](https://openclaw.svc.plus/nodes), compare that ID against: + - `Pending` + - `Paired` +4. If the IDs differ, the client is not reusing a stable local identity. + +### Local Reset For A Broken Pairing Loop + +Run these steps only when the device ID keeps changing or the stored operator token is clearly stale: + +```bash +rm -f "$HOME/Library/Containers/plus.svc.xworkmate/Data/Library/Application Support/plus.svc.xworkmate/xworkmate/gateway-auth/gateway-device-identity.json" +rm -f "$HOME/Library/Containers/plus.svc.xworkmate/Data/Library/Application Support/plus.svc.xworkmate/xworkmate/gateway-auth/gateway-device-token."*.operator.txt +``` + +Then: + +1. Remove stale paired `XWorkmate Mac` entries from the operator page if they are no longer valid. +2. Reopen XWorkmate. +3. Connect with the shared token once. +4. Approve the single new pending request. +5. Reconnect and verify the same `deviceId` now appears in `Paired`. + +## Common Error Meanings + +- `AUTH_TOKEN_MISSING` + - The active handshake did not carry a shared token or device token. + - Check the current form input first, then stored secure refs. +- `CONNECT_CHALLENGE_TIMEOUT` + - Usually an invalid `ws/wss` endpoint, reverse proxy mismatch, or malformed stored host value. + - Confirm the final gateway target is `openclaw.svc.plus:443` for remote mode. +- `PAIRING_REQUIRED` + - Shared token auth succeeded, but the current device is not yet paired, or the gateway is treating the connect as a metadata or scope upgrade. +- `AUTH_DEVICE_TOKEN_MISMATCH` + - Local operator device token is stale or revoked. + - Clear the stored device token and reconnect once with the shared token. + +## Runtime Debugging + +- XWorkmate UI: + - `设置 -> 运行日志` + - Check `connect`, `auth`, `socket`, and `pairing` entries. +- macOS preferences snapshot: + - `defaults read "$HOME/Library/Containers/plus.svc.xworkmate/Data/Library/Preferences/plus.svc.xworkmate.plist"` +- OpenClaw operator state: + - [https://openclaw.svc.plus/nodes](https://openclaw.svc.plus/nodes) +- OpenClaw CLI on gateway host: + - `openclaw devices list` + +Do not paste real tokens into issues, commits, or logs. + +## Development Validation + +Baseline checks: + +```bash +flutter analyze +flutter test +``` + +macOS integration tests must run serially: + +```bash +pkill -f '/build/macos/Build/Products/Debug/XWorkmate.app/Contents/MacOS/XWorkmate' || true +flutter test integration_test/desktop_navigation_flow_test.dart -d macos +pkill -f '/build/macos/Build/Products/Debug/XWorkmate.app/Contents/MacOS/XWorkmate' || true +flutter test integration_test/desktop_settings_flow_test.dart -d macos +``` + +Build and install: + +```bash +flutter build macos +flutter build ios --simulator +make install-mac +``` + +If a device-run test hangs instead of failing with an assertion, record it as manual follow-up. + +## Manual Acceptance + +1. Verify local mode can connect and chat through `ws://127.0.0.1:18789`. +2. Verify remote mode can connect through `wss://openclaw.svc.plus:443`. +3. Verify first remote connect creates one pending pairing request. +4. Approve that request from [https://openclaw.svc.plus/nodes](https://openclaw.svc.plus/nodes). +5. Reconnect and verify the same `deviceId` is now listed under `Paired`. +6. Restart the app and verify remote reconnect does not create a fresh pending request. diff --git a/lib/runtime/gateway_runtime.dart b/lib/runtime/gateway_runtime.dart index ff4c97bf..af78576d 100644 --- a/lib/runtime/gateway_runtime.dart +++ b/lib/runtime/gateway_runtime.dart @@ -1059,7 +1059,12 @@ class GatewayRuntime extends ChangeNotifier { if (host.isEmpty) { return null; } - return (host, profile.port, profile.tls); + final normalized = parseGatewayEndpoint( + host.contains('://') + ? host + : _composeManualUrl(host, profile.port, profile.tls), + ); + return normalized ?? (host, profile.port, profile.tls); } void _handleIncoming(dynamic raw, Completer challenge) { @@ -1477,7 +1482,7 @@ String _resolveSetupCodeCandidate(String raw) { final parsedPort = uri?.port; final port = parsedPort != null && parsedPort >= 1 && parsedPort <= 65535 ? parsedPort - : 18789; + : (tls ? 443 : 18789); return (host, port, tls); } diff --git a/lib/runtime/runtime_models.dart b/lib/runtime/runtime_models.dart index 10279796..d0843baf 100644 --- a/lib/runtime/runtime_models.dart +++ b/lib/runtime/runtime_models.dart @@ -112,13 +112,18 @@ class GatewayConnectionProfile { bool? tls, String? selectedAgentId, }) { + final normalized = _normalizeGatewayManualEndpoint( + host: host ?? this.host, + port: port ?? this.port, + tls: tls ?? this.tls, + ); return GatewayConnectionProfile( mode: mode ?? this.mode, useSetupCode: useSetupCode ?? this.useSetupCode, setupCode: setupCode ?? this.setupCode, - host: host ?? this.host, - port: port ?? this.port, - tls: tls ?? this.tls, + host: normalized.host, + port: normalized.port, + tls: normalized.tls, selectedAgentId: selectedAgentId ?? this.selectedAgentId, ); } @@ -136,18 +141,58 @@ class GatewayConnectionProfile { } factory GatewayConnectionProfile.fromJson(Map json) { + final defaults = GatewayConnectionProfile.defaults(); + final normalized = _normalizeGatewayManualEndpoint( + host: json['host'] as String? ?? defaults.host, + port: json['port'] as int? ?? defaults.port, + tls: json['tls'] as bool? ?? defaults.tls, + ); return GatewayConnectionProfile( mode: RuntimeConnectionModeCopy.fromJsonValue(json['mode'] as String?), useSetupCode: json['useSetupCode'] as bool? ?? false, setupCode: json['setupCode'] as String? ?? '', - host: json['host'] as String? ?? GatewayConnectionProfile.defaults().host, - port: json['port'] as int? ?? GatewayConnectionProfile.defaults().port, - tls: json['tls'] as bool? ?? true, + host: normalized.host, + port: normalized.port, + tls: normalized.tls, selectedAgentId: json['selectedAgentId'] as String? ?? '', ); } } +({String host, int port, bool tls}) _normalizeGatewayManualEndpoint({ + required String host, + required int port, + required bool tls, +}) { + final trimmedHost = host.trim(); + if (trimmedHost.isEmpty) { + return (host: trimmedHost, port: port, tls: tls); + } + final normalizedInput = trimmedHost.contains('://') + ? trimmedHost + : '${tls ? 'https' : 'http'}://$trimmedHost:${port > 0 ? port : (tls ? 443 : 18789)}'; + final uri = Uri.tryParse(normalizedInput); + final normalizedHost = uri?.host.trim() ?? trimmedHost; + if (normalizedHost.isEmpty) { + return (host: trimmedHost, port: port, tls: tls); + } + final scheme = uri?.scheme.trim().toLowerCase() ?? (tls ? 'https' : 'http'); + final normalizedTls = switch (scheme) { + 'ws' || 'http' => false, + _ => true, + }; + final normalizedPort = uri?.hasPort == true + ? uri!.port + : normalizedTls + ? 443 + : 18789; + return ( + host: normalizedHost, + port: normalizedPort > 0 ? normalizedPort : port, + tls: normalizedTls, + ); +} + class OllamaLocalConfig { const OllamaLocalConfig({ required this.endpoint, @@ -1293,4 +1338,22 @@ class LocalDeviceIdentity { final String publicKeyBase64Url; final String privateKeyBase64Url; final int createdAtMs; + + Map toJson() { + return { + 'deviceId': deviceId, + 'publicKeyBase64Url': publicKeyBase64Url, + 'privateKeyBase64Url': privateKeyBase64Url, + 'createdAtMs': createdAtMs, + }; + } + + factory LocalDeviceIdentity.fromJson(Map json) { + return LocalDeviceIdentity( + deviceId: json['deviceId'] as String? ?? '', + publicKeyBase64Url: json['publicKeyBase64Url'] as String? ?? '', + privateKeyBase64Url: json['privateKeyBase64Url'] as String? ?? '', + createdAtMs: (json['createdAtMs'] as num?)?.toInt() ?? 0, + ); + } } diff --git a/lib/runtime/secure_config_store.dart b/lib/runtime/secure_config_store.dart index cd758e21..8e1cc47b 100644 --- a/lib/runtime/secure_config_store.dart +++ b/lib/runtime/secure_config_store.dart @@ -1,12 +1,15 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'runtime_models.dart'; class SecureConfigStore { - SecureConfigStore(); + SecureConfigStore({Future Function()? fallbackDirectoryPathResolver}) + : _fallbackDirectoryPathResolver = fallbackDirectoryPathResolver; static const _settingsKey = 'xworkmate.settings.snapshot'; static const _auditKey = 'xworkmate.secrets.audit'; @@ -18,6 +21,7 @@ class SecureConfigStore { 'xworkmate.gateway.device.public_key'; static const _gatewayDevicePrivateKeyKey = 'xworkmate.gateway.device.private_key'; + static const _deviceIdentityFallbackFileName = 'gateway-device-identity.json'; static const _ollamaCloudApiKeyKey = 'xworkmate.ollama.cloud.api_key'; static const _vaultTokenKey = 'xworkmate.vault.token'; @@ -25,6 +29,7 @@ class SecureConfigStore { FlutterSecureStorage? _secureStorage; final Map _memoryPrefs = {}; final Map _memorySecure = {}; + final Future Function()? _fallbackDirectoryPathResolver; bool _initialized = false; Future initialize() async { @@ -116,7 +121,11 @@ class SecureConfigStore { final publicKey = await _readSecure(_gatewayDevicePublicKeyKey); final privateKey = await _readSecure(_gatewayDevicePrivateKeyKey); if (deviceId == null || publicKey == null || privateKey == null) { - return null; + final fallbackIdentity = await _loadDeviceIdentityFallback(); + if (fallbackIdentity != null) { + await saveDeviceIdentity(fallbackIdentity); + } + return fallbackIdentity; } return LocalDeviceIdentity( deviceId: deviceId, @@ -134,6 +143,7 @@ class SecureConfigStore { _gatewayDevicePrivateKeyKey, identity.privateKeyBase64Url, ); + await _saveDeviceIdentityFallback(identity); } Future loadDeviceToken({ @@ -141,7 +151,23 @@ class SecureConfigStore { required String role, }) async { await initialize(); - return _readSecure(_deviceTokenKey(deviceId, role)); + final secureValue = await _readSecure(_deviceTokenKey(deviceId, role)); + if (secureValue != null && secureValue.trim().isNotEmpty) { + return secureValue; + } + final fallbackValue = await _loadDeviceTokenFallback( + deviceId: deviceId, + role: role, + ); + if (fallbackValue != null && fallbackValue.trim().isNotEmpty) { + await saveDeviceToken( + deviceId: deviceId, + role: role, + token: fallbackValue, + ); + return fallbackValue; + } + return null; } Future saveDeviceToken({ @@ -151,6 +177,11 @@ class SecureConfigStore { }) async { await initialize(); await _writeSecure(_deviceTokenKey(deviceId, role), token); + await _saveDeviceTokenFallback( + deviceId: deviceId, + role: role, + token: token, + ); } Future clearDeviceToken({ @@ -159,6 +190,7 @@ class SecureConfigStore { }) async { await initialize(); await _deleteSecure(_deviceTokenKey(deviceId, role)); + await _deleteDeviceTokenFallback(deviceId: deviceId, role: role); } Future> loadSecureRefs() async { @@ -258,4 +290,146 @@ class SecureConfigStore { final safeRole = role.trim().isEmpty ? 'operator' : role.trim(); return 'xworkmate.gateway.device_token.$deviceId.$safeRole'; } + + static String _deviceTokenFallbackFileName(String deviceId, String role) { + final safeRole = role.trim().isEmpty ? 'operator' : role.trim(); + return 'gateway-device-token.$deviceId.$safeRole.txt'; + } + + Future _resolveFallbackDirectory() async { + try { + final resolvedPath = + await _fallbackDirectoryPathResolver?.call() ?? + await _defaultFallbackDirectoryPath(); + final trimmed = resolvedPath?.trim() ?? ''; + if (trimmed.isEmpty) { + return null; + } + final directory = Directory(trimmed); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + return directory; + } catch (_) { + return null; + } + } + + Future _defaultFallbackDirectoryPath() async { + try { + final supportDirectory = await getApplicationSupportDirectory(); + return '${supportDirectory.path}/xworkmate/gateway-auth'; + } catch (_) { + return null; + } + } + + Future _deviceIdentityFallbackFile() async { + final directory = await _resolveFallbackDirectory(); + if (directory == null) { + return null; + } + return File('${directory.path}/$_deviceIdentityFallbackFileName'); + } + + Future _deviceTokenFallbackFile({ + required String deviceId, + required String role, + }) async { + final directory = await _resolveFallbackDirectory(); + if (directory == null) { + return null; + } + return File( + '${directory.path}/${_deviceTokenFallbackFileName(deviceId, role)}', + ); + } + + Future _loadDeviceIdentityFallback() async { + try { + final file = await _deviceIdentityFallbackFile(); + if (file == null || !await file.exists()) { + return null; + } + final decoded = + jsonDecode(await file.readAsString()) as Map; + final identity = LocalDeviceIdentity.fromJson(decoded); + if (identity.deviceId.trim().isEmpty || + identity.publicKeyBase64Url.trim().isEmpty || + identity.privateKeyBase64Url.trim().isEmpty) { + return null; + } + return identity; + } catch (_) { + return null; + } + } + + Future _saveDeviceIdentityFallback(LocalDeviceIdentity identity) async { + try { + final file = await _deviceIdentityFallbackFile(); + if (file == null) { + return; + } + await file.writeAsString(jsonEncode(identity.toJson()), flush: true); + } catch (_) { + return; + } + } + + Future _loadDeviceTokenFallback({ + required String deviceId, + required String role, + }) async { + try { + final file = await _deviceTokenFallbackFile( + deviceId: deviceId, + role: role, + ); + if (file == null || !await file.exists()) { + return null; + } + final value = (await file.readAsString()).trim(); + return value.isEmpty ? null : value; + } catch (_) { + return null; + } + } + + Future _saveDeviceTokenFallback({ + required String deviceId, + required String role, + required String token, + }) async { + try { + final file = await _deviceTokenFallbackFile( + deviceId: deviceId, + role: role, + ); + if (file == null) { + return; + } + await file.writeAsString(token, flush: true); + } catch (_) { + return; + } + } + + Future _deleteDeviceTokenFallback({ + required String deviceId, + required String role, + }) async { + try { + final file = await _deviceTokenFallbackFile( + deviceId: deviceId, + role: role, + ); + if (file == null || !await file.exists()) { + return; + } + await file.delete(); + } catch (_) { + return; + } + } } diff --git a/lib/widgets/gateway_connect_dialog.dart b/lib/widgets/gateway_connect_dialog.dart index 6528452c..07378063 100644 --- a/lib/widgets/gateway_connect_dialog.dart +++ b/lib/widgets/gateway_connect_dialog.dart @@ -68,10 +68,6 @@ class _GatewayConnectDialogState extends State { final typedGatewayToken = _tokenController.text.trim(); final willUseStoredGatewayToken = typedGatewayToken.isEmpty && hasStoredGatewayToken; - final willUseBootstrapToken = - typedGatewayToken.isEmpty && - !hasStoredGatewayToken && - _bootstrapToken.isNotEmpty; final body = SingleChildScrollView( padding: const EdgeInsets.all(24), child: Column( @@ -205,18 +201,12 @@ class _GatewayConnectDialogState extends State { ), onChanged: (_) => setState(() {}), ), - if (willUseStoredGatewayToken || - willUseBootstrapToken || - typedGatewayToken.isNotEmpty) ...[ + if (willUseStoredGatewayToken || typedGatewayToken.isNotEmpty) ...[ const SizedBox(height: 8), _SharedTokenStatusCard( hasStoredGatewayToken: hasStoredGatewayToken, storedGatewayTokenMask: storedGatewayTokenMask, willUseStoredGatewayToken: willUseStoredGatewayToken, - willUseBootstrapToken: willUseBootstrapToken, - bootstrapTokenMask: _bootstrapToken.isEmpty - ? null - : _maskValue(_bootstrapToken), overridingStoredToken: hasStoredGatewayToken && typedGatewayToken.isNotEmpty, onClearStoredToken: hasStoredGatewayToken @@ -353,8 +343,6 @@ class _SharedTokenStatusCard extends StatelessWidget { required this.hasStoredGatewayToken, required this.storedGatewayTokenMask, required this.willUseStoredGatewayToken, - required this.willUseBootstrapToken, - required this.bootstrapTokenMask, required this.overridingStoredToken, this.onClearStoredToken, }); @@ -362,8 +350,6 @@ class _SharedTokenStatusCard extends StatelessWidget { final bool hasStoredGatewayToken; final String? storedGatewayTokenMask; final bool willUseStoredGatewayToken; - final bool willUseBootstrapToken; - final String? bootstrapTokenMask; final bool overridingStoredToken; final Future Function()? onClearStoredToken; @@ -381,8 +367,8 @@ class _SharedTokenStatusCard extends StatelessWidget { 'A shared token is already stored securely ($storedGatewayTokenMask). Leave the field empty to connect with it.', ) : appText( - '将使用开发预填 token($bootstrapTokenMask)连接;点击连接后会写入安全存储。', - 'The connect action will use the bootstrap token ($bootstrapTokenMask) and persist it into secure storage.', + '首次连接需要 shared token;点击连接后会写入安全存储。', + 'The first connection needs a shared token; after connect it will be saved into secure storage.', ); return Container( width: double.infinity, @@ -413,17 +399,6 @@ class _SharedTokenStatusCard extends StatelessWidget { } } -String _maskValue(String value) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - return 'Not set'; - } - if (trimmed.length <= 6) { - return '••••••'; - } - return '${trimmed.substring(0, 3)}••••${trimmed.substring(trimmed.length - 3)}'; -} - class _StatusBanner extends StatelessWidget { const _StatusBanner({required this.controller}); @@ -473,6 +448,16 @@ class _StatusBanner extends StatelessWidget { ), style: theme.textTheme.bodySmall, ), + if ((connection.deviceId ?? '').isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + appText( + '当前设备 ID: ${connection.deviceId}', + 'Current device ID: ${connection.deviceId}', + ), + style: theme.textTheme.bodySmall, + ), + ], ] else if (connection.gatewayTokenMissing) ...[ const SizedBox(height: 8), Text( diff --git a/pubspec.lock b/pubspec.lock index 2c9d1cc1..4e36aa1e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -429,7 +429,7 @@ packages: source: hosted version: "1.9.1" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" diff --git a/pubspec.yaml b/pubspec.yaml index 2bd74d0a..6df0650f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: file_selector: ^1.0.3 flutter_secure_storage: ^9.2.4 package_info_plus: ^8.3.1 + path_provider: ^2.1.5 shared_preferences: ^2.5.3 web_socket_channel: ^3.0.3 yaml: ^3.1.3 diff --git a/test/runtime/gateway_endpoint_normalization_test.dart b/test/runtime/gateway_endpoint_normalization_test.dart new file mode 100644 index 00000000..21fb018a --- /dev/null +++ b/test/runtime/gateway_endpoint_normalization_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/runtime/gateway_runtime.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; + +void main() { + test('GatewayConnectionProfile normalizes a remote wss host value', () { + final profile = GatewayConnectionProfile.fromJson({ + 'mode': 'remote', + 'host': 'wss://openclaw.svc.plus', + 'port': 443, + 'tls': true, + }); + + expect(profile.host, 'openclaw.svc.plus'); + expect(profile.port, 443); + expect(profile.tls, isTrue); + }); + + test('GatewayConnectionProfile normalizes a local ws host value', () { + final profile = GatewayConnectionProfile.defaults().copyWith( + mode: RuntimeConnectionMode.local, + host: 'ws://127.0.0.1', + port: 18789, + tls: false, + ); + + expect(profile.host, '127.0.0.1'); + expect(profile.port, 18789); + expect(profile.tls, isFalse); + }); + + test('parseGatewayEndpoint resolves default ports from ws and wss URLs', () { + expect(parseGatewayEndpoint('wss://openclaw.svc.plus'), ( + 'openclaw.svc.plus', + 443, + true, + )); + expect(parseGatewayEndpoint('ws://127.0.0.1'), ('127.0.0.1', 18789, false)); + }); +} diff --git a/test/runtime/secure_config_store_test.dart b/test/runtime/secure_config_store_test.dart index 2694daa2..3ee9bbbd 100644 --- a/test/runtime/secure_config_store_test.dart +++ b/test/runtime/secure_config_store_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; @@ -57,4 +59,52 @@ void main() { ); }, ); + + test( + 'SecureConfigStore falls back to file-backed device identity and token across instances', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-secure-store-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + + final identity = const LocalDeviceIdentity( + deviceId: 'device-123', + publicKeyBase64Url: 'public-key', + privateKeyBase64Url: 'private-key', + createdAtMs: 1700000000000, + ); + final firstStore = SecureConfigStore( + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + await firstStore.saveDeviceIdentity(identity); + await firstStore.saveDeviceToken( + deviceId: identity.deviceId, + role: 'operator', + token: 'device-token', + ); + + final secondStore = SecureConfigStore( + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + final reloadedIdentity = await secondStore.loadDeviceIdentity(); + final reloadedToken = await secondStore.loadDeviceToken( + deviceId: identity.deviceId, + role: 'operator', + ); + + expect(reloadedIdentity?.deviceId, identity.deviceId); + expect(reloadedIdentity?.publicKeyBase64Url, identity.publicKeyBase64Url); + expect( + reloadedIdentity?.privateKeyBase64Url, + identity.privateKeyBase64Url, + ); + expect(reloadedToken, 'device-token'); + }, + ); } diff --git a/test/widgets/gateway_connect_dialog_test.dart b/test/widgets/gateway_connect_dialog_test.dart index 77aed46d..1bb7f883 100644 --- a/test/widgets/gateway_connect_dialog_test.dart +++ b/test/widgets/gateway_connect_dialog_test.dart @@ -27,6 +27,7 @@ void main() { expect(find.text('共享 Token'), findsOneWidget); expect(find.text('认证诊断'), findsOneWidget); expect(find.textContaining('fields: none'), findsOneWidget); + expect(find.textContaining('开发预填 token'), findsNothing); }, ); }