fix: stabilize remote gateway pairing identity
This commit is contained in:
parent
8fef5986e4
commit
1eb48e5547
226
docs/runbooks/gateway-dev-runbook.md
Normal file
226
docs/runbooks/gateway-dev-runbook.md
Normal 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.
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -429,7 +429,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
|
||||
@ -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
|
||||
|
||||
40
test/runtime/gateway_endpoint_normalization_test.dart
Normal file
40
test/runtime/gateway_endpoint_normalization_test.dart
Normal 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));
|
||||
});
|
||||
}
|
||||
@ -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');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user