refactor: remove stale runtime fallbacks

This commit is contained in:
Cowork 3P 2026-06-04 22:38:09 +08:00
parent 1b3a3b5c4a
commit 60ed369df7
29 changed files with 459 additions and 190 deletions

View File

@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/file_store_support.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';
import '../runtime/gateway_runtime.dart';

View File

@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';
import '../runtime/gateway_runtime.dart';

View File

@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';
import '../runtime/gateway_runtime.dart';

View File

@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';
import '../runtime/gateway_runtime.dart';

View File

@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';
import '../runtime/gateway_runtime.dart';

View File

@ -14,7 +14,6 @@ import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/go_core.dart';
import '../runtime/acp_endpoint_paths.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';

View File

@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';
import '../runtime/gateway_runtime.dart';

View File

@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';
import '../runtime/gateway_runtime.dart';

View File

@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';
import '../runtime/gateway_runtime.dart';

View File

@ -13,7 +13,6 @@ import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';
import '../runtime/gateway_runtime.dart';
@ -97,10 +96,14 @@ extension AppControllerDesktopThreadActions on AppController {
}
try {
await runtimeInternal.health();
} catch (_) {}
} catch (error) {
debugPrint('Gateway health refresh failed: $error');
}
try {
await runtimeInternal.status();
} catch (_) {}
} catch (error) {
debugPrint('Gateway status refresh failed: $error');
}
notifyListeners();
}
@ -465,12 +468,20 @@ extension AppControllerDesktopThreadActions on AppController {
final capturedLocalAttachments = List<CollaborationAttachment>.unmodifiable(
localAttachments,
);
final taskMetadata = Map<String, dynamic>.unmodifiable(dispatch.metadata);
final executionWorkingDirectory = gatewayExecutionWorkingDirectoryInternal(
target: currentTarget,
workingDirectory: workingDirectory,
remoteWorkingDirectoryHint: remoteWorkingDirectoryHint,
);
final taskMetadata = Map<String, dynamic>.unmodifiable(
gatewayTaskMetadataWithArtifactContractInternal(
baseMetadata: dispatch.metadata,
sessionKey: normalizedSessionKey,
localWorkingDirectory: workingDirectory,
executionWorkingDirectory: executionWorkingDirectory,
remoteWorkingDirectoryHint: remoteWorkingDirectoryHint,
),
);
if (usesOpenClawGatewayQueueInternal(currentTarget, provider)) {
await enqueueOpenClawGatewayTurnInternal(
OpenClawGatewayQueuedTurnInternal(
@ -924,12 +935,57 @@ extension AppControllerDesktopThreadActions on AppController {
'7. Files listed in taskInputAttachments already belong to this TaskThread; reuse them from the task context and do not ask the user to upload them again.',
)
..writeln();
if (target.isGateway) {
buffer
..writeln('XWorkmate task artifact contract:')
..writeln(
'- The remote runtime owns final-deliverable detection; do not rely on local task classification.',
)
..writeln(
'- If this request needs files, export the final deliverables through the current XWorkmate task artifact scope before final response.',
)
..writeln(
'- A textual download/path claim is not a deliverable unless the file has been exported into the current task artifact scope.',
)
..writeln(
'- Do not reuse artifacts from previous sessions, previous runs, or global OpenClaw workspaces.',
)
..writeln();
}
buffer
..writeln('User request:')
..write(requestText);
return buffer.toString();
}
Map<String, dynamic> gatewayTaskMetadataWithArtifactContractInternal({
required Map<String, dynamic> baseMetadata,
required String sessionKey,
required String localWorkingDirectory,
required String executionWorkingDirectory,
required String remoteWorkingDirectoryHint,
}) {
final localWorkspace = localWorkingDirectory.trim();
final executionWorkspace = executionWorkingDirectory.trim();
final remoteHint = remoteWorkingDirectoryHint.trim();
return <String, dynamic>{
...baseMetadata,
'xworkmateTaskArtifactContract': <String, dynamic>{
'version': 1,
'sessionKey': sessionKey,
'scopeKind': 'task',
'finalDeliverableDetection': 'remote-runtime',
'requiresExportBeforeFinalResponse': true,
'rejectTextOnlyFileClaims': true,
'currentTaskWorkspace': executionWorkspace.isNotEmpty
? executionWorkspace
: (remoteHint.isNotEmpty ? remoteHint : localWorkspace),
if (localWorkspace.isNotEmpty) 'localWorkspace': localWorkspace,
if (remoteHint.isNotEmpty) 'remoteWorkspaceHint': remoteHint,
},
};
}
bool usesOpenClawGatewayQueueInternal(
AssistantExecutionTarget target,
SingleAgentProvider provider,

View File

@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';
import '../runtime/gateway_runtime.dart';

View File

@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';
import '../runtime/gateway_runtime.dart';

View File

@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';
import '../runtime/gateway_runtime.dart';
@ -66,7 +65,8 @@ Future<void> openOnlineWorkspaceThreadSessionInternal(
if (Platform.isLinux) {
await Process.run('xdg-open', [url]);
}
} catch (_) {
} catch (error) {
debugPrint('Open collaboration URL failed: $error');
// Best effort only. Do not surface a blocking error from a convenience link.
}
}

View File

@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';
import '../runtime/gateway_runtime.dart';

View File

@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';
import '../runtime/gateway_runtime.dart';

View File

@ -156,7 +156,8 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
selectedSkillLabels: selectedSkillLabels,
);
clearComposerDraftForSessionInternal(submittedSessionKey);
} catch (_) {
} catch (error) {
debugPrint('Assistant task submission failed: $error');
if (!mounted) {
rethrow;
}

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import '../../app/app_controller.dart';
@ -30,6 +31,22 @@ Map<String, Object?> desktopOfferParams({
};
}
Future<MediaStream?> desktopRemoteVideoStreamForTrack(
RTCTrackEvent event, {
required Future<MediaStream> Function(String label) createFallbackStream,
}) async {
if (event.track.kind != 'video') {
return null;
}
if (event.streams.isNotEmpty) {
return event.streams.first;
}
final stream = await createFallbackStream('xworkmate-remote-desktop');
await stream.addTrack(event.track, addToNative: false);
return stream;
}
class DesktopClient {
DesktopClient({required this.controller, required this.sessionId});
@ -38,7 +55,6 @@ class DesktopClient {
RTCPeerConnection? _peerConnection;
RTCDataChannel? _dataChannel;
MediaStream? _remoteStream;
final StreamController<MediaStream> _streamController =
StreamController<MediaStream>.broadcast();
@ -76,14 +92,18 @@ class DesktopClient {
_peerConnection = await createPeerConnection(config);
// Listen for remote streams
// Listen for remote video tracks. Some Unified Plan servers send
// streamless tracks, so synthesize a stream for RTCVideoView when needed.
_peerConnection!.onTrack = (event) {
if (event.track.kind == 'video') {
if (event.streams.isNotEmpty) {
_remoteStream = event.streams.first;
_streamController.add(_remoteStream!);
unawaited(() async {
final stream = await desktopRemoteVideoStreamForTrack(
event,
createFallbackStream: createLocalMediaStream,
);
if (stream != null) {
_streamController.add(stream);
}
}
}());
};
_peerConnection!.onConnectionState = (state) {
@ -187,7 +207,9 @@ class DesktopClient {
},
},
);
} catch (_) {}
} catch (error) {
debugPrint('Desktop ICE candidate send failed: $error');
}
}
void sendInput(Map<String, dynamic> event) {
@ -205,13 +227,14 @@ class DesktopClient {
method: 'xworkmate.desktop.close',
params: {'sessionId': sessionId},
);
} catch (_) {}
} catch (error) {
debugPrint('Desktop close request failed: $error');
}
await _dataChannel?.close();
await _peerConnection?.close();
_dataChannel = null;
_peerConnection = null;
_remoteStream = null;
_stateController.add('disconnected');
}
}

