fix: stabilize remote gateway pairing identity

This commit is contained in:
Haitao Pan 2026-03-12 09:55:17 +08:00
parent 8fef5986e4
commit 1eb48e5547
10 changed files with 585 additions and 40 deletions

View File

@ -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.<deviceId>.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.

View File

@ -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<String> 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);
}

View File

@ -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<String, dynamic> 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<String, dynamic> toJson() {
return <String, dynamic>{
'deviceId': deviceId,
'publicKeyBase64Url': publicKeyBase64Url,
'privateKeyBase64Url': privateKeyBase64Url,
'createdAtMs': createdAtMs,
};
}
factory LocalDeviceIdentity.fromJson(Map<String, dynamic> 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,
);
}
}

View File

@ -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<String?> 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<String, Object?> _memoryPrefs = <String, Object?>{};
final Map<String, String> _memorySecure = <String, String>{};
final Future<String?> Function()? _fallbackDirectoryPathResolver;
bool _initialized = false;
Future<void> 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<String?> 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<void> 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<void> clearDeviceToken({
@ -159,6 +190,7 @@ class SecureConfigStore {
}) async {
await initialize();
await _deleteSecure(_deviceTokenKey(deviceId, role));
await _deleteDeviceTokenFallback(deviceId: deviceId, role: role);
}
Future<Map<String, String>> 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<Directory?> _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<String?> _defaultFallbackDirectoryPath() async {
try {
final supportDirectory = await getApplicationSupportDirectory();
return '${supportDirectory.path}/xworkmate/gateway-auth';
} catch (_) {
return null;
}
}
Future<File?> _deviceIdentityFallbackFile() async {
final directory = await _resolveFallbackDirectory();
if (directory == null) {
return null;
}
return File('${directory.path}/$_deviceIdentityFallbackFileName');
}
Future<File?> _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<LocalDeviceIdentity?> _loadDeviceIdentityFallback() async {
try {
final file = await _deviceIdentityFallbackFile();
if (file == null || !await file.exists()) {
return null;
}
final decoded =
jsonDecode(await file.readAsString()) as Map<String, dynamic>;
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<void> _saveDeviceIdentityFallback(LocalDeviceIdentity identity) async {
try {
final file = await _deviceIdentityFallbackFile();
if (file == null) {
return;
}
await file.writeAsString(jsonEncode(identity.toJson()), flush: true);
} catch (_) {
return;
}
}
Future<String?> _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<void> _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<void> _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;
}
}
}

View File

@ -68,10 +68,6 @@ class _GatewayConnectDialogState extends State<GatewayConnectDialog> {
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<GatewayConnectDialog> {
),
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<void> 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(

View File

@ -429,7 +429,7 @@ packages:
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"

View File

@ -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

View File

@ -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(<String, dynamic>{
'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));
});
}

View File

@ -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(<String, Object>{});
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');
},
);
}

View File

@ -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);
},
);
}