diff --git a/lib/features/ai_gateway/ai_gateway_page.dart b/lib/features/ai_gateway/ai_gateway_page.dart index 6b8d791b..c211304a 100644 --- a/lib/features/ai_gateway/ai_gateway_page.dart +++ b/lib/features/ai_gateway/ai_gateway_page.dart @@ -1,9 +1,9 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; import '../../i18n/app_language.dart'; import '../../models/app_models.dart'; +import '../../runtime/platform_environment.dart'; import '../../runtime/runtime_models.dart'; import '../../theme/app_palette.dart'; import '../../widgets/metric_card.dart'; @@ -816,8 +816,7 @@ class _CodexIntegrationCardState extends State<_CodexIntegrationCard> { }); try { - final home = Platform.environment['HOME'] ?? ''; - final codexHome = Platform.environment['CODEX_HOME'] ?? '$home/.codex'; + final codexHome = resolveCodexHomeDirectory(); final configPath = '$codexHome/config.toml'; final gatewayUrl = widget.controller.aiGatewayUrl; diff --git a/lib/runtime/codex_config_bridge.dart b/lib/runtime/codex_config_bridge.dart index ea6feff7..709781e3 100644 --- a/lib/runtime/codex_config_bridge.dart +++ b/lib/runtime/codex_config_bridge.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:io'; +import 'platform_environment.dart'; + /// Bridge for generating Codex configuration files. /// /// This class generates `~/.codex/config.toml` and `~/.codex/auth.json` @@ -12,10 +14,7 @@ class CodexConfigBridge { final String codexHome; CodexConfigBridge({String? codexHome}) - : codexHome = - codexHome ?? - Platform.environment['CODEX_HOME'] ?? - '${Platform.environment['HOME']}/.codex'; + : codexHome = codexHome ?? resolveCodexHomeDirectory(); /// Generate config.toml to use XWorkmate AI Gateway. Future configureForGateway({ diff --git a/lib/runtime/codex_runtime.dart b/lib/runtime/codex_runtime.dart index 88d31a08..36a7db01 100644 --- a/lib/runtime/codex_runtime.dart +++ b/lib/runtime/codex_runtime.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import '../app/app_metadata.dart'; +import 'platform_environment.dart'; /// Codex sandbox mode for controlling file system access. enum CodexSandboxMode { @@ -62,11 +63,11 @@ class CodexThread { } Map toJson() => { - 'id': id, - if (path != null) 'path': path, - 'ephemeral': ephemeral, - if (createdAt != null) 'createdAt': createdAt!.toIso8601String(), - }; + 'id': id, + if (path != null) 'path': path, + 'ephemeral': ephemeral, + if (createdAt != null) 'createdAt': createdAt!.toIso8601String(), + }; } /// Codex turn information. @@ -122,7 +123,8 @@ class CodexAccount { plan: json['plan'] as String?, hasCredits: json['hasCredits'] as bool? ?? false, creditsBalance: (json['creditsBalance'] as num?)?.toDouble(), - rateLimits: (json['rateLimits'] as List?) + rateLimits: + (json['rateLimits'] as List?) ?.map((e) => CodexRateLimit.fromJson(e as Map)) .toList() ?? [], @@ -166,11 +168,11 @@ class CodexUserInput { }); Map toJson() => { - 'type': type, - 'content': content, - if (attachments != null && attachments!.isNotEmpty) - 'attachments': attachments!.map((a) => a.toJson()).toList(), - }; + 'type': type, + 'content': content, + if (attachments != null && attachments!.isNotEmpty) + 'attachments': attachments!.map((a) => a.toJson()).toList(), + }; } /// Codex file attachment. @@ -181,9 +183,9 @@ class CodexAttachment { const CodexAttachment({required this.path, this.name}); Map toJson() => { - 'path': path, - if (name != null) 'name': name, - }; + 'path': path, + if (name != null) 'name': name, + }; } /// Base class for Codex events. @@ -209,10 +211,7 @@ class CodexNotificationEvent extends CodexEvent { final String method; final Map params; - const CodexNotificationEvent({ - required this.method, - required this.params, - }); + const CodexNotificationEvent({required this.method, required this.params}); } /// Turn event (item/started, item/completed, etc.). @@ -255,11 +254,7 @@ class CodexRpcError implements Exception { final String message; final dynamic data; - const CodexRpcError({ - required this.code, - required this.message, - this.data, - }); + const CodexRpcError({required this.code, required this.message, this.data}); factory CodexRpcError.fromJson(Map json) { return CodexRpcError( @@ -295,8 +290,6 @@ class CodexRuntime extends ChangeNotifier { CodexConnectionState _state = CodexConnectionState.disconnected; String? _lastError; - String? _codexPath; - String? _workingDirectory; bool _isInitialized = false; CodexAccount? _account; @@ -320,29 +313,29 @@ class CodexRuntime extends ChangeNotifier { } // Try common locations - final home = Platform.environment['HOME'] ?? ''; - final paths = [ - '/usr/local/bin/codex', - '/opt/homebrew/bin/codex', - '$home/.cargo/bin/codex', - '$home/.local/bin/codex', - ]; + final paths = defaultCodexBinaryCandidates(); for (final path in paths) { - final expanded = path.replaceAll('\$HOME', home).replaceAll('~', home); - final file = File(expanded); + final file = File(path); if (await file.exists()) { - return expanded; + return path; } } - // Try to find via 'which' + // Try to find via platform-native lookup. try { - final result = await Process.run('which', ['codex']); + final result = await Process.run( + _lookupExecutableProgram(), + _lookupExecutableArguments(), + ); if (result.exitCode == 0) { - final path = (result.stdout as String).trim(); - if (path.isNotEmpty) { - return path; + final lines = LineSplitter.split( + result.stdout as String, + ).map((line) => line.trim()).where((line) => line.isNotEmpty); + for (final path in lines) { + if (await File(path).exists()) { + return path; + } } } } catch (_) { @@ -364,8 +357,6 @@ class CodexRuntime extends ChangeNotifier { throw StateError('Codex already running'); } - _codexPath = codexPath; - _workingDirectory = cwd; _state = CodexConnectionState.connecting; _lastError = null; notifyListeners(); @@ -373,16 +364,21 @@ class CodexRuntime extends ChangeNotifier { try { final args = [ 'app-server', - '--listen', 'stdio://', - '-s', sandbox.value, - '-a', approval.value, + '--listen', + 'stdio://', + '-s', + sandbox.value, + '-a', + approval.value, ...extraArgs, ]; + final launch = _resolveLaunchConfiguration(codexPath, args); _process = await Process.start( - codexPath, - args, + launch.executable, + launch.arguments, workingDirectory: cwd, + runInShell: launch.runInShell, ); _setupStdioStreams(); @@ -395,6 +391,52 @@ class CodexRuntime extends ChangeNotifier { } } + @visibleForTesting + static CodexLaunchConfiguration resolveLaunchConfigurationForTest( + String codexPath, + List arguments, { + String? operatingSystem, + }) { + return _resolveLaunchConfiguration( + codexPath, + arguments, + operatingSystem: operatingSystem, + ); + } + + static CodexLaunchConfiguration _resolveLaunchConfiguration( + String codexPath, + List arguments, { + String? operatingSystem, + }) { + final host = detectRuntimeHostPlatform(operatingSystem: operatingSystem); + final normalizedPath = codexPath.toLowerCase(); + final isBatchWrapper = + host == RuntimeHostPlatform.windows && + (normalizedPath.endsWith('.cmd') || normalizedPath.endsWith('.bat')); + if (isBatchWrapper) { + return CodexLaunchConfiguration( + executable: 'cmd.exe', + arguments: ['/c', codexPath, ...arguments], + ); + } + return CodexLaunchConfiguration( + executable: codexPath, + arguments: arguments, + ); + } + + static String _lookupExecutableProgram({String? operatingSystem}) { + return detectRuntimeHostPlatform(operatingSystem: operatingSystem) == + RuntimeHostPlatform.windows + ? 'where' + : 'which'; + } + + static List _lookupExecutableArguments() { + return const ['codex']; + } + void _setupStdioStreams() { final process = _process!; final stdoutLines = []; @@ -405,67 +447,77 @@ class CodexRuntime extends ChangeNotifier { .transform(utf8.decoder) .transform(LineSplitter()) .listen( - (line) { - final trimmed = line.trim(); - if (trimmed.isEmpty) return; + (line) { + final trimmed = line.trim(); + if (trimmed.isEmpty) return; - // Try to parse as JSON-RPC - if (trimmed.startsWith('{')) { - _handleMessage(trimmed); - } else { - // Non-JSON output, emit as log - stdoutLines.add(trimmed); - if (stdoutLines.length > 100) stdoutLines.removeAt(0); - _events.add(CodexLogEvent( - level: 'debug', - message: trimmed, - timestamp: DateTime.now(), - )); - } - }, - onError: (error) { - _events.add(CodexLogEvent( - level: 'error', - message: 'stdout error: $error', - timestamp: DateTime.now(), - )); - }, - ); + // Try to parse as JSON-RPC + if (trimmed.startsWith('{')) { + _handleMessage(trimmed); + } else { + // Non-JSON output, emit as log + stdoutLines.add(trimmed); + if (stdoutLines.length > 100) stdoutLines.removeAt(0); + _events.add( + CodexLogEvent( + level: 'debug', + message: trimmed, + timestamp: DateTime.now(), + ), + ); + } + }, + onError: (error) { + _events.add( + CodexLogEvent( + level: 'error', + message: 'stdout error: $error', + timestamp: DateTime.now(), + ), + ); + }, + ); // stderr: Log output _stderrSubscription = process.stderr .transform(utf8.decoder) .transform(LineSplitter()) .listen( - (line) { - final trimmed = line.trim(); - if (trimmed.isEmpty) return; + (line) { + final trimmed = line.trim(); + if (trimmed.isEmpty) return; - stderrLines.add(trimmed); - if (stderrLines.length > 100) stderrLines.removeAt(0); + stderrLines.add(trimmed); + if (stderrLines.length > 100) stderrLines.removeAt(0); - _events.add(CodexLogEvent( - level: 'info', - message: trimmed, - timestamp: DateTime.now(), - )); - }, - onError: (error) { - _events.add(CodexLogEvent( - level: 'error', - message: 'stderr error: $error', - timestamp: DateTime.now(), - )); - }, - ); + _events.add( + CodexLogEvent( + level: 'info', + message: trimmed, + timestamp: DateTime.now(), + ), + ); + }, + onError: (error) { + _events.add( + CodexLogEvent( + level: 'error', + message: 'stderr error: $error', + timestamp: DateTime.now(), + ), + ); + }, + ); // Handle process exit process.exitCode.then((exitCode) { - _events.add(CodexLogEvent( - level: exitCode == 0 ? 'info' : 'warn', - message: 'Codex exited with code $exitCode', - timestamp: DateTime.now(), - )); + _events.add( + CodexLogEvent( + level: exitCode == 0 ? 'info' : 'warn', + message: 'Codex exited with code $exitCode', + timestamp: DateTime.now(), + ), + ); _process = null; _state = CodexConnectionState.disconnected; _isInitialized = false; @@ -478,19 +530,19 @@ class CodexRuntime extends ChangeNotifier { notifyListeners(); try { - final result = await request('initialize', params: { - 'clientInfo': { - 'name': 'xworkmate', - 'version': kAppVersion, + final result = await request( + 'initialize', + params: { + 'clientInfo': {'name': 'xworkmate', 'version': kAppVersion}, + 'capabilities': {'optOutNotificationMethods': []}, }, - 'capabilities': { - 'optOutNotificationMethods': [], - }, - }); + ); // Store any account info from response if (result.containsKey('account')) { - _account = CodexAccount.fromJson(result['account'] as Map); + _account = CodexAccount.fromJson( + result['account'] as Map, + ); } // Send initialized notification @@ -523,7 +575,9 @@ class CodexRuntime extends ChangeNotifier { final id = json['id'].toString(); final completer = _pendingRequests.remove(id); if (completer != null && !completer.isCompleted) { - completer.completeError(CodexRpcError.fromJson(json['error'] as Map)); + completer.completeError( + CodexRpcError.fromJson(json['error'] as Map), + ); } } else if (json.containsKey('method')) { // Notification @@ -532,11 +586,13 @@ class CodexRuntime extends ChangeNotifier { _events.add(CodexNotificationEvent(method: method, params: params)); } } catch (e) { - _events.add(CodexLogEvent( - level: 'warn', - message: 'Failed to parse message: $e', - timestamp: DateTime.now(), - )); + _events.add( + CodexLogEvent( + level: 'warn', + message: 'Failed to parse message: $e', + timestamp: DateTime.now(), + ), + ); } } @@ -574,7 +630,10 @@ class CodexRuntime extends ChangeNotifier { } /// Send notification (no response expected). - Future _sendNotification(String method, {required Map params}) async { + Future _sendNotification( + String method, { + required Map params, + }) async { final process = _process; if (process == null) { throw StateError('Codex not running'); @@ -600,11 +659,13 @@ class CodexRuntime extends ChangeNotifier { }) async { final params = { 'cwd': cwd, - if (model != null) 'model': model, - if (sandbox != null) 'sandbox': sandbox.value, - if (approval != null) 'approvalPolicy': approval.value, + ...?model == null ? null : {'model': model}, + ...?sandbox == null ? null : {'sandbox': sandbox.value}, + ...?approval == null + ? null + : {'approvalPolicy': approval.value}, if (ephemeral) 'ephemeral': true, - if (settings != null) 'settings': settings, + ...?settings == null ? null : {'settings': settings}, }; final result = await request('thread/start', params: params); @@ -618,7 +679,7 @@ class CodexRuntime extends ChangeNotifier { }) async { final params = { 'threadId': threadId, - if (cwd != null) 'cwd': cwd, + ...?cwd == null ? null : {'cwd': cwd}, }; final result = await request('thread/resume', params: params); @@ -633,15 +694,16 @@ class CodexRuntime extends ChangeNotifier { Duration timeout = const Duration(minutes: 10), }) async* { // Start turn - final turnResult = await request('turn/start', params: { - 'threadId': threadId, - 'userInput': CodexUserInput( - content: prompt, - attachments: attachments, - ).toJson(), - }); - - final turnId = turnResult['turnId'] as String; + await request( + 'turn/start', + params: { + 'threadId': threadId, + 'userInput': CodexUserInput( + content: prompt, + attachments: attachments, + ).toJson(), + }, + ); // Listen for events until turn/completed await for (final event in _events.stream) { @@ -675,16 +737,24 @@ class CodexRuntime extends ChangeNotifier { } /// List available models. - Future>> listModels({bool includeHidden = false}) async { - final result = await request('model/list', params: { - 'includeHidden': includeHidden, - }); + Future>> listModels({ + bool includeHidden = false, + }) async { + final result = await request( + 'model/list', + params: {'includeHidden': includeHidden}, + ); return (result['models'] as List).cast>(); } /// List available skills. Future>> listSkills({required String cwd}) async { - final result = await request('skills/list', params: {'cwds': [cwd]}); + final result = await request( + 'skills/list', + params: { + 'cwds': [cwd], + }, + ); return (result['skills'] as List?)?.cast>() ?? []; } @@ -719,3 +789,15 @@ class CodexRuntime extends ChangeNotifier { super.dispose(); } } + +class CodexLaunchConfiguration { + const CodexLaunchConfiguration({ + required this.executable, + required this.arguments, + this.runInShell = false, + }); + + final String executable; + final List arguments; + final bool runInShell; +} diff --git a/lib/runtime/gateway_runtime.dart b/lib/runtime/gateway_runtime.dart index df011600..7bd800f0 100644 --- a/lib/runtime/gateway_runtime.dart +++ b/lib/runtime/gateway_runtime.dart @@ -10,6 +10,7 @@ import 'package:web_socket_channel/io.dart'; import '../app/app_metadata.dart'; import 'device_identity_store.dart'; +import 'platform_environment.dart'; import 'runtime_models.dart'; import 'secure_config_store.dart'; @@ -79,10 +80,10 @@ class GatewayRuntime extends ChangeNotifier { version: kAppVersion, buildNumber: kAppBuildNumber, ); - RuntimeDeviceInfo _deviceInfo = const RuntimeDeviceInfo( - platform: 'macos', + RuntimeDeviceInfo _deviceInfo = RuntimeDeviceInfo( + platform: Platform.operatingSystem, platformVersion: '', - deviceFamily: 'Mac', + deviceFamily: 'Desktop', modelIdentifier: 'unknown', ); @@ -1303,16 +1304,7 @@ class GatewayRuntime extends ChangeNotifier { } String _resolveClientId() { - if (Platform.isMacOS) { - return 'openclaw-macos'; - } - if (Platform.isIOS) { - return 'openclaw-ios'; - } - if (Platform.isAndroid) { - return 'openclaw-android'; - } - return 'gateway-client'; + return resolveGatewayClientId(); } Future _loadPackageInfo() async { diff --git a/lib/runtime/platform_environment.dart b/lib/runtime/platform_environment.dart new file mode 100644 index 00000000..6f952c07 --- /dev/null +++ b/lib/runtime/platform_environment.dart @@ -0,0 +1,148 @@ +import 'dart:io'; + +enum RuntimeHostPlatform { macos, windows, linux, ios, android, other } + +RuntimeHostPlatform detectRuntimeHostPlatform({String? operatingSystem}) { + return switch (operatingSystem ?? Platform.operatingSystem) { + 'macos' => RuntimeHostPlatform.macos, + 'windows' => RuntimeHostPlatform.windows, + 'linux' => RuntimeHostPlatform.linux, + 'ios' => RuntimeHostPlatform.ios, + 'android' => RuntimeHostPlatform.android, + _ => RuntimeHostPlatform.other, + }; +} + +String resolveUserHomeDirectory({ + Map? environment, + String? operatingSystem, +}) { + final env = environment ?? Platform.environment; + final host = detectRuntimeHostPlatform(operatingSystem: operatingSystem); + + if (host == RuntimeHostPlatform.windows) { + final userProfile = env['USERPROFILE']?.trim() ?? ''; + if (userProfile.isNotEmpty) { + return userProfile; + } + final homeDrive = env['HOMEDRIVE']?.trim() ?? ''; + final homePath = env['HOMEPATH']?.trim() ?? ''; + if (homeDrive.isNotEmpty && homePath.isNotEmpty) { + return '$homeDrive$homePath'; + } + } + + final home = env['HOME']?.trim() ?? ''; + if (home.isNotEmpty) { + return home; + } + + return env['USERPROFILE']?.trim() ?? ''; +} + +String resolveCodexHomeDirectory({ + Map? environment, + String? operatingSystem, +}) { + final env = environment ?? Platform.environment; + final explicit = env['CODEX_HOME']?.trim() ?? ''; + if (explicit.isNotEmpty) { + return explicit; + } + + final home = resolveUserHomeDirectory( + environment: env, + operatingSystem: operatingSystem, + ); + if (home.isEmpty) { + return '.codex'; + } + return joinPlatformPath(home, '.codex', operatingSystem: operatingSystem); +} + +String joinPlatformPath(String base, String child, {String? operatingSystem}) { + if (base.isEmpty) { + return child; + } + final separator = + detectRuntimeHostPlatform(operatingSystem: operatingSystem) == + RuntimeHostPlatform.windows + ? r'\' + : '/'; + final normalizedBase = base.endsWith(separator) + ? base.substring(0, base.length - 1) + : base; + return '$normalizedBase$separator$child'; +} + +List defaultCodexBinaryCandidates({ + Map? environment, + String? operatingSystem, +}) { + final env = environment ?? Platform.environment; + final host = detectRuntimeHostPlatform(operatingSystem: operatingSystem); + final home = resolveUserHomeDirectory( + environment: env, + operatingSystem: operatingSystem, + ); + + if (host == RuntimeHostPlatform.windows) { + final appData = env['APPDATA']?.trim() ?? ''; + final localAppData = env['LOCALAPPDATA']?.trim() ?? ''; + return [ + if (home.isNotEmpty) + joinPlatformPath( + home, + r'.cargo\bin\codex.exe', + operatingSystem: operatingSystem, + ), + if (appData.isNotEmpty) + joinPlatformPath( + appData, + r'npm\codex.cmd', + operatingSystem: operatingSystem, + ), + if (localAppData.isNotEmpty) + joinPlatformPath( + localAppData, + r'Programs\codex\codex.exe', + operatingSystem: operatingSystem, + ), + if (home.isNotEmpty) + joinPlatformPath( + home, + r'scoop\shims\codex.cmd', + operatingSystem: operatingSystem, + ), + ]; + } + + return [ + '/usr/local/bin/codex', + '/opt/homebrew/bin/codex', + if (home.isNotEmpty) + joinPlatformPath( + home, + '.cargo/bin/codex', + operatingSystem: operatingSystem, + ), + if (home.isNotEmpty) + joinPlatformPath( + home, + '.local/bin/codex', + operatingSystem: operatingSystem, + ), + if (host == RuntimeHostPlatform.linux) '/usr/bin/codex', + ]; +} + +String resolveGatewayClientId({String? operatingSystem}) { + return switch (detectRuntimeHostPlatform(operatingSystem: operatingSystem)) { + RuntimeHostPlatform.macos => 'openclaw-macos', + RuntimeHostPlatform.windows => 'openclaw-windows', + RuntimeHostPlatform.ios => 'openclaw-ios', + RuntimeHostPlatform.android => 'openclaw-android', + RuntimeHostPlatform.linux => 'openclaw-linux', + RuntimeHostPlatform.other => 'gateway-client', + }; +} diff --git a/test/runtime/codex_runtime_test.dart b/test/runtime/codex_runtime_test.dart index 64b5b7e4..7f8aa76f 100644 --- a/test/runtime/codex_runtime_test.dart +++ b/test/runtime/codex_runtime_test.dart @@ -1,7 +1,3 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/runtime/codex_runtime.dart'; @@ -10,7 +6,10 @@ void main() { test('has correct values', () { expect(CodexSandboxMode.readOnly.value, equals('read-only')); expect(CodexSandboxMode.workspaceWrite.value, equals('workspace-write')); - expect(CodexSandboxMode.dangerFullAccess.value, equals('danger-full-access')); + expect( + CodexSandboxMode.dangerFullAccess.value, + equals('danger-full-access'), + ); }); }); @@ -133,6 +132,34 @@ void main() { expect(path, anyOf(isNull, isA())); }); + test('wraps windows cmd launch via cmd.exe', () { + final launch = CodexRuntime.resolveLaunchConfigurationForTest( + r'C:\Users\tester\AppData\Roaming\npm\codex.cmd', + const ['app-server', '--listen', 'stdio://'], + operatingSystem: 'windows', + ); + + expect(launch.executable, 'cmd.exe'); + expect(launch.arguments, [ + '/c', + r'C:\Users\tester\AppData\Roaming\npm\codex.cmd', + 'app-server', + '--listen', + 'stdio://', + ]); + }); + + test('passes executable launch through for native binaries', () { + final launch = CodexRuntime.resolveLaunchConfigurationForTest( + r'C:\Users\tester\.cargo\bin\codex.exe', + const ['app-server'], + operatingSystem: 'windows', + ); + + expect(launch.executable, r'C:\Users\tester\.cargo\bin\codex.exe'); + expect(launch.arguments, ['app-server']); + }); + test('request throws when not connected', () async { expect( () => runtime.request('initialize', params: {}), diff --git a/test/runtime/platform_environment_test.dart b/test/runtime/platform_environment_test.dart new file mode 100644 index 00000000..b0f9da55 --- /dev/null +++ b/test/runtime/platform_environment_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/runtime/platform_environment.dart'; + +void main() { + test('resolveCodexHomeDirectory uses USERPROFILE on windows', () { + final codexHome = resolveCodexHomeDirectory( + environment: const {'USERPROFILE': r'C:\Users\tester'}, + operatingSystem: 'windows', + ); + + expect(codexHome, r'C:\Users\tester\.codex'); + }); + + test('resolveCodexHomeDirectory honors explicit CODEX_HOME', () { + final codexHome = resolveCodexHomeDirectory( + environment: const { + 'CODEX_HOME': r'D:\Tools\CodexHome', + 'USERPROFILE': r'C:\Users\tester', + }, + operatingSystem: 'windows', + ); + + expect(codexHome, r'D:\Tools\CodexHome'); + }); + + test('defaultCodexBinaryCandidates include common windows locations', () { + final candidates = defaultCodexBinaryCandidates( + environment: const { + 'USERPROFILE': r'C:\Users\tester', + 'APPDATA': r'C:\Users\tester\AppData\Roaming', + 'LOCALAPPDATA': r'C:\Users\tester\AppData\Local', + }, + operatingSystem: 'windows', + ); + + expect(candidates, contains(r'C:\Users\tester\.cargo\bin\codex.exe')); + expect( + candidates, + contains(r'C:\Users\tester\AppData\Roaming\npm\codex.cmd'), + ); + expect( + candidates, + contains(r'C:\Users\tester\AppData\Local\Programs\codex\codex.exe'), + ); + }); + + test('resolveGatewayClientId returns windows specific identifier', () { + expect( + resolveGatewayClientId(operatingSystem: 'windows'), + 'openclaw-windows', + ); + }); +}