View File

@ -1,4 +1,3 @@
import 'dart:async';
/// Codex sandbox mode for controlling file system access.
enum CodexSandboxMode {
@ -274,70 +273,6 @@ enum CodexConnectionState {
/// Codex App Server RPC client.
class CodexRuntime {}
List<Map<String, dynamic>> _decodeModelListResponse(
Map<String, dynamic> result,
) {
final rawModels = <Object?>[
...switch (result['models']) {
final List<Object?> items => items,
_ => const <Object?>[],
},
if (switch (result['models']) {
final List<Object?> items => items.isEmpty,
_ => true,
})
...switch (result['data']) {
final List<Object?> items => items,
_ => const <Object?>[],
},
];
final seen = <String>{};
final items = <Map<String, dynamic>>[];
for (final item in rawModels) {
if (item is! Map) {
continue;
}
final model = item.cast<String, dynamic>();
final rawId = model['id'] ?? model['name'];
final id = rawId is String ? rawId.trim() : '';
if (id.isEmpty || !seen.add(id)) {
continue;
}
items.add(model);
}
return items;
}
Object _normalizeModelListError(Object error) {
if (error is TimeoutException) {
return TimeoutException('Codex model refresh timed out');
}
if (error is CodexRpcError) {
final message = error.message.trim();
final lower = message.toLowerCase();
if (lower.contains('cloudflare') || lower.contains('403 forbidden')) {
return CodexRpcError(
code: error.code,
message: 'Codex model refresh blocked by Cloudflare (403)',
data: error.data,
);
}
if (lower.contains('timeout waiting for child process to exit')) {
return TimeoutException(
'Codex model refresh timed out waiting for child process exit',
);
}
if (lower.contains('missing field `models`')) {
return CodexRpcError(
code: error.code,
message: 'Codex model list payload used an unsupported schema',
data: error.data,
);
}
}
return error;
}
class CodexLaunchConfiguration {
const CodexLaunchConfiguration({
required this.executable,

View File

@ -14,14 +14,3 @@ bool shouldBlockEmbeddedAgentLaunch({
enabled: enabled,
);
}
/// Helper for Go core launch blocking check.
bool shouldBlockGoCoreLaunch({
required bool isAppleHost,
bool? enabled,
}) {
return shouldBlockEmbeddedAgentLaunch(
isAppleHost: isAppleHost,
enabled: enabled,
);
}

View File

@ -1,6 +1,7 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:yaml/yaml.dart';
@ -143,19 +144,7 @@ class StoreLayoutResolver {
await _resolvePath(_supportRootPathResolver) ??
await _defaultSupportRootPath();
if (supportRootPath == null) {
// Fallback to a temporary directory instead of failing fast with an error.
// This ensures the app remains usable in "memory-only" or "ephemeral" mode.
final tempDir = await Directory.systemTemp.createTemp(
'xworkmate-fallback-',
);
final layout = StoreLayout(
rootDirectory: tempDir,
configDirectory: await ensureDirectory('${tempDir.path}/config'),
tasksDirectory: await ensureDirectory('${tempDir.path}/tasks'),
secretDirectory: await ensureDirectory('${tempDir.path}/secrets'),
);
_cached = layout;
return layout;
throw StateError('Persistent support root is unavailable.');
}
final appDataRootPath =
await _resolvePath(_appDataRootPathResolver) ?? supportRootPath;
@ -197,7 +186,8 @@ class StoreLayoutResolver {
try {
final supportDirectory = await getApplicationSupportDirectory();
return '${supportDirectory.path}/xworkmate';
} catch (_) {
} catch (error) {
debugPrint('Application support directory lookup failed: $error');
return defaultUserSettingsRootPath();
}
}
@ -213,7 +203,8 @@ class StoreLayoutResolver {
return null;
}
return normalizeStoreDirectoryPath(trimmed);
} catch (_) {
} catch (error) {
debugPrint('Store layout path resolver failed: $error');
return null;
}
}
@ -302,7 +293,8 @@ Object? decodeYamlDocument(String raw) {
}
try {
return _yamlToObject(loadYaml(trimmed));
} catch (_) {
} catch (error) {
debugPrint('YAML decode failed: $error');
return null;
}
}

View File

@ -1,36 +0,0 @@
import 'dart:async';
/// DEPRECATED: Local Go core execution is disabled.
enum GoCoreLaunchSource { buildArtifact }
/// DEPRECATED: Local Go core execution is disabled.
class GoCoreLaunch {
const GoCoreLaunch({
required this.executable,
required this.source,
this.arguments = const <String>[],
this.workingDirectory,
});
final String executable;
final GoCoreLaunchSource source;
final List<String> arguments;
final String? workingDirectory;
}
typedef GoCoreBinaryExistsResolver = Future<bool> Function(String command);
/// DEPRECATED: Local Go core locator is disabled.
class GoCoreLocator {
GoCoreLocator({
GoCoreBinaryExistsResolver? binaryExistsResolver,
String? workspaceRoot,
String Function()? resolvedExecutableResolver,
});
/// Always returns null as local execution is disabled.
Future<GoCoreLaunch?> locate() async => null;
/// Always returns false as local execution is disabled.
Future<bool> isAvailable() async => false;
}

View File

@ -496,9 +496,28 @@ class GoTaskServiceResult {
? raw['resultSummary'].toString().trim()
: raw['summary']?.toString().trim() ?? '';
String get status => raw['status']?.toString().trim() ?? '';
String get status => _firstNestedGoTaskString(
raw,
const <List<String>>[
<String>['status'],
<String>['error', 'status'],
<String>['details', 'status'],
<String>['payload', 'status'],
<String>['result', 'status'],
],
);
String get code => raw['code']?.toString().trim() ?? '';
String get code => _firstNestedGoTaskString(
raw,
const <List<String>>[
<String>['code'],
<String>['error', 'code'],
<String>['error', 'details', 'code'],
<String>['details', 'code'],
<String>['payload', 'code'],
<String>['result', 'code'],
],
);
bool get isOpenClawRunningTaskHandle {
final normalizedStatus = status.trim().toLowerCase();
@ -793,7 +812,8 @@ GoTaskServiceResult goTaskServiceResultFromAcpResponse(
.map((item) => item['id']?.toString().trim() ?? '')
.where((item) => item.isNotEmpty)
.toList(growable: false);
final success = _boolValue(result['success']) ?? true;
final success =
_boolValue(result['success']) ?? _inferGoTaskSuccess(result);
final fallbackFailureText = () {
if (success) {
return '';
@ -821,7 +841,7 @@ GoTaskServiceResult goTaskServiceResultFromAcpResponse(
? streamedText.trim()
: '')
.trim();
final directErrorMessage = result['error']?.toString().trim() ?? '';
final directErrorMessage = _extractGoTaskDisplayText(result['error']);
final effectiveErrorMessage = success
? directErrorMessage
: fallbackFailureText.isNotEmpty
@ -843,6 +863,44 @@ GoTaskServiceResult goTaskServiceResultFromAcpResponse(
);
}
bool _inferGoTaskSuccess(Map<String, dynamic> result) {
if (result.containsKey('error')) {
return false;
}
final status = _firstNestedGoTaskString(
result,
const <List<String>>[
<String>['status'],
<String>['details', 'status'],
<String>['payload', 'status'],
<String>['result', 'status'],
],
).toLowerCase();
if (status == 'failed' ||
status == 'error' ||
status == 'artifact_missing' ||
status == 'cancelled' ||
status == 'canceled') {
return false;
}
final code = _firstNestedGoTaskString(
result,
const <List<String>>[
<String>['code'],
<String>['details', 'code'],
<String>['payload', 'code'],
<String>['result', 'code'],
],
).toUpperCase();
if (code == 'OPENCLAW_REQUIRED_ARTIFACT_MISSING' ||
code == 'OPENCLAW_ARTIFACT_MISSING' ||
code == 'OPENCLAW_NO_EXPORTED_ARTIFACTS' ||
code == 'ARTIFACT_MISSING') {
return false;
}
return true;
}
String _firstGoTaskFailureText(Map<String, dynamic> result) {
for (final key in const <String>[
'error',
@ -1061,6 +1119,27 @@ List<Map<String, dynamic>> _castMapList(Object? raw) {
return raw.map(_castMap).toList(growable: false);
}
String _firstNestedGoTaskString(
Map<String, dynamic> source,
List<List<String>> paths,
) {
for (final path in paths) {
Object? current = source;
for (final key in path) {
if (current is! Map) {
current = null;
break;
}
current = current[key];
}
final value = current?.toString().trim() ?? '';
if (value.isNotEmpty) {
return value;
}
}
return '';
}
List<dynamic> _castList(Object? raw) {
if (raw is List) {
return raw;

View File

@ -212,7 +212,6 @@ class SettingsController extends ChangeNotifier {
Future<String> resolveSecretValueInternal({
String explicitValue = '',
String refName = '',
String fallbackRefName = '',
String accountTarget = '',
bool allowVaultLookup = true,
bool persistExplicitValue = true,
@ -220,7 +219,6 @@ class SettingsController extends ChangeNotifier {
this,
explicitValue: explicitValue,
refName: refName,
fallbackRefName: fallbackRefName,
accountTarget: accountTarget,
allowVaultLookup: allowVaultLookup,
persistExplicitValue: persistExplicitValue,

View File

@ -14,8 +14,6 @@ extension SettingsControllerAccountExtension on SettingsController {
pendingAccountMfaTicketInternal.trim().isNotEmpty && !accountSignedIn;
bool get hasEffectiveAiGatewayApiKey =>
secureRefsInternal.containsKey(aiGatewayApiKeyRefInternal()) ||
(aiGatewayApiKeyRefInternal() == 'ai_gateway_api_key' &&
secureRefsInternal.containsKey('ai_gateway_api_key')) ||
secureRefsInternal.containsKey(
kAccountManagedSecretTargetAIGatewayAccessToken,
);
@ -43,7 +41,6 @@ extension SettingsControllerAccountExtension on SettingsController {
Future<String> loadEffectiveAiGatewayApiKey() async {
return resolveSecretValueInternal(
refName: snapshotInternal.aiGateway.apiKeyRef,
fallbackRefName: 'ai_gateway_api_key',
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
);
}
@ -64,7 +61,6 @@ extension SettingsControllerAccountExtension on SettingsController {
return resolveSecretValueInternal(
refName: gatewayTokenRefForProfileInternal(resolvedProfileIndex),
fallbackRefName: SecretStore.gatewayTokenRefKey(resolvedProfileIndex),
accountTarget: resolvedProfileIndex == kGatewayRemoteProfileIndex
? kAccountManagedSecretTargetBridgeAuthToken
: '',
@ -77,7 +73,6 @@ extension SettingsControllerAccountExtension on SettingsController {
return resolveSecretValueInternal(
refName: gatewayPasswordRefForProfileInternal(resolvedProfileIndex),
fallbackRefName: SecretStore.gatewayPasswordRefKey(resolvedProfileIndex),
allowVaultLookup: true,
);
}

View File

@ -149,14 +149,12 @@ Future<AiGatewayProfile> syncAiGatewayCatalogSettingsInternal(
? await controller.resolveSecretValueInternal(
explicitValue: apiKeyOverride,
refName: profile.apiKeyRef,
fallbackRefName: 'ai_gateway_api_key',
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
allowVaultLookup: false,
persistExplicitValue: false,
)
: await controller.resolveSecretValueInternal(
refName: profile.apiKeyRef,
fallbackRefName: 'ai_gateway_api_key',
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
);
if (apiKey.isEmpty && !_allowsAnonymousAiGatewayInternal(normalizedBaseUrl)) {
@ -254,14 +252,12 @@ Future<AiGatewayConnectionCheck> testAiGatewayConnectionSettingsInternal(
? await controller.resolveSecretValueInternal(
explicitValue: apiKeyOverride,
refName: profile.apiKeyRef,
fallbackRefName: 'ai_gateway_api_key',
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
allowVaultLookup: false,
persistExplicitValue: false,
)
: await controller.resolveSecretValueInternal(
refName: profile.apiKeyRef,
fallbackRefName: 'ai_gateway_api_key',
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
);
final endpoint = controller
@ -320,14 +316,12 @@ Future<List<GatewayModelSummary>> loadAiGatewayModelsSettingsInternal(
? await controller.resolveSecretValueInternal(
explicitValue: apiKeyOverride,
refName: activeProfile.apiKeyRef,
fallbackRefName: 'ai_gateway_api_key',
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
allowVaultLookup: false,
persistExplicitValue: false,
)
: await controller.resolveSecretValueInternal(
refName: activeProfile.apiKeyRef,
fallbackRefName: 'ai_gateway_api_key',
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
);
if (apiKey.isEmpty && !_allowsAnonymousAiGatewayInternal(normalizedBaseUrl)) {

View File

@ -424,15 +424,12 @@ Future<String> resolveSecretValueSettingsInternal(
SettingsController controller, {
String explicitValue = '',
String refName = '',
String fallbackRefName = '',
String accountTarget = '',
bool allowVaultLookup = true,
bool persistExplicitValue = true,
}) async {
final trimmedExplicit = explicitValue.trim();
final normalizedRef = refName.trim().isNotEmpty
? refName.trim()
: fallbackRefName.trim();
final normalizedRef = refName.trim();
if (trimmedExplicit.isNotEmpty) {
if (persistExplicitValue && normalizedRef.isNotEmpty) {
await controller.storeInternal.saveSecretValueByRef(
@ -463,8 +460,9 @@ Future<String> resolveSecretValueSettingsInternal(
);
return vaultValue;
}
} catch (_) {
// Keep account-managed fallback available even when Vault lookup fails.
} catch (error) {
debugPrint('Vault secret lookup failed for $normalizedRef: $error');
// Keep account-managed secret resolution available even when Vault lookup fails.
}
}
}

View File

@ -1,6 +1,8 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'file_store_support.dart';
import 'runtime_models.dart';
@ -127,7 +129,8 @@ class SecretStore {
_secureStorage = FileSecureStorageClient(
() async => _layout?.secretDirectory,
);
} catch (_) {
} catch (error) {
debugPrint('Secret store initialization failed: $error');
_layout = null;
_secureStorage = null;
}
@ -231,7 +234,8 @@ class SecretStore {
return AccountSessionSummary.fromJson(
(jsonDecode(raw!) as Map).cast<String, dynamic>(),
);
} catch (_) {
} catch (error) {
debugPrint('Account session summary decode failed: $error');
return null;
}
}
@ -251,7 +255,8 @@ class SecretStore {
return AccountSyncState.fromJson(
(jsonDecode(raw!) as Map).cast<String, dynamic>(),
);
} catch (_) {
} catch (error) {
debugPrint('Account sync state decode failed: $error');
return null;
}
}
@ -532,7 +537,8 @@ class SecretStore {
.map((item) => item.toString().trim())
.where((item) => item.isNotEmpty)
.toSet();
} catch (_) {
} catch (error) {
debugPrint('Custom secret ref registry decode failed: $error');
return <String>{};
}
}
@ -561,7 +567,8 @@ class SecretStore {
_memorySecure[key] = value;
return value;
}
} catch (_) {
} catch (error) {
debugPrint('Secure read failed for $key: $error');
// Fall back to memory only when the secret path is unavailable.
}
}

View File

@ -1,7 +1,105 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:xworkmate/features/desktop/desktop_client.dart';
class FakeMediaStream extends MediaStream {
FakeMediaStream(String id) : super(id, 'test');
final List<MediaStreamTrack> tracks = [];
final List<bool> addToNativeValues = [];
@override
bool? get active => true;
@override
Future<void> addTrack(
MediaStreamTrack track, {
bool addToNative = true,
}) async {
tracks.add(track);
addToNativeValues.add(addToNative);
}
@override
Future<void> getMediaTracks() async {}
@override
List<MediaStreamTrack> getAudioTracks() =>
tracks.where((track) => track.kind == 'audio').toList();
@override
MediaStreamTrack? getTrackById(String trackId) {
for (final track in tracks) {
if (track.id == trackId) {
return track;
}
}
return null;
}
@override
List<MediaStreamTrack> getTracks() => List.unmodifiable(tracks);
@override
List<MediaStreamTrack> getVideoTracks() =>
tracks.where((track) => track.kind == 'video').toList();
@override
Future<void> removeTrack(
MediaStreamTrack track, {
bool removeFromNative = true,
}) async {
tracks.remove(track);
}
}
class FakeMediaStreamTrack extends MediaStreamTrack {
FakeMediaStreamTrack({required this.trackId, required this.trackKind});
final String trackId;
final String trackKind;
bool _enabled = true;
@override
Future<ByteBuffer> captureFrame() {
throw UnimplementedError();
}
@override
Future<void> dispose() async {}
@override
bool get enabled => _enabled;
@override
set enabled(bool b) {
_enabled = b;
}
@override
Future<bool> hasTorch() async => false;
@override
String? get id => trackId;
@override
String? get kind => trackKind;
@override
String? get label => trackKind;
@override
bool? get muted => false;
@override
Future<void> setTorch(bool torch) async {}
@override
Future<void> stop() async {}
}
void main() {
group('DesktopClient protocol helpers', () {
test('normalizes WebRTC connection states for view gating', () {
@ -42,5 +140,62 @@ void main() {
expect(params['width'], 1280);
expect(params['height'], 720);
});
test('uses bridge-provided remote stream when present', () async {
var fallbackCreated = false;
final providedStream = FakeMediaStream('provided-stream');
final track = FakeMediaStreamTrack(
trackId: 'video-track-1',
trackKind: 'video',
);
final stream = await desktopRemoteVideoStreamForTrack(
RTCTrackEvent(streams: [providedStream], track: track),
createFallbackStream: (label) async {
fallbackCreated = true;
return FakeMediaStream(label);
},
);
expect(stream, same(providedStream));
expect(fallbackCreated, isFalse);
expect(providedStream.tracks, isEmpty);
});
test('synthesizes stream for streamless remote video track', () async {
final track = FakeMediaStreamTrack(
trackId: 'video-track-1',
trackKind: 'video',
);
final fallbackStream = FakeMediaStream('fallback-stream');
final stream = await desktopRemoteVideoStreamForTrack(
RTCTrackEvent(streams: const [], track: track),
createFallbackStream: (label) async => fallbackStream,
);
expect(stream, same(fallbackStream));
expect(fallbackStream.tracks, [same(track)]);
expect(fallbackStream.addToNativeValues, [isFalse]);
});
test('ignores streamless non-video tracks', () async {
var fallbackCreated = false;
final track = FakeMediaStreamTrack(
trackId: 'audio-track-1',
trackKind: 'audio',
);
final stream = await desktopRemoteVideoStreamForTrack(
RTCTrackEvent(streams: const [], track: track),
createFallbackStream: (label) async {
fallbackCreated = true;
return FakeMediaStream(label);
},
);
expect(stream, isNull);
expect(fallbackCreated, isFalse);
});
});
}

View File

@ -1307,6 +1307,13 @@ void main() {
final request = fakeGoTaskService.requests.single;
expect(request.metadata, isNot(contains('taskLoadClass')));
expect(request.metadata, isNot(contains('expectedArtifactExtensions')));
expect(request.metadata, contains('xworkmateTaskArtifactContract'));
final artifactContract =
(request.metadata['xworkmateTaskArtifactContract'] as Map)
.cast<String, dynamic>();
expect(artifactContract['finalDeliverableDetection'], 'remote-runtime');
expect(artifactContract['requiresExportBeforeFinalResponse'], isTrue);
expect(artifactContract, isNot(contains('expectedArtifactExtensions')));
expect(request.prompt, isNot(contains('Task load classification:')));
expect(
request.prompt,
@ -1345,7 +1352,22 @@ void main() {
final request = fakeGoTaskService.requests.single;
expect(request.metadata, isNot(contains('taskLoadClass')));
expect(request.metadata, isNot(contains('expectedArtifactExtensions')));
expect(request.metadata, contains('xworkmateTaskArtifactContract'));
final artifactContract =
(request.metadata['xworkmateTaskArtifactContract'] as Map)
.cast<String, dynamic>();
expect(artifactContract['scopeKind'], 'task');
expect(artifactContract['rejectTextOnlyFileClaims'], isTrue);
expect(
artifactContract['currentTaskWorkspace'],
request.workingDirectory,
);
expect(request.prompt, isNot(contains('Required final artifact')));
expect(request.prompt, contains('XWorkmate task artifact contract:'));
expect(
request.prompt,
contains('export the final deliverables through the current XWorkmate task artifact scope'),
);
expect(request.prompt, contains('最后 输出 PDF文件'));
},
);
@ -1931,6 +1953,68 @@ void main() {
},
);
test(
'sendChatMessage treats nested OpenClaw artifact errors as terminal failures',
() async {
final fakeGoTaskService = _RecordingGoTaskServiceClient()
..outcomes.add(
goTaskServiceResultFromAcpResponse(
const <String, dynamic>{
'jsonrpc': '2.0',
'id': 'nested-openclaw-artifact-error',
'result': <String, dynamic>{
'status': 'failed',
'error': <String, dynamic>{
'code': 'OPENCLAW_REQUIRED_ARTIFACT_MISSING',
'message':
'openclaw returned partial artifacts without required final deliverables',
},
},
},
route: GoTaskServiceRoute.externalAcpSingle,
),
)
..outcomes.add(
const GoTaskServiceResult(
success: true,
message: 'new OpenClaw session delivered final artifacts',
turnId: 'turn-2',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
final controller = _connectedController(fakeGoTaskService);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession(
'unit-fixture-task-a',
);
await controller.sendChatMessage('first turn');
expect(fakeGoTaskService.requests, hasLength(1));
final failedThread = controller.taskThreadForSessionInternal(
'unit-fixture-task-a',
);
expect(
failedThread?.lifecycleState.lastResultCode,
'OPENCLAW_REQUIRED_ARTIFACT_MISSING',
);
expect(failedThread?.lastArtifactSyncStatus, 'failed');
await controller.sendChatMessage('retry final artifact');
expect(fakeGoTaskService.requests, hasLength(2));
expect(fakeGoTaskService.requests.last.resumeSession, isFalse);
await _waitForLastChatMessageText(
controller,
'new OpenClaw session delivered final artifacts',
);
},
);
test(
'sendChatMessage hides OpenClaw artifact guard text from failed results and streaming',
() async {
@ -4355,6 +4439,21 @@ UiFeatureManifest _defaultDesktopManifest() {
);
}
Future<void> _resilientDelete(Directory dir) async {
if (!await dir.exists()) {
return;
}
for (var attempt = 0; attempt < 8; attempt++) {
try {
await dir.delete(recursive: true);
return;
} catch (_) {
await Future<void>.delayed(const Duration(milliseconds: 50));
}
}
await dir.delete(recursive: true);
}
AppController _sandboxController({
SecureConfigStore? store,
RuntimeCoordinator? runtimeCoordinator,
@ -4371,10 +4470,7 @@ AppController _sandboxController({
final actualHome = homeDir ?? Directory.systemTemp.createTempSync('xworkmate-sandbox-home-').path;
if (homeDir == null) {
addTearDown(() async {
final dir = Directory(actualHome);
if (await dir.exists()) {
await dir.delete(recursive: true);
}
await _resilientDelete(Directory(actualHome));
});
}
return AppController(