diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c2f0cc487a..090f6046334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob. - Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898. - secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant. +- Browser/existing-session: support `browser.profiles..userDataDir` so Chrome DevTools MCP can attach to Brave, Edge, and other Chromium-based browsers through their own user data directories. (#48170) thanks @velvet-shark. ### Breaking diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt index 40cabebd17c..d9ad83175b4 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt @@ -18,14 +18,13 @@ import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { private val viewModel: MainViewModel by viewModels() private lateinit var permissionRequester: PermissionRequester + private var didAttachRuntimeUi = false + private var didStartNodeService = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) permissionRequester = PermissionRequester(this) - viewModel.camera.attachLifecycleOwner(this) - viewModel.camera.attachPermissionRequester(permissionRequester) - viewModel.sms.attachPermissionRequester(permissionRequester) lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -39,6 +38,20 @@ class MainActivity : ComponentActivity() { } } + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.runtimeInitialized.collect { ready -> + if (!ready || didAttachRuntimeUi) return@collect + viewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester) + didAttachRuntimeUi = true + if (!didStartNodeService) { + NodeForegroundService.start(this@MainActivity) + didStartNodeService = true + } + } + } + } + setContent { OpenClawTheme { Surface(modifier = Modifier) { @@ -46,9 +59,6 @@ class MainActivity : ComponentActivity() { } } } - - // Keep startup path lean: start foreground service after first frame. - window.decorView.post { NodeForegroundService.start(this) } } override fun onStart() { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 80f42e02843..82fe643314c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -2,209 +2,268 @@ package ai.openclaw.app import android.app.Application import androidx.lifecycle.AndroidViewModel -import ai.openclaw.app.gateway.GatewayEndpoint +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.viewModelScope +import ai.openclaw.app.chat.ChatMessage +import ai.openclaw.app.chat.ChatPendingToolCall +import ai.openclaw.app.chat.ChatSessionEntry import ai.openclaw.app.chat.OutgoingAttachment +import ai.openclaw.app.gateway.GatewayEndpoint import ai.openclaw.app.node.CameraCaptureManager import ai.openclaw.app.node.CanvasController import ai.openclaw.app.node.SmsManager import ai.openclaw.app.voice.VoiceConversationEntry +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +@OptIn(ExperimentalCoroutinesApi::class) class MainViewModel(app: Application) : AndroidViewModel(app) { - private val runtime: NodeRuntime = (app as NodeApp).runtime + private val nodeApp = app as NodeApp + private val prefs = nodeApp.prefs + private val runtimeRef = MutableStateFlow(null) + private var foreground = true - val canvas: CanvasController = runtime.canvas - val canvasCurrentUrl: StateFlow = runtime.canvas.currentUrl - val canvasA2uiHydrated: StateFlow = runtime.canvasA2uiHydrated - val canvasRehydratePending: StateFlow = runtime.canvasRehydratePending - val canvasRehydrateErrorText: StateFlow = runtime.canvasRehydrateErrorText - val camera: CameraCaptureManager = runtime.camera - val sms: SmsManager = runtime.sms + private fun ensureRuntime(): NodeRuntime { + runtimeRef.value?.let { return it } + val runtime = nodeApp.ensureRuntime() + runtime.setForeground(foreground) + runtimeRef.value = runtime + return runtime + } - val gateways: StateFlow> = runtime.gateways - val discoveryStatusText: StateFlow = runtime.discoveryStatusText + private fun runtimeState( + initial: T, + selector: (NodeRuntime) -> StateFlow, + ): StateFlow = + runtimeRef + .flatMapLatest { runtime -> runtime?.let(selector) ?: flowOf(initial) } + .stateIn(viewModelScope, SharingStarted.Eagerly, initial) - val isConnected: StateFlow = runtime.isConnected - val isNodeConnected: StateFlow = runtime.nodeConnected - val statusText: StateFlow = runtime.statusText - val serverName: StateFlow = runtime.serverName - val remoteAddress: StateFlow = runtime.remoteAddress - val pendingGatewayTrust: StateFlow = runtime.pendingGatewayTrust - val isForeground: StateFlow = runtime.isForeground - val seamColorArgb: StateFlow = runtime.seamColorArgb - val mainSessionKey: StateFlow = runtime.mainSessionKey + val runtimeInitialized: StateFlow = + runtimeRef + .flatMapLatest { runtime -> flowOf(runtime != null) } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) - val cameraHud: StateFlow = runtime.cameraHud - val cameraFlashToken: StateFlow = runtime.cameraFlashToken + val canvasCurrentUrl: StateFlow = runtimeState(initial = null) { it.canvas.currentUrl } + val canvasA2uiHydrated: StateFlow = runtimeState(initial = false) { it.canvasA2uiHydrated } + val canvasRehydratePending: StateFlow = runtimeState(initial = false) { it.canvasRehydratePending } + val canvasRehydrateErrorText: StateFlow = runtimeState(initial = null) { it.canvasRehydrateErrorText } - val instanceId: StateFlow = runtime.instanceId - val displayName: StateFlow = runtime.displayName - val cameraEnabled: StateFlow = runtime.cameraEnabled - val locationMode: StateFlow = runtime.locationMode - val locationPreciseEnabled: StateFlow = runtime.locationPreciseEnabled - val preventSleep: StateFlow = runtime.preventSleep - val micEnabled: StateFlow = runtime.micEnabled - val micCooldown: StateFlow = runtime.micCooldown - val micStatusText: StateFlow = runtime.micStatusText - val micLiveTranscript: StateFlow = runtime.micLiveTranscript - val micIsListening: StateFlow = runtime.micIsListening - val micQueuedMessages: StateFlow> = runtime.micQueuedMessages - val micConversation: StateFlow> = runtime.micConversation - val micInputLevel: StateFlow = runtime.micInputLevel - val micIsSending: StateFlow = runtime.micIsSending - val speakerEnabled: StateFlow = runtime.speakerEnabled - val manualEnabled: StateFlow = runtime.manualEnabled - val manualHost: StateFlow = runtime.manualHost - val manualPort: StateFlow = runtime.manualPort - val manualTls: StateFlow = runtime.manualTls - val gatewayToken: StateFlow = runtime.gatewayToken - val onboardingCompleted: StateFlow = runtime.onboardingCompleted - val canvasDebugStatusEnabled: StateFlow = runtime.canvasDebugStatusEnabled + val gateways: StateFlow> = runtimeState(initial = emptyList()) { it.gateways } + val discoveryStatusText: StateFlow = runtimeState(initial = "Searching…") { it.discoveryStatusText } - val chatSessionKey: StateFlow = runtime.chatSessionKey - val chatSessionId: StateFlow = runtime.chatSessionId - val chatMessages = runtime.chatMessages - val chatError: StateFlow = runtime.chatError - val chatHealthOk: StateFlow = runtime.chatHealthOk - val chatThinkingLevel: StateFlow = runtime.chatThinkingLevel - val chatStreamingAssistantText: StateFlow = runtime.chatStreamingAssistantText - val chatPendingToolCalls = runtime.chatPendingToolCalls - val chatSessions = runtime.chatSessions - val pendingRunCount: StateFlow = runtime.pendingRunCount + val isConnected: StateFlow = runtimeState(initial = false) { it.isConnected } + val isNodeConnected: StateFlow = runtimeState(initial = false) { it.nodeConnected } + val statusText: StateFlow = runtimeState(initial = "Offline") { it.statusText } + val serverName: StateFlow = runtimeState(initial = null) { it.serverName } + val remoteAddress: StateFlow = runtimeState(initial = null) { it.remoteAddress } + val pendingGatewayTrust: StateFlow = runtimeState(initial = null) { it.pendingGatewayTrust } + val seamColorArgb: StateFlow = runtimeState(initial = 0xFF0EA5E9) { it.seamColorArgb } + val mainSessionKey: StateFlow = runtimeState(initial = "main") { it.mainSessionKey } + + val cameraHud: StateFlow = runtimeState(initial = null) { it.cameraHud } + val cameraFlashToken: StateFlow = runtimeState(initial = 0L) { it.cameraFlashToken } + + val instanceId: StateFlow = prefs.instanceId + val displayName: StateFlow = prefs.displayName + val cameraEnabled: StateFlow = prefs.cameraEnabled + val locationMode: StateFlow = prefs.locationMode + val locationPreciseEnabled: StateFlow = prefs.locationPreciseEnabled + val preventSleep: StateFlow = prefs.preventSleep + val manualEnabled: StateFlow = prefs.manualEnabled + val manualHost: StateFlow = prefs.manualHost + val manualPort: StateFlow = prefs.manualPort + val manualTls: StateFlow = prefs.manualTls + val gatewayToken: StateFlow = prefs.gatewayToken + val onboardingCompleted: StateFlow = prefs.onboardingCompleted + val canvasDebugStatusEnabled: StateFlow = prefs.canvasDebugStatusEnabled + val speakerEnabled: StateFlow = prefs.speakerEnabled + val micEnabled: StateFlow = prefs.talkEnabled + + val micCooldown: StateFlow = runtimeState(initial = false) { it.micCooldown } + val micStatusText: StateFlow = runtimeState(initial = "Mic off") { it.micStatusText } + val micLiveTranscript: StateFlow = runtimeState(initial = null) { it.micLiveTranscript } + val micIsListening: StateFlow = runtimeState(initial = false) { it.micIsListening } + val micQueuedMessages: StateFlow> = runtimeState(initial = emptyList()) { it.micQueuedMessages } + val micConversation: StateFlow> = runtimeState(initial = emptyList()) { it.micConversation } + val micInputLevel: StateFlow = runtimeState(initial = 0f) { it.micInputLevel } + val micIsSending: StateFlow = runtimeState(initial = false) { it.micIsSending } + + val chatSessionKey: StateFlow = runtimeState(initial = "main") { it.chatSessionKey } + val chatSessionId: StateFlow = runtimeState(initial = null) { it.chatSessionId } + val chatMessages: StateFlow> = runtimeState(initial = emptyList()) { it.chatMessages } + val chatError: StateFlow = runtimeState(initial = null) { it.chatError } + val chatHealthOk: StateFlow = runtimeState(initial = false) { it.chatHealthOk } + val chatThinkingLevel: StateFlow = runtimeState(initial = "off") { it.chatThinkingLevel } + val chatStreamingAssistantText: StateFlow = runtimeState(initial = null) { it.chatStreamingAssistantText } + val chatPendingToolCalls: StateFlow> = runtimeState(initial = emptyList()) { it.chatPendingToolCalls } + val chatSessions: StateFlow> = runtimeState(initial = emptyList()) { it.chatSessions } + val pendingRunCount: StateFlow = runtimeState(initial = 0) { it.pendingRunCount } + + init { + if (prefs.onboardingCompleted.value) { + ensureRuntime() + } + } + + val canvas: CanvasController + get() = ensureRuntime().canvas + + val camera: CameraCaptureManager + get() = ensureRuntime().camera + + val sms: SmsManager + get() = ensureRuntime().sms + + fun attachRuntimeUi(owner: LifecycleOwner, permissionRequester: PermissionRequester) { + val runtime = runtimeRef.value ?: return + runtime.camera.attachLifecycleOwner(owner) + runtime.camera.attachPermissionRequester(permissionRequester) + runtime.sms.attachPermissionRequester(permissionRequester) + } fun setForeground(value: Boolean) { - runtime.setForeground(value) + foreground = value + runtimeRef.value?.setForeground(value) } fun setDisplayName(value: String) { - runtime.setDisplayName(value) + prefs.setDisplayName(value) } fun setCameraEnabled(value: Boolean) { - runtime.setCameraEnabled(value) + prefs.setCameraEnabled(value) } fun setLocationMode(mode: LocationMode) { - runtime.setLocationMode(mode) + prefs.setLocationMode(mode) } fun setLocationPreciseEnabled(value: Boolean) { - runtime.setLocationPreciseEnabled(value) + prefs.setLocationPreciseEnabled(value) } fun setPreventSleep(value: Boolean) { - runtime.setPreventSleep(value) + prefs.setPreventSleep(value) } fun setManualEnabled(value: Boolean) { - runtime.setManualEnabled(value) + prefs.setManualEnabled(value) } fun setManualHost(value: String) { - runtime.setManualHost(value) + prefs.setManualHost(value) } fun setManualPort(value: Int) { - runtime.setManualPort(value) + prefs.setManualPort(value) } fun setManualTls(value: Boolean) { - runtime.setManualTls(value) + prefs.setManualTls(value) } fun setGatewayToken(value: String) { - runtime.setGatewayToken(value) + prefs.setGatewayToken(value) } fun setGatewayBootstrapToken(value: String) { - runtime.setGatewayBootstrapToken(value) + prefs.setGatewayBootstrapToken(value) } fun setGatewayPassword(value: String) { - runtime.setGatewayPassword(value) + prefs.setGatewayPassword(value) } fun setOnboardingCompleted(value: Boolean) { - runtime.setOnboardingCompleted(value) + if (value) { + ensureRuntime() + } + prefs.setOnboardingCompleted(value) } fun setCanvasDebugStatusEnabled(value: Boolean) { - runtime.setCanvasDebugStatusEnabled(value) + prefs.setCanvasDebugStatusEnabled(value) } fun setVoiceScreenActive(active: Boolean) { - runtime.setVoiceScreenActive(active) + ensureRuntime().setVoiceScreenActive(active) } fun setMicEnabled(enabled: Boolean) { - runtime.setMicEnabled(enabled) + ensureRuntime().setMicEnabled(enabled) } fun setSpeakerEnabled(enabled: Boolean) { - runtime.setSpeakerEnabled(enabled) + ensureRuntime().setSpeakerEnabled(enabled) } fun refreshGatewayConnection() { - runtime.refreshGatewayConnection() + ensureRuntime().refreshGatewayConnection() } fun connect(endpoint: GatewayEndpoint) { - runtime.connect(endpoint) + ensureRuntime().connect(endpoint) } fun connectManual() { - runtime.connectManual() + ensureRuntime().connectManual() } fun disconnect() { - runtime.disconnect() + runtimeRef.value?.disconnect() } fun acceptGatewayTrustPrompt() { - runtime.acceptGatewayTrustPrompt() + runtimeRef.value?.acceptGatewayTrustPrompt() } fun declineGatewayTrustPrompt() { - runtime.declineGatewayTrustPrompt() + runtimeRef.value?.declineGatewayTrustPrompt() } fun handleCanvasA2UIActionFromWebView(payloadJson: String) { - runtime.handleCanvasA2UIActionFromWebView(payloadJson) + ensureRuntime().handleCanvasA2UIActionFromWebView(payloadJson) } fun requestCanvasRehydrate(source: String = "screen_tab") { - runtime.requestCanvasRehydrate(source = source, force = true) + ensureRuntime().requestCanvasRehydrate(source = source, force = true) } fun refreshHomeCanvasOverviewIfConnected() { - runtime.refreshHomeCanvasOverviewIfConnected() + ensureRuntime().refreshHomeCanvasOverviewIfConnected() } fun loadChat(sessionKey: String) { - runtime.loadChat(sessionKey) + ensureRuntime().loadChat(sessionKey) } fun refreshChat() { - runtime.refreshChat() + ensureRuntime().refreshChat() } fun refreshChatSessions(limit: Int? = null) { - runtime.refreshChatSessions(limit = limit) + ensureRuntime().refreshChatSessions(limit = limit) } fun setChatThinkingLevel(level: String) { - runtime.setChatThinkingLevel(level) + ensureRuntime().setChatThinkingLevel(level) } fun switchChatSession(sessionKey: String) { - runtime.switchChatSession(sessionKey) + ensureRuntime().switchChatSession(sessionKey) } fun abortChat() { - runtime.abortChat() + ensureRuntime().abortChat() } fun sendChat(message: String, thinking: String, attachments: List) { - runtime.sendChat(message = message, thinking = thinking, attachments = attachments) + ensureRuntime().sendChat(message = message, thinking = thinking, attachments = attachments) } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt index 0d172a8abe7..adfd4b73907 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt @@ -4,7 +4,18 @@ import android.app.Application import android.os.StrictMode class NodeApp : Application() { - val runtime: NodeRuntime by lazy { NodeRuntime(this) } + val prefs: SecurePrefs by lazy { SecurePrefs(this) } + + @Volatile private var runtimeInstance: NodeRuntime? = null + + fun ensureRuntime(): NodeRuntime { + runtimeInstance?.let { return it } + return synchronized(this) { + runtimeInstance ?: NodeRuntime(this, prefs).also { runtimeInstance = it } + } + } + + fun peekRuntime(): NodeRuntime? = runtimeInstance override fun onCreate() { super.onCreate() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt index 5761567ebcc..4c7ccdd56e5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt @@ -28,7 +28,11 @@ class NodeForegroundService : Service() { val initial = buildNotification(title = "OpenClaw Node", text = "Starting…") startForegroundWithTypes(notification = initial) - val runtime = (application as NodeApp).runtime + val runtime = (application as NodeApp).peekRuntime() + if (runtime == null) { + stopSelf() + return + } notificationJob = scope.launch { combine( @@ -59,7 +63,7 @@ class NodeForegroundService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_STOP -> { - (application as NodeApp).runtime.disconnect() + (application as NodeApp).peekRuntime()?.disconnect() stopSelf() return START_NOT_STICKY } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index c2bce9a247a..9ee6198e15c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -43,11 +43,12 @@ import kotlinx.serialization.json.buildJsonObject import java.util.UUID import java.util.concurrent.atomic.AtomicLong -class NodeRuntime(context: Context) { +class NodeRuntime( + context: Context, + val prefs: SecurePrefs = SecurePrefs(context.applicationContext), +) { private val appContext = context.applicationContext private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - val prefs = SecurePrefs(appContext) private val deviceAuthStore = DeviceAuthStore(prefs) val canvas = CanvasController() val camera = CameraCaptureManager(appContext) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/Base64ImageState.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/Base64ImageState.kt index b2b540bdb7a..8180d24bbed 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/Base64ImageState.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/Base64ImageState.kt @@ -1,7 +1,5 @@ package ai.openclaw.app.ui.chat -import android.graphics.BitmapFactory -import android.util.Base64 import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -28,8 +26,7 @@ internal fun rememberBase64ImageState(base64: String): Base64ImageState { image = withContext(Dispatchers.Default) { try { - val bytes = Base64.decode(base64, Base64.DEFAULT) - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null + val bitmap = decodeBase64Bitmap(base64) ?: return@withContext null bitmap.asImageBitmap() } catch (_: Throwable) { null diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt new file mode 100644 index 00000000000..6574fa8678d --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt @@ -0,0 +1,150 @@ +package ai.openclaw.app.ui.chat + +import android.content.ContentResolver +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Base64 +import android.util.LruCache +import androidx.core.graphics.scale +import ai.openclaw.app.node.JpegSizeLimiter +import java.io.ByteArrayOutputStream +import kotlin.math.max +import kotlin.math.roundToInt + +private const val CHAT_ATTACHMENT_MAX_WIDTH = 1600 +private const val CHAT_ATTACHMENT_MAX_BASE64_CHARS = 300 * 1024 +private const val CHAT_ATTACHMENT_START_QUALITY = 85 +private const val CHAT_DECODE_MAX_DIMENSION = 1600 +private const val CHAT_IMAGE_CACHE_BYTES = 16 * 1024 * 1024 + +private val decodedBitmapCache = + object : LruCache(CHAT_IMAGE_CACHE_BYTES) { + override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount.coerceAtLeast(1) + } + +internal fun loadSizedImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment { + val fileName = normalizeAttachmentFileName((uri.lastPathSegment ?: "image").substringAfterLast('/')) + val bitmap = decodeScaledBitmap(resolver, uri, maxDimension = CHAT_ATTACHMENT_MAX_WIDTH) + if (bitmap == null) { + throw IllegalStateException("unsupported attachment") + } + val maxBytes = (CHAT_ATTACHMENT_MAX_BASE64_CHARS / 4) * 3 + val encoded = + JpegSizeLimiter.compressToLimit( + initialWidth = bitmap.width, + initialHeight = bitmap.height, + startQuality = CHAT_ATTACHMENT_START_QUALITY, + maxBytes = maxBytes, + minSize = 240, + encode = { width, height, quality -> + val working = + if (width == bitmap.width && height == bitmap.height) { + bitmap + } else { + bitmap.scale(width, height, true) + } + try { + val out = ByteArrayOutputStream() + if (!working.compress(Bitmap.CompressFormat.JPEG, quality, out)) { + throw IllegalStateException("attachment encode failed") + } + out.toByteArray() + } finally { + if (working !== bitmap) { + working.recycle() + } + } + }, + ) + val base64 = Base64.encodeToString(encoded.bytes, Base64.NO_WRAP) + return PendingImageAttachment( + id = uri.toString() + "#" + System.currentTimeMillis().toString(), + fileName = fileName, + mimeType = "image/jpeg", + base64 = base64, + ) +} + +internal fun decodeBase64Bitmap(base64: String, maxDimension: Int = CHAT_DECODE_MAX_DIMENSION): Bitmap? { + val cacheKey = "$maxDimension:${base64.length}:${base64.hashCode()}" + decodedBitmapCache.get(cacheKey)?.let { return it } + + val bytes = Base64.decode(base64, Base64.DEFAULT) + if (bytes.isEmpty()) return null + + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + val bitmap = + BitmapFactory.decodeByteArray( + bytes, + 0, + bytes.size, + BitmapFactory.Options().apply { + inSampleSize = computeInSampleSize(bounds.outWidth, bounds.outHeight, maxDimension) + inPreferredConfig = Bitmap.Config.RGB_565 + }, + ) ?: return null + + decodedBitmapCache.put(cacheKey, bitmap) + return bitmap +} + +internal fun computeInSampleSize(width: Int, height: Int, maxDimension: Int): Int { + if (width <= 0 || height <= 0 || maxDimension <= 0) return 1 + + var sample = 1 + var longestEdge = max(width, height) + while (longestEdge > maxDimension && sample < 64) { + sample *= 2 + longestEdge = max(width / sample, height / sample) + } + return sample.coerceAtLeast(1) +} + +internal fun normalizeAttachmentFileName(raw: String): String { + val trimmed = raw.trim() + if (trimmed.isEmpty()) return "image.jpg" + val stem = trimmed.substringBeforeLast('.', missingDelimiterValue = trimmed).ifEmpty { "image" } + return "$stem.jpg" +} + +private fun decodeScaledBitmap( + resolver: ContentResolver, + uri: Uri, + maxDimension: Int, +): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + resolver.openInputStream(uri).use { input -> + if (input == null) return null + BitmapFactory.decodeStream(input, null, bounds) + } + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + val decoded = + resolver.openInputStream(uri).use { input -> + if (input == null) return null + BitmapFactory.decodeStream( + input, + null, + BitmapFactory.Options().apply { + inSampleSize = computeInSampleSize(bounds.outWidth, bounds.outHeight, maxDimension) + inPreferredConfig = Bitmap.Config.ARGB_8888 + }, + ) + } ?: return null + + val longestEdge = max(decoded.width, decoded.height) + if (longestEdge <= maxDimension) return decoded + + val scale = maxDimension.toDouble() / longestEdge.toDouble() + val targetWidth = max(1, (decoded.width * scale).roundToInt()) + val targetHeight = max(1, (decoded.height * scale).roundToInt()) + val scaled = decoded.scale(targetWidth, targetHeight, true) + if (scaled !== decoded) { + decoded.recycle() + } + return scaled +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt index 201832b9fd3..2d8fb255baa 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt @@ -1,8 +1,5 @@ package ai.openclaw.app.ui.chat -import android.content.ContentResolver -import android.net.Uri -import android.util.Base64 import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.BorderStroke @@ -47,7 +44,6 @@ import ai.openclaw.app.ui.mobileDanger import ai.openclaw.app.ui.mobileDangerSoft import ai.openclaw.app.ui.mobileText import ai.openclaw.app.ui.mobileTextSecondary -import java.io.ByteArrayOutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -83,7 +79,7 @@ fun ChatSheetContent(viewModel: MainViewModel) { val next = uris.take(8).mapNotNull { uri -> try { - loadImageAttachment(resolver, uri) + loadSizedImageAttachment(resolver, uri) } catch (_: Throwable) { null } @@ -217,24 +213,3 @@ data class PendingImageAttachment( val mimeType: String, val base64: String, ) - -private suspend fun loadImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment { - val mimeType = resolver.getType(uri) ?: "image/*" - val fileName = (uri.lastPathSegment ?: "image").substringAfterLast('/') - val bytes = - withContext(Dispatchers.IO) { - resolver.openInputStream(uri)?.use { input -> - val out = ByteArrayOutputStream() - input.copyTo(out) - out.toByteArray() - } ?: ByteArray(0) - } - if (bytes.isEmpty()) throw IllegalStateException("empty attachment") - val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP) - return PendingImageAttachment( - id = uri.toString() + "#" + System.currentTimeMillis().toString(), - fileName = fileName, - mimeType = mimeType, - base64 = base64, - ) -} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatImageCodecTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatImageCodecTest.kt new file mode 100644 index 00000000000..c3d55e80494 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatImageCodecTest.kt @@ -0,0 +1,18 @@ +package ai.openclaw.app.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ChatImageCodecTest { + @Test + fun computeInSampleSizeCapsLongestEdge() { + assertEquals(4, computeInSampleSize(width = 4032, height = 3024, maxDimension = 1600)) + assertEquals(1, computeInSampleSize(width = 800, height = 600, maxDimension = 1600)) + } + + @Test + fun normalizeAttachmentFileNameForcesJpegExtension() { + assertEquals("photo.jpg", normalizeAttachmentFileName("photo.png")) + assertEquals("image.jpg", normalizeAttachmentFileName("")) + } +} diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index c63572a5e7f..65688a7fc7a 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -8035,7 +8035,21 @@ "storage" ], "label": "Browser Profile Driver", - "help": "Per-profile browser driver mode. Use \"openclaw\" (or legacy \"clawd\") for CDP-based profiles, or use \"existing-session\" for host-local Chrome MCP attachment.", + "help": "Per-profile browser driver mode. Use \"openclaw\" (or legacy \"clawd\") for CDP-based profiles, or use \"existing-session\" for host-local Chrome DevTools MCP attachment.", + "hasChildren": false + }, + { + "path": "browser.profiles.*.userDataDir", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "storage" + ], + "label": "Browser Profile User Data Dir", + "help": "Per-profile Chromium user data directory for existing-session attachment through Chrome DevTools MCP. Use this for host-local Brave, Edge, Chromium, or non-default Chrome profiles when the built-in auto-connect path would pick the wrong browser data directory.", "hasChildren": false }, { @@ -32140,6 +32154,16 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.telegram.accounts.*.silentErrorReplies", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.telegram.accounts.*.streaming", "kind": "channel", @@ -34137,6 +34161,21 @@ "help": "Minimum retry delay in ms for Telegram outbound calls.", "hasChildren": false }, + { + "path": "channels.telegram.silentErrorReplies", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "channels", + "network" + ], + "label": "Telegram Silent Error Replies", + "help": "When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false.", + "hasChildren": false + }, { "path": "channels.telegram.streaming", "kind": "channel", diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index f857ff2d7f4..d8d82d7bb7a 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -1,4 +1,4 @@ -{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5101} +{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5104} {"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -707,7 +707,8 @@ {"recordType":"path","path":"browser.profiles.*.cdpPort","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile CDP Port","help":"Per-profile local CDP port used when connecting to browser instances by port instead of URL. Use unique ports per profile to avoid connection collisions.","hasChildren":false} {"recordType":"path","path":"browser.profiles.*.cdpUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile CDP URL","help":"Per-profile CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.","hasChildren":false} {"recordType":"path","path":"browser.profiles.*.color","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Accent Color","help":"Per-profile accent color for visual differentiation in dashboards and browser-related UI hints. Use distinct colors for high-signal operator recognition of active profiles.","hasChildren":false} -{"recordType":"path","path":"browser.profiles.*.driver","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Driver","help":"Per-profile browser driver mode. Use \"openclaw\" (or legacy \"clawd\") for CDP-based profiles, or use \"existing-session\" for host-local Chrome MCP attachment.","hasChildren":false} +{"recordType":"path","path":"browser.profiles.*.driver","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Driver","help":"Per-profile browser driver mode. Use \"openclaw\" (or legacy \"clawd\") for CDP-based profiles, or use \"existing-session\" for host-local Chrome DevTools MCP attachment.","hasChildren":false} +{"recordType":"path","path":"browser.profiles.*.userDataDir","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile User Data Dir","help":"Per-profile Chromium user data directory for existing-session attachment through Chrome DevTools MCP. Use this for host-local Brave, Edge, Chromium, or non-default Chrome profiles when the built-in auto-connect path would pick the wrong browser data directory.","hasChildren":false} {"recordType":"path","path":"browser.remoteCdpHandshakeTimeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Remote CDP Handshake Timeout (ms)","help":"Timeout in milliseconds for post-connect CDP handshake readiness checks against remote browser targets. Raise this for slow-start remote browsers and lower to fail fast in automation loops.","hasChildren":false} {"recordType":"path","path":"browser.remoteCdpTimeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Remote CDP Timeout (ms)","help":"Timeout in milliseconds for connecting to a remote CDP endpoint before failing the browser attach attempt. Increase for high-latency tunnels, or lower for faster failure detection.","hasChildren":false} {"recordType":"path","path":"browser.snapshotDefaults","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Browser Snapshot Defaults","help":"Default snapshot capture configuration used when callers do not provide explicit snapshot options. Tune this for consistent capture behavior across channels and automation paths.","hasChildren":true} @@ -2903,6 +2904,7 @@ {"recordType":"path","path":"channels.telegram.accounts.*.retry.jitter","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.retry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.retry.minDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.telegram.accounts.*.silentErrorReplies","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.streaming","kind":"channel","type":["boolean","string"],"required":false,"enumValues":["off","partial","block","progress"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.streamMode","kind":"channel","type":"string","required":false,"enumValues":["off","partial","block"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -3080,6 +3082,7 @@ {"recordType":"path","path":"channels.telegram.retry.jitter","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","reliability"],"label":"Telegram Retry Jitter","help":"Jitter factor (0-1) applied to Telegram retry delays.","hasChildren":false} {"recordType":"path","path":"channels.telegram.retry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","performance","reliability"],"label":"Telegram Retry Max Delay (ms)","help":"Maximum retry delay cap in ms for Telegram outbound calls.","hasChildren":false} {"recordType":"path","path":"channels.telegram.retry.minDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","reliability"],"label":"Telegram Retry Min Delay (ms)","help":"Minimum retry delay in ms for Telegram outbound calls.","hasChildren":false} +{"recordType":"path","path":"channels.telegram.silentErrorReplies","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Silent Error Replies","help":"When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false.","hasChildren":false} {"recordType":"path","path":"channels.telegram.streaming","kind":"channel","type":["boolean","string"],"required":false,"enumValues":["off","partial","block","progress"],"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Streaming Mode","help":"Unified Telegram stream preview mode: \"off\" | \"partial\" | \"block\" | \"progress\" (default: \"partial\"). \"progress\" maps to \"partial\" on Telegram. Legacy boolean/streamMode keys are auto-mapped.","hasChildren":false} {"recordType":"path","path":"channels.telegram.streamMode","kind":"channel","type":"string","required":false,"enumValues":["off","partial","block"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} diff --git a/docs/cli/browser.md b/docs/cli/browser.md index 42af08f84f3..c5cb5ab9984 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -91,6 +91,7 @@ Use the built-in `user` profile, or create your own `existing-session` profile: ```bash openclaw browser --browser-profile user tabs openclaw browser create-profile --name chrome-live --driver existing-session +openclaw browser create-profile --name brave-live --driver existing-session --user-data-dir "~/Library/Application Support/BraveSoftware/Brave-Browser" openclaw browser --browser-profile chrome-live tabs ``` diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index a46f342a360..170c0a94219 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2443,6 +2443,12 @@ See [Plugins](/tools/plugin). openclaw: { cdpPort: 18800, color: "#FF4500" }, work: { cdpPort: 18801, color: "#0066CC" }, user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, + brave: { + driver: "existing-session", + attachOnly: true, + userDataDir: "~/Library/Application Support/BraveSoftware/Brave-Browser", + color: "#FB542B", + }, remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" }, }, color: "#FF4500", @@ -2463,6 +2469,8 @@ See [Plugins](/tools/plugin). - In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions. - Remote profiles are attach-only (start/stop/reset disabled). - `existing-session` profiles are host-only and use Chrome MCP instead of CDP. +- `existing-session` profiles can set `userDataDir` to target a specific + Chromium-based browser profile such as Brave or Edge. - Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary. - Control service: loopback only (port derived from `gateway.port`, default `18791`). - `extraArgs` appends extra launch flags to local Chromium startup (for example diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 6c0711c7aea..78430476051 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -155,18 +155,20 @@ normalizes it to the current host-local Chrome MCP attach model: Doctor also audits the host-local Chrome MCP path when you use `defaultProfile: "user"` or a configured `existing-session` profile: -- checks whether Google Chrome is installed on the same host +- checks whether Google Chrome is installed on the same host for default + auto-connect profiles - checks the detected Chrome version and warns when it is below Chrome 144 -- reminds you to enable remote debugging in Chrome at - `chrome://inspect/#remote-debugging` +- reminds you to enable remote debugging in the browser inspect page (for + example `chrome://inspect/#remote-debugging`, `brave://inspect/#remote-debugging`, + or `edge://inspect/#remote-debugging`) Doctor cannot enable the Chrome-side setting for you. Host-local Chrome MCP still requires: -- Google Chrome 144+ on the gateway/node host -- Chrome running locally -- remote debugging enabled in Chrome -- approving the first attach consent prompt in Chrome +- a Chromium-based browser 144+ on the gateway/node host +- the browser running locally +- remote debugging enabled in that browser +- approving the first attach consent prompt in the browser This check does **not** apply to Docker, sandbox, remote-browser, or other headless flows. Those continue to use raw CDP. diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 19ee23a25ca..0b8f89bc3d8 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -88,6 +88,12 @@ Browser settings live in `~/.openclaw/openclaw.json`. attachOnly: true, color: "#00AA00", }, + brave: { + driver: "existing-session", + attachOnly: true, + userDataDir: "~/Library/Application Support/BraveSoftware/Brave-Browser", + color: "#FB542B", + }, remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" }, }, }, @@ -114,6 +120,8 @@ Notes: - Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl` — set those only for remote CDP. - `driver: "existing-session"` uses Chrome DevTools MCP instead of raw CDP. Do not set `cdpUrl` for that driver. +- Set `browser.profiles..userDataDir` when an existing-session profile + should attach to a non-default Chromium user profile such as Brave or Edge. ## Use Brave (or another Chromium-based browser) @@ -289,11 +297,11 @@ Defaults: All control endpoints accept `?profile=`; the CLI uses `--browser-profile`. -## Chrome existing-session via MCP +## Existing-session via Chrome DevTools MCP -OpenClaw can also attach to a running Chrome profile through the official -Chrome DevTools MCP server. This reuses the tabs and login state already open in -that Chrome profile. +OpenClaw can also attach to a running Chromium-based browser profile through the +official Chrome DevTools MCP server. This reuses the tabs and login state +already open in that browser profile. Official background and setup references: @@ -305,13 +313,41 @@ Built-in profile: - `user` Optional: create your own custom existing-session profile if you want a -different name or color. +different name, color, or browser data directory. -Then in Chrome: +Default behavior: -1. Open `chrome://inspect/#remote-debugging` -2. Enable remote debugging -3. Keep Chrome running and approve the connection prompt when OpenClaw attaches +- The built-in `user` profile uses Chrome MCP auto-connect, which targets the + default local Google Chrome profile. + +Use `userDataDir` for Brave, Edge, Chromium, or a non-default Chrome profile: + +```json5 +{ + browser: { + profiles: { + brave: { + driver: "existing-session", + attachOnly: true, + userDataDir: "~/Library/Application Support/BraveSoftware/Brave-Browser", + color: "#FB542B", + }, + }, + }, +} +``` + +Then in the matching browser: + +1. Open that browser's inspect page for remote debugging. +2. Enable remote debugging. +3. Keep the browser running and approve the connection prompt when OpenClaw attaches. + +Common inspect pages: + +- Chrome: `chrome://inspect/#remote-debugging` +- Brave: `brave://inspect/#remote-debugging` +- Edge: `edge://inspect/#remote-debugging` Live attach smoke test: @@ -327,17 +363,17 @@ What success looks like: - `status` shows `driver: existing-session` - `status` shows `transport: chrome-mcp` - `status` shows `running: true` -- `tabs` lists your already-open Chrome tabs +- `tabs` lists your already-open browser tabs - `snapshot` returns refs from the selected live tab What to check if attach does not work: -- Chrome is version `144+` -- remote debugging is enabled at `chrome://inspect/#remote-debugging` -- Chrome showed and you accepted the attach consent prompt +- the target Chromium-based browser is version `144+` +- remote debugging is enabled in that browser's inspect page +- the browser showed and you accepted the attach consent prompt - `openclaw doctor` migrates old extension-based browser config and checks that - Chrome is installed locally with a compatible version, but it cannot enable - Chrome-side remote debugging for you + Chrome is installed locally for default auto-connect profiles, but it cannot + enable browser-side remote debugging for you Agent use: @@ -351,10 +387,11 @@ Notes: - This path is higher-risk than the isolated `openclaw` profile because it can act inside your signed-in browser session. -- OpenClaw does not launch Chrome for this driver; it attaches to an existing - session only. -- OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here, not - the legacy default-profile remote debugging port workflow. +- OpenClaw does not launch the browser for this driver; it attaches to an + existing session only. +- OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here. If + `userDataDir` is set, OpenClaw passes it through to target that explicit + Chromium user data directory. - Existing-session screenshots support page captures and `--ref` element captures from snapshots, but not CSS `--element` selectors. - Existing-session `wait --url` supports exact, substring, and glob patterns diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 770eaa215e0..ec0247c8d72 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -842,6 +842,37 @@ instead of the full plugin entry. This keeps startup and setup lighter when your main plugin entry also wires tools, hooks, or other runtime-only code. +Optional: `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` +can opt a channel plugin into the same `setupEntry` path during the gateway's +pre-listen startup phase, even when the channel is already configured. + +Use this only when `setupEntry` fully covers the startup surface that must exist +before the gateway starts listening. In practice, that means the setup entry +must register every channel-owned capability that startup depends on, such as: + +- channel registration itself +- any HTTP routes that must be available before the gateway starts listening +- any gateway methods, tools, or services that must exist during that same window + +If your full entry still owns any required startup capability, do not enable +this flag. Keep the plugin on the default behavior and let OpenClaw load the +full entry during startup. + +Example: + +```json +{ + "name": "@scope/my-channel", + "openclaw": { + "extensions": ["./index.ts"], + "setupEntry": "./setup-entry.ts", + "startup": { + "deferConfiguredChannelFullLoadUntilAfterListen": true + } + } +} +``` + ### Channel catalog metadata Channel plugins can advertise setup/discovery metadata via `openclaw.channel` and @@ -1752,6 +1783,7 @@ Publishing contract: - Plugin `package.json` must include `openclaw.extensions` with one or more entry files. - Optional: `openclaw.setupEntry` may point at a lightweight setup-only entry for disabled or still-unconfigured channel setup. +- Optional: `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` may opt a channel plugin into using `setupEntry` during pre-listen gateway startup, but only when that setup entry completely covers the plugin's startup-critical surface. - Entry files can be `.js` or `.ts` (jiti loads TS at runtime). - `openclaw plugins install ` uses `npm pack`, extracts into `~/.openclaw/extensions//`, and enables it in config. - Config key stability: scoped packages are normalized to the **unscoped** id for `plugins.entries.*`. diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 49fe53843f3..e745063d8d0 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -21,7 +21,6 @@ import { } from "../../../../src/acp/persistent-bindings.route.js"; import { resolveHumanDelayConfig } from "../../../../src/agents/identity.js"; import { resolveChunkMode, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js"; -import { resolveCommandAuthorization } from "../../../../src/auto-reply/command-auth.js"; import type { ChatCommandDefinition, CommandArgDefinition, @@ -61,8 +60,10 @@ import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { isDiscordGroupAllowedByPolicy, + normalizeDiscordAllowList, normalizeDiscordSlug, resolveDiscordChannelConfigWithFallback, + resolveDiscordAllowListMatch, resolveDiscordGuildEntry, resolveDiscordMemberAccessState, resolveDiscordOwnerAccess, @@ -108,33 +109,26 @@ function resolveDiscordNativeCommandAllowlistAccess(params: { if (!commandsAllowFrom || typeof commandsAllowFrom !== "object") { return { configured: false, allowed: false } as const; } - const configured = - Array.isArray(commandsAllowFrom.discord) || Array.isArray(commandsAllowFrom["*"]); - if (!configured) { + const rawAllowList = Array.isArray(commandsAllowFrom.discord) + ? commandsAllowFrom.discord + : commandsAllowFrom["*"]; + if (!Array.isArray(rawAllowList)) { return { configured: false, allowed: false } as const; } - - const from = - params.chatType === "direct" - ? `discord:${params.sender.id}` - : `discord:${params.chatType}:${params.conversationId ?? "unknown"}`; - const auth = resolveCommandAuthorization({ - ctx: { - Provider: "discord", - Surface: "discord", - OriginatingChannel: "discord", - AccountId: params.accountId ?? undefined, - ChatType: params.chatType, - From: from, - SenderId: params.sender.id, - SenderUsername: params.sender.name, - SenderTag: params.sender.tag, - }, - cfg: params.cfg, - // We only want explicit commands.allowFrom authorization here. - commandAuthorized: false, + const allowList = normalizeDiscordAllowList(rawAllowList.map(String), [ + "discord:", + "user:", + "pk:", + ]); + if (!allowList) { + return { configured: true, allowed: false } as const; + } + const match = resolveDiscordAllowListMatch({ + allowList, + candidate: params.sender, + allowNameMatching: false, }); - return { configured: true, allowed: auth.isAuthorizedSender } as const; + return { configured: true, allowed: match.allowed } as const; } function buildDiscordCommandOptions(params: { diff --git a/extensions/discord/src/subagent-hooks.test.ts b/extensions/discord/src/subagent-hooks.test.ts index 6d5824f69ae..9ba082144e6 100644 --- a/extensions/discord/src/subagent-hooks.test.ts +++ b/extensions/discord/src/subagent-hooks.test.ts @@ -35,8 +35,10 @@ const hookMocks = vi.hoisted(() => ({ unbindThreadBindingsBySessionKey: vi.fn(() => []), })); -vi.mock("openclaw/plugin-sdk/discord", () => ({ +vi.mock("./accounts.js", () => ({ resolveDiscordAccount: hookMocks.resolveDiscordAccount, +})); +vi.mock("./monitor/thread-bindings.js", () => ({ autoBindSpawnedDiscordSubagent: hookMocks.autoBindSpawnedDiscordSubagent, listThreadBindingsBySessionKey: hookMocks.listThreadBindingsBySessionKey, unbindThreadBindingsBySessionKey: hookMocks.unbindThreadBindingsBySessionKey, diff --git a/extensions/telegram/src/bot-native-commands.test-helpers.ts b/extensions/telegram/src/bot-native-commands.test-helpers.ts index 0b4babb180e..33c3f04f904 100644 --- a/extensions/telegram/src/bot-native-commands.test-helpers.ts +++ b/extensions/telegram/src/bot-native-commands.test-helpers.ts @@ -12,6 +12,13 @@ type GetPluginCommandSpecsFn = type MatchPluginCommandFn = typeof import("../../../src/plugins/commands.js").matchPluginCommand; type ExecutePluginCommandFn = typeof import("../../../src/plugins/commands.js").executePluginCommand; +type DispatchReplyWithBufferedBlockDispatcherFn = + typeof import("../../../src/auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher; +type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< + ReturnType +>; +type RecordInboundSessionMetaSafeFn = + typeof import("../../../src/channels/session-meta.js").recordInboundSessionMetaSafe; type AnyMock = MockFn<(...args: unknown[]) => unknown>; type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise>; type NativeCommandHarness = { @@ -43,6 +50,37 @@ vi.mock("../../../src/plugins/commands.js", () => ({ executePluginCommand: pluginCommandMocks.executePluginCommand, })); +const replyPipelineMocks = vi.hoisted(() => { + const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = { + queuedFinal: false, + counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"], + }; + return { + finalizeInboundContext: vi.fn((ctx: unknown) => ctx), + dispatchReplyWithBufferedBlockDispatcher: vi.fn( + async () => dispatchReplyResult, + ), + createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })), + recordInboundSessionMetaSafe: vi.fn(async () => undefined), + }; +}); +export const dispatchReplyWithBufferedBlockDispatcher = + replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher; + +vi.mock("../../../src/auto-reply/reply/inbound-context.js", () => ({ + finalizeInboundContext: replyPipelineMocks.finalizeInboundContext, +})); +vi.mock("../../../src/auto-reply/reply/provider-dispatcher.js", () => ({ + dispatchReplyWithBufferedBlockDispatcher: + replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher, +})); +vi.mock("../../../src/channels/reply-prefix.js", () => ({ + createReplyPrefixOptions: replyPipelineMocks.createReplyPrefixOptions, +})); +vi.mock("../../../src/channels/session-meta.js", () => ({ + recordInboundSessionMetaSafe: replyPipelineMocks.recordInboundSessionMetaSafe, +})); + const deliveryMocks = vi.hoisted(() => ({ deliverReplies: vi.fn(async () => {}), })); diff --git a/src/agents/tools/browser-tool.actions.ts b/src/agents/tools/browser-tool.actions.ts index dc78557eab2..12cb54e323d 100644 --- a/src/agents/tools/browser-tool.actions.ts +++ b/src/agents/tools/browser-tool.actions.ts @@ -347,7 +347,7 @@ export async function executeActAction(params: { } if (!tabs.length) { throw new Error( - `No Chrome tabs found for profile="${profile}". Make sure Chrome (v144+) is running and has open tabs, then retry.`, + `No browser tabs found for profile="${profile}". Make sure the configured Chromium-based browser (v144+) is running and has open tabs, then retry.`, { cause: err }, ); } diff --git a/src/agents/tools/browser-tool.ts b/src/agents/tools/browser-tool.ts index c0111ab9977..f16e7e5d969 100644 --- a/src/agents/tools/browser-tool.ts +++ b/src/agents/tools/browser-tool.ts @@ -307,7 +307,7 @@ export function createBrowserTool(opts?: { description: [ "Control the browser via OpenClaw's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).", "Browser choice: omit profile by default for the isolated OpenClaw-managed browser (`openclaw`).", - 'For the logged-in user browser on the local host, use profile="user". Chrome (v144+) must be running. Use only when existing logins/cookies matter and the user is present.', + 'For the logged-in user browser on the local host, use profile="user". A supported Chromium-based browser (v144+) must be running. Use only when existing logins/cookies matter and the user is present.', 'When a node-hosted browser proxy is available, the tool may auto-route to it. Pin a node with node= or target="node".', "When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).", 'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.', diff --git a/src/browser/chrome-mcp.test.ts b/src/browser/chrome-mcp.test.ts index a77149d7a72..03204cf3b87 100644 --- a/src/browser/chrome-mcp.test.ts +++ b/src/browser/chrome-mcp.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { + buildChromeMcpArgs, evaluateChromeMcpScript, listChromeMcpTabs, openChromeMcpTab, @@ -103,6 +104,18 @@ describe("chrome MCP page parsing", () => { ]); }); + it("adds --userDataDir when an explicit Chromium profile path is configured", () => { + expect(buildChromeMcpArgs("/tmp/brave-profile")).toEqual([ + "-y", + "chrome-devtools-mcp@latest", + "--autoConnect", + "--experimentalStructuredContent", + "--experimental-page-id-routing", + "--userDataDir", + "/tmp/brave-profile", + ]); + }); + it("parses new_page text responses and returns the created tab", async () => { const factory: ChromeMcpSessionFactory = async () => createFakeSession(); setChromeMcpSessionFactoryForTest(factory); @@ -250,6 +263,33 @@ describe("chrome MCP page parsing", () => { expect(tabs).toHaveLength(2); }); + it("creates a fresh session when userDataDir changes for the same profile", async () => { + const createdSessions: ChromeMcpSession[] = []; + const closeMocks: Array> = []; + const factoryCalls: Array<{ profileName: string; userDataDir?: string }> = []; + const factory: ChromeMcpSessionFactory = async (profileName, userDataDir) => { + factoryCalls.push({ profileName, userDataDir }); + const session = createFakeSession(); + const closeMock = vi.fn().mockResolvedValue(undefined); + session.client.close = closeMock as typeof session.client.close; + createdSessions.push(session); + closeMocks.push(closeMock); + return session; + }; + setChromeMcpSessionFactoryForTest(factory); + + await listChromeMcpTabs("chrome-live", "/tmp/brave-a"); + await listChromeMcpTabs("chrome-live", "/tmp/brave-b"); + + expect(factoryCalls).toEqual([ + { profileName: "chrome-live", userDataDir: "/tmp/brave-a" }, + { profileName: "chrome-live", userDataDir: "/tmp/brave-b" }, + ]); + expect(createdSessions).toHaveLength(2); + expect(closeMocks[0]).toHaveBeenCalledTimes(1); + expect(closeMocks[1]).not.toHaveBeenCalled(); + }); + it("clears failed pending sessions so the next call can retry", async () => { let factoryCalls = 0; const factory: ChromeMcpSessionFactory = async () => { diff --git a/src/browser/chrome-mcp.ts b/src/browser/chrome-mcp.ts index a673feb2c27..bc724d2eaea 100644 --- a/src/browser/chrome-mcp.ts +++ b/src/browser/chrome-mcp.ts @@ -26,7 +26,10 @@ type ChromeMcpSession = { ready: Promise; }; -type ChromeMcpSessionFactory = (profileName: string) => Promise; +type ChromeMcpSessionFactory = ( + profileName: string, + userDataDir?: string, +) => Promise; const DEFAULT_CHROME_MCP_COMMAND = "npx"; const DEFAULT_CHROME_MCP_ARGS = [ @@ -168,10 +171,62 @@ function extractJsonMessage(result: ChromeMcpToolResult): unknown { return null; } -async function createRealSession(profileName: string): Promise { +function normalizeChromeMcpUserDataDir(userDataDir?: string): string | undefined { + const trimmed = userDataDir?.trim(); + return trimmed ? trimmed : undefined; +} + +function buildChromeMcpSessionCacheKey(profileName: string, userDataDir?: string): string { + return JSON.stringify([profileName, normalizeChromeMcpUserDataDir(userDataDir) ?? ""]); +} + +function cacheKeyMatchesProfileName(cacheKey: string, profileName: string): boolean { + try { + const parsed = JSON.parse(cacheKey); + return Array.isArray(parsed) && parsed[0] === profileName; + } catch { + return false; + } +} + +async function closeChromeMcpSessionsForProfile( + profileName: string, + keepKey?: string, +): Promise { + let closed = false; + + for (const key of Array.from(pendingSessions.keys())) { + if (key !== keepKey && cacheKeyMatchesProfileName(key, profileName)) { + pendingSessions.delete(key); + closed = true; + } + } + + for (const [key, session] of Array.from(sessions.entries())) { + if (key !== keepKey && cacheKeyMatchesProfileName(key, profileName)) { + sessions.delete(key); + closed = true; + await session.client.close().catch(() => {}); + } + } + + return closed; +} + +export function buildChromeMcpArgs(userDataDir?: string): string[] { + const normalizedUserDataDir = normalizeChromeMcpUserDataDir(userDataDir); + return normalizedUserDataDir + ? [...DEFAULT_CHROME_MCP_ARGS, "--userDataDir", normalizedUserDataDir] + : [...DEFAULT_CHROME_MCP_ARGS]; +} + +async function createRealSession( + profileName: string, + userDataDir?: string, +): Promise { const transport = new StdioClientTransport({ command: DEFAULT_CHROME_MCP_COMMAND, - args: DEFAULT_CHROME_MCP_ARGS, + args: buildChromeMcpArgs(userDataDir), stderr: "pipe", }); const client = new Client( @@ -191,9 +246,12 @@ async function createRealSession(profileName: string): Promise } } catch (err) { await client.close().catch(() => {}); + const targetLabel = userDataDir + ? `the configured Chromium user data dir (${userDataDir})` + : "Google Chrome's default profile"; throw new BrowserProfileUnavailableError( `Chrome MCP existing-session attach failed for profile "${profileName}". ` + - `Make sure Chrome (v144+) is running. ` + + `Make sure ${targetLabel} is running locally with remote debugging enabled. ` + `Details: ${String(err)}`, ); } @@ -206,27 +264,34 @@ async function createRealSession(profileName: string): Promise }; } -async function getSession(profileName: string): Promise { - let session = sessions.get(profileName); +async function getSession(profileName: string, userDataDir?: string): Promise { + const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir); + await closeChromeMcpSessionsForProfile(profileName, cacheKey); + + let session = sessions.get(cacheKey); if (session && session.transport.pid === null) { - sessions.delete(profileName); + sessions.delete(cacheKey); session = undefined; } if (!session) { - let pending = pendingSessions.get(profileName); + let pending = pendingSessions.get(cacheKey); if (!pending) { pending = (async () => { - const created = await (sessionFactory ?? createRealSession)(profileName); - sessions.set(profileName, created); + const created = await (sessionFactory ?? createRealSession)(profileName, userDataDir); + if (pendingSessions.get(cacheKey) === pending) { + sessions.set(cacheKey, created); + } else { + await created.client.close().catch(() => {}); + } return created; })(); - pendingSessions.set(profileName, pending); + pendingSessions.set(cacheKey, pending); } try { session = await pending; } finally { - if (pendingSessions.get(profileName) === pending) { - pendingSessions.delete(profileName); + if (pendingSessions.get(cacheKey) === pending) { + pendingSessions.delete(cacheKey); } } } @@ -234,9 +299,9 @@ async function getSession(profileName: string): Promise { await session.ready; return session; } catch (err) { - const current = sessions.get(profileName); + const current = sessions.get(cacheKey); if (current?.transport === session.transport) { - sessions.delete(profileName); + sessions.delete(cacheKey); } throw err; } @@ -244,10 +309,12 @@ async function getSession(profileName: string): Promise { async function callTool( profileName: string, + userDataDir: string | undefined, name: string, args: Record = {}, ): Promise { - const session = await getSession(profileName); + const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir); + const session = await getSession(profileName, userDataDir); let result: ChromeMcpToolResult; try { result = (await session.client.callTool({ @@ -256,7 +323,7 @@ async function callTool( })) as ChromeMcpToolResult; } catch (err) { // Transport/connection error — tear down session so it reconnects on next call - sessions.delete(profileName); + sessions.delete(cacheKey); await session.client.close().catch(() => {}); throw err; } @@ -278,8 +345,12 @@ async function withTempFile(fn: (filePath: string) => Promise): Promise } } -async function findPageById(profileName: string, pageId: number): Promise { - const pages = await listChromeMcpPages(profileName); +async function findPageById( + profileName: string, + pageId: number, + userDataDir?: string, +): Promise { + const pages = await listChromeMcpPages(profileName, userDataDir); const page = pages.find((entry) => entry.id === pageId); if (!page) { throw new BrowserTabNotFoundError(); @@ -287,43 +358,54 @@ async function findPageById(profileName: string, pageId: number): Promise { - await getSession(profileName); +export async function ensureChromeMcpAvailable( + profileName: string, + userDataDir?: string, +): Promise { + await getSession(profileName, userDataDir); } export function getChromeMcpPid(profileName: string): number | null { - return sessions.get(profileName)?.transport.pid ?? null; + for (const [key, session] of sessions.entries()) { + if (cacheKeyMatchesProfileName(key, profileName)) { + return session.transport.pid ?? null; + } + } + return null; } export async function closeChromeMcpSession(profileName: string): Promise { - pendingSessions.delete(profileName); - const session = sessions.get(profileName); - if (!session) { - return false; - } - sessions.delete(profileName); - await session.client.close().catch(() => {}); - return true; + return await closeChromeMcpSessionsForProfile(profileName); } export async function stopAllChromeMcpSessions(): Promise { - const names = [...sessions.keys()]; + const names = [...new Set([...sessions.keys()].map((key) => JSON.parse(key)[0] as string))]; for (const name of names) { await closeChromeMcpSession(name).catch(() => {}); } } -export async function listChromeMcpPages(profileName: string): Promise { - const result = await callTool(profileName, "list_pages"); +export async function listChromeMcpPages( + profileName: string, + userDataDir?: string, +): Promise { + const result = await callTool(profileName, userDataDir, "list_pages"); return extractStructuredPages(result); } -export async function listChromeMcpTabs(profileName: string): Promise { - return toBrowserTabs(await listChromeMcpPages(profileName)); +export async function listChromeMcpTabs( + profileName: string, + userDataDir?: string, +): Promise { + return toBrowserTabs(await listChromeMcpPages(profileName, userDataDir)); } -export async function openChromeMcpTab(profileName: string, url: string): Promise { - const result = await callTool(profileName, "new_page", { url }); +export async function openChromeMcpTab( + profileName: string, + url: string, + userDataDir?: string, +): Promise { + const result = await callTool(profileName, userDataDir, "new_page", { url }); const pages = extractStructuredPages(result); const chosen = pages.find((page) => page.selected) ?? pages.at(-1); if (!chosen) { @@ -337,38 +419,52 @@ export async function openChromeMcpTab(profileName: string, url: string): Promis }; } -export async function focusChromeMcpTab(profileName: string, targetId: string): Promise { - await callTool(profileName, "select_page", { +export async function focusChromeMcpTab( + profileName: string, + targetId: string, + userDataDir?: string, +): Promise { + await callTool(profileName, userDataDir, "select_page", { pageId: parsePageId(targetId), bringToFront: true, }); } -export async function closeChromeMcpTab(profileName: string, targetId: string): Promise { - await callTool(profileName, "close_page", { pageId: parsePageId(targetId) }); +export async function closeChromeMcpTab( + profileName: string, + targetId: string, + userDataDir?: string, +): Promise { + await callTool(profileName, userDataDir, "close_page", { pageId: parsePageId(targetId) }); } export async function navigateChromeMcpPage(params: { profileName: string; + userDataDir?: string; targetId: string; url: string; timeoutMs?: number; }): Promise<{ url: string }> { - await callTool(params.profileName, "navigate_page", { + await callTool(params.profileName, params.userDataDir, "navigate_page", { pageId: parsePageId(params.targetId), type: "url", url: params.url, ...(typeof params.timeoutMs === "number" ? { timeout: params.timeoutMs } : {}), }); - const page = await findPageById(params.profileName, parsePageId(params.targetId)); + const page = await findPageById( + params.profileName, + parsePageId(params.targetId), + params.userDataDir, + ); return { url: page.url ?? params.url }; } export async function takeChromeMcpSnapshot(params: { profileName: string; + userDataDir?: string; targetId: string; }): Promise { - const result = await callTool(params.profileName, "take_snapshot", { + const result = await callTool(params.profileName, params.userDataDir, "take_snapshot", { pageId: parsePageId(params.targetId), }); return extractSnapshot(result); @@ -376,13 +472,14 @@ export async function takeChromeMcpSnapshot(params: { export async function takeChromeMcpScreenshot(params: { profileName: string; + userDataDir?: string; targetId: string; uid?: string; fullPage?: boolean; format?: "png" | "jpeg"; }): Promise { return await withTempFile(async (filePath) => { - await callTool(params.profileName, "take_screenshot", { + await callTool(params.profileName, params.userDataDir, "take_screenshot", { pageId: parsePageId(params.targetId), filePath, format: params.format ?? "png", @@ -395,11 +492,12 @@ export async function takeChromeMcpScreenshot(params: { export async function clickChromeMcpElement(params: { profileName: string; + userDataDir?: string; targetId: string; uid: string; doubleClick?: boolean; }): Promise { - await callTool(params.profileName, "click", { + await callTool(params.profileName, params.userDataDir, "click", { pageId: parsePageId(params.targetId), uid: params.uid, ...(params.doubleClick ? { dblClick: true } : {}), @@ -408,11 +506,12 @@ export async function clickChromeMcpElement(params: { export async function fillChromeMcpElement(params: { profileName: string; + userDataDir?: string; targetId: string; uid: string; value: string; }): Promise { - await callTool(params.profileName, "fill", { + await callTool(params.profileName, params.userDataDir, "fill", { pageId: parsePageId(params.targetId), uid: params.uid, value: params.value, @@ -421,10 +520,11 @@ export async function fillChromeMcpElement(params: { export async function fillChromeMcpForm(params: { profileName: string; + userDataDir?: string; targetId: string; elements: Array<{ uid: string; value: string }>; }): Promise { - await callTool(params.profileName, "fill_form", { + await callTool(params.profileName, params.userDataDir, "fill_form", { pageId: parsePageId(params.targetId), elements: params.elements, }); @@ -432,10 +532,11 @@ export async function fillChromeMcpForm(params: { export async function hoverChromeMcpElement(params: { profileName: string; + userDataDir?: string; targetId: string; uid: string; }): Promise { - await callTool(params.profileName, "hover", { + await callTool(params.profileName, params.userDataDir, "hover", { pageId: parsePageId(params.targetId), uid: params.uid, }); @@ -443,11 +544,12 @@ export async function hoverChromeMcpElement(params: { export async function dragChromeMcpElement(params: { profileName: string; + userDataDir?: string; targetId: string; fromUid: string; toUid: string; }): Promise { - await callTool(params.profileName, "drag", { + await callTool(params.profileName, params.userDataDir, "drag", { pageId: parsePageId(params.targetId), from_uid: params.fromUid, to_uid: params.toUid, @@ -456,11 +558,12 @@ export async function dragChromeMcpElement(params: { export async function uploadChromeMcpFile(params: { profileName: string; + userDataDir?: string; targetId: string; uid: string; filePath: string; }): Promise { - await callTool(params.profileName, "upload_file", { + await callTool(params.profileName, params.userDataDir, "upload_file", { pageId: parsePageId(params.targetId), uid: params.uid, filePath: params.filePath, @@ -469,10 +572,11 @@ export async function uploadChromeMcpFile(params: { export async function pressChromeMcpKey(params: { profileName: string; + userDataDir?: string; targetId: string; key: string; }): Promise { - await callTool(params.profileName, "press_key", { + await callTool(params.profileName, params.userDataDir, "press_key", { pageId: parsePageId(params.targetId), key: params.key, }); @@ -480,11 +584,12 @@ export async function pressChromeMcpKey(params: { export async function resizeChromeMcpPage(params: { profileName: string; + userDataDir?: string; targetId: string; width: number; height: number; }): Promise { - await callTool(params.profileName, "resize_page", { + await callTool(params.profileName, params.userDataDir, "resize_page", { pageId: parsePageId(params.targetId), width: params.width, height: params.height, @@ -493,11 +598,12 @@ export async function resizeChromeMcpPage(params: { export async function handleChromeMcpDialog(params: { profileName: string; + userDataDir?: string; targetId: string; action: "accept" | "dismiss"; promptText?: string; }): Promise { - await callTool(params.profileName, "handle_dialog", { + await callTool(params.profileName, params.userDataDir, "handle_dialog", { pageId: parsePageId(params.targetId), action: params.action, ...(params.promptText ? { promptText: params.promptText } : {}), @@ -506,11 +612,12 @@ export async function handleChromeMcpDialog(params: { export async function evaluateChromeMcpScript(params: { profileName: string; + userDataDir?: string; targetId: string; fn: string; args?: string[]; }): Promise { - const result = await callTool(params.profileName, "evaluate_script", { + const result = await callTool(params.profileName, params.userDataDir, "evaluate_script", { pageId: parsePageId(params.targetId), function: params.fn, ...(params.args?.length ? { args: params.args } : {}), @@ -520,11 +627,12 @@ export async function evaluateChromeMcpScript(params: { export async function waitForChromeMcpText(params: { profileName: string; + userDataDir?: string; targetId: string; text: string[]; timeoutMs?: number; }): Promise { - await callTool(params.profileName, "wait_for", { + await callTool(params.profileName, params.userDataDir, "wait_for", { pageId: parsePageId(params.targetId), text: params.text, ...(typeof params.timeoutMs === "number" ? { timeout: params.timeoutMs } : {}), diff --git a/src/browser/client.ts b/src/browser/client.ts index 7791b4405be..d7d8690147f 100644 --- a/src/browser/client.ts +++ b/src/browser/client.ts @@ -162,6 +162,7 @@ export type BrowserCreateProfileResult = { transport?: BrowserTransport; cdpPort: number | null; cdpUrl: string | null; + userDataDir: string | null; color: string; isRemote: boolean; }; @@ -172,6 +173,7 @@ export async function browserCreateProfile( name: string; color?: string; cdpUrl?: string; + userDataDir?: string; driver?: "openclaw" | "existing-session"; }, ): Promise { @@ -184,6 +186,7 @@ export async function browserCreateProfile( name: opts.name, color: opts.color, cdpUrl: opts.cdpUrl, + userDataDir: opts.userDataDir, driver: opts.driver, }), timeoutMs: 10000, diff --git a/src/browser/config.test.ts b/src/browser/config.test.ts index 7f80c4389a1..8ca609f13b6 100644 --- a/src/browser/config.test.ts +++ b/src/browser/config.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { withEnv } from "../test-utils/env.js"; +import { resolveUserPath } from "../utils.js"; import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js"; import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; @@ -26,6 +27,7 @@ describe("browser config", () => { expect(user?.driver).toBe("existing-session"); expect(user?.cdpPort).toBe(0); expect(user?.cdpUrl).toBe(""); + expect(user?.userDataDir).toBeUndefined(); // chrome-relay is no longer auto-created expect(resolveProfile(resolved, "chrome-relay")).toBe(null); expect(resolved.remoteCdpTimeoutMs).toBe(1500); @@ -275,9 +277,29 @@ describe("browser config", () => { expect(profile?.cdpPort).toBe(0); expect(profile?.cdpUrl).toBe(""); expect(profile?.cdpIsLoopback).toBe(true); + expect(profile?.userDataDir).toBeUndefined(); expect(profile?.color).toBe("#00AA00"); }); + it("expands tilde-prefixed userDataDir for existing-session profiles", () => { + const resolved = resolveBrowserConfig({ + profiles: { + brave: { + driver: "existing-session", + attachOnly: true, + userDataDir: "~/Library/Application Support/BraveSoftware/Brave-Browser", + color: "#FB542B", + }, + }, + }); + + const profile = resolveProfile(resolved, "brave"); + expect(profile?.driver).toBe("existing-session"); + expect(profile?.userDataDir).toBe( + resolveUserPath("~/Library/Application Support/BraveSoftware/Brave-Browser"), + ); + }); + it("sets usesChromeMcp only for existing-session profiles", () => { const resolved = resolveBrowserConfig({ profiles: { diff --git a/src/browser/config.ts b/src/browser/config.ts index 64fffce865c..a5bc131766a 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -7,6 +7,7 @@ import { } from "../config/port-defaults.js"; import { isLoopbackHost } from "../gateway/net.js"; import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import { resolveUserPath } from "../utils.js"; import { DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_BROWSER_ENABLED, @@ -44,6 +45,7 @@ export type ResolvedBrowserProfile = { cdpUrl: string; cdpHost: string; cdpIsLoopback: boolean; + userDataDir?: string; color: string; driver: "openclaw" | "existing-session"; attachOnly: boolean; @@ -328,6 +330,7 @@ export function resolveProfile( cdpUrl: "", cdpHost: "", cdpIsLoopback: true, + userDataDir: resolveUserPath(profile.userDataDir?.trim() || "") || undefined, color: profile.color, driver, attachOnly: true, diff --git a/src/browser/profiles-service.test.ts b/src/browser/profiles-service.test.ts index b726ad3fbdb..e36ae0ce695 100644 --- a/src/browser/profiles-service.test.ts +++ b/src/browser/profiles-service.test.ts @@ -150,6 +150,7 @@ describe("BrowserProfilesService", () => { expect(result.transport).toBe("chrome-mcp"); expect(result.cdpPort).toBeNull(); expect(result.cdpUrl).toBeNull(); + expect(result.userDataDir).toBeNull(); expect(result.isRemote).toBe(false); expect(state.resolved.profiles["chrome-live"]).toEqual({ driver: "existing-session", @@ -186,6 +187,51 @@ describe("BrowserProfilesService", () => { ).rejects.toThrow(/does not accept cdpUrl/i); }); + it("creates existing-session profiles with an explicit userDataDir", async () => { + const resolved = resolveBrowserConfig({}); + const { ctx, state } = createCtx(resolved); + vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } }); + + const tempDir = fs.mkdtempSync(path.join("/tmp", "openclaw-profile-")); + const userDataDir = path.join(tempDir, "BraveSoftware", "Brave-Browser"); + fs.mkdirSync(userDataDir, { recursive: true }); + + const service = createBrowserProfilesService(ctx); + const result = await service.createProfile({ + name: "brave-live", + driver: "existing-session", + userDataDir, + }); + + expect(result.transport).toBe("chrome-mcp"); + expect(result.userDataDir).toBe(userDataDir); + expect(state.resolved.profiles["brave-live"]).toEqual({ + driver: "existing-session", + attachOnly: true, + userDataDir, + color: expect.any(String), + }); + }); + + it("rejects userDataDir for non-existing-session profiles", async () => { + const resolved = resolveBrowserConfig({}); + const { ctx } = createCtx(resolved); + vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } }); + + const tempDir = fs.mkdtempSync(path.join("/tmp", "openclaw-profile-")); + const userDataDir = path.join(tempDir, "BraveSoftware", "Brave-Browser"); + fs.mkdirSync(userDataDir, { recursive: true }); + + const service = createBrowserProfilesService(ctx); + + await expect( + service.createProfile({ + name: "brave-live", + userDataDir, + }), + ).rejects.toThrow(/driver=existing-session is required/i); + }); + it("deletes remote profiles without stopping or removing local data", async () => { const resolved = resolveBrowserConfig({ profiles: { diff --git a/src/browser/profiles-service.ts b/src/browser/profiles-service.ts index af747015e45..ea1f3b674c6 100644 --- a/src/browser/profiles-service.ts +++ b/src/browser/profiles-service.ts @@ -3,6 +3,7 @@ import path from "node:path"; import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js"; import { loadConfig, writeConfigFile } from "../config/config.js"; import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js"; +import { resolveUserPath } from "../utils.js"; import { resolveOpenClawUserDataDir } from "./chrome.js"; import { parseHttpUrl, resolveProfile } from "./config.js"; import { @@ -26,6 +27,7 @@ export type CreateProfileParams = { name: string; color?: string; cdpUrl?: string; + userDataDir?: string; driver?: "openclaw" | "existing-session"; }; @@ -35,6 +37,7 @@ export type CreateProfileResult = { transport: "cdp" | "chrome-mcp"; cdpPort: number | null; cdpUrl: string | null; + userDataDir: string | null; color: string; isRemote: boolean; }; @@ -79,6 +82,8 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { const createProfile = async (params: CreateProfileParams): Promise => { const name = params.name.trim(); const rawCdpUrl = params.cdpUrl?.trim() || undefined; + const rawUserDataDir = params.userDataDir?.trim() || undefined; + const normalizedUserDataDir = rawUserDataDir ? resolveUserPath(rawUserDataDir) : undefined; const driver = params.driver === "existing-session" ? "existing-session" : undefined; if (!isValidProfileName(name)) { @@ -104,6 +109,17 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { params.color && HEX_COLOR_RE.test(params.color) ? params.color : allocateColor(usedColors); let profileConfig: BrowserProfileConfig; + if (normalizedUserDataDir && driver !== "existing-session") { + throw new BrowserValidationError( + "driver=existing-session is required when userDataDir is provided", + ); + } + if (normalizedUserDataDir && !fs.existsSync(normalizedUserDataDir)) { + throw new BrowserValidationError( + `browser user data directory not found: ${normalizedUserDataDir}`, + ); + } + if (rawCdpUrl) { let parsed: ReturnType; try { @@ -127,6 +143,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { profileConfig = { driver, attachOnly: true, + ...(normalizedUserDataDir ? { userDataDir: normalizedUserDataDir } : {}), color: profileColor, }; } else { @@ -170,6 +187,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp", cdpPort: capabilities.usesChromeMcp ? null : resolved.cdpPort, cdpUrl: capabilities.usesChromeMcp ? null : resolved.cdpUrl, + userDataDir: resolved.userDataDir ?? null, color: resolved.color, isRemote: !resolved.cdpIsLoopback, }; diff --git a/src/browser/resolved-config-refresh.ts b/src/browser/resolved-config-refresh.ts index 999a7ca1229..1d20eecec94 100644 --- a/src/browser/resolved-config-refresh.ts +++ b/src/browser/resolved-config-refresh.ts @@ -22,6 +22,9 @@ function changedProfileInvariants( if (current.cdpIsLoopback !== next.cdpIsLoopback) { changed.push("cdpIsLoopback"); } + if ((current.userDataDir ?? "") !== (next.userDataDir ?? "")) { + changed.push("userDataDir"); + } return changed; } diff --git a/src/browser/routes/agent.act.hooks.ts b/src/browser/routes/agent.act.hooks.ts index a141a9cbe5a..a55e2f9b21e 100644 --- a/src/browser/routes/agent.act.hooks.ts +++ b/src/browser/routes/agent.act.hooks.ts @@ -65,6 +65,7 @@ export function registerBrowserAgentActHookRoutes( } await uploadChromeMcpFile({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, uid, filePath: resolvedPaths[0] ?? "", @@ -134,6 +135,7 @@ export function registerBrowserAgentActHookRoutes( } await evaluateChromeMcpScript({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, fn: `() => { const state = (window.__openclawDialogHook ??= {}); diff --git a/src/browser/routes/agent.act.ts b/src/browser/routes/agent.act.ts index 1b444d1b963..af0d8e40794 100644 --- a/src/browser/routes/agent.act.ts +++ b/src/browser/routes/agent.act.ts @@ -78,6 +78,7 @@ function buildExistingSessionWaitPredicate(params: { async function waitForExistingSessionCondition(params: { profileName: string; + userDataDir?: string; targetId: string; timeMs?: number; text?: string; @@ -103,6 +104,7 @@ async function waitForExistingSessionCondition(params: { ready = Boolean( await evaluateChromeMcpScript({ profileName: params.profileName, + userDataDir: params.userDataDir, targetId: params.targetId, fn: `async () => ${predicate}`, }), @@ -111,6 +113,7 @@ async function waitForExistingSessionCondition(params: { if (ready && params.url) { const currentUrl = await evaluateChromeMcpScript({ profileName: params.profileName, + userDataDir: params.userDataDir, targetId: params.targetId, fn: "() => window.location.href", }); @@ -520,6 +523,7 @@ export function registerBrowserAgentActRoutes( } await clickChromeMcpElement({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, uid: ref!, doubleClick, @@ -586,6 +590,7 @@ export function registerBrowserAgentActRoutes( } await fillChromeMcpElement({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, uid: ref!, value: text, @@ -593,6 +598,7 @@ export function registerBrowserAgentActRoutes( if (submit) { await pressChromeMcpKey({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, key: "Enter", }); @@ -632,7 +638,12 @@ export function registerBrowserAgentActRoutes( if (delayMs) { return jsonError(res, 501, "existing-session press does not support delayMs."); } - await pressChromeMcpKey({ profileName, targetId: tab.targetId, key }); + await pressChromeMcpKey({ + profileName, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + key, + }); return res.json({ ok: true, targetId: tab.targetId }); } const pw = await requirePwAi(res, `act:${kind}`); @@ -669,7 +680,12 @@ export function registerBrowserAgentActRoutes( "existing-session hover does not support timeoutMs overrides.", ); } - await hoverChromeMcpElement({ profileName, targetId: tab.targetId, uid: ref! }); + await hoverChromeMcpElement({ + profileName, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + uid: ref!, + }); return res.json({ ok: true, targetId: tab.targetId }); } const pw = await requirePwAi(res, `act:${kind}`); @@ -709,6 +725,7 @@ export function registerBrowserAgentActRoutes( } await evaluateChromeMcpScript({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, fn: `(el) => { el.scrollIntoView({ block: "center", inline: "center" }); return true; }`, args: [ref!], @@ -764,6 +781,7 @@ export function registerBrowserAgentActRoutes( } await dragChromeMcpElement({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, fromUid: startRef!, toUid: endRef!, @@ -817,6 +835,7 @@ export function registerBrowserAgentActRoutes( } await fillChromeMcpElement({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, uid: ref!, value: values[0] ?? "", @@ -861,6 +880,7 @@ export function registerBrowserAgentActRoutes( } await fillChromeMcpForm({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, elements: fields.map((field) => ({ uid: field.ref, @@ -890,6 +910,7 @@ export function registerBrowserAgentActRoutes( if (isExistingSession) { await resizeChromeMcpPage({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, width, height, @@ -951,6 +972,7 @@ export function registerBrowserAgentActRoutes( } await waitForExistingSessionCondition({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, timeMs, text, @@ -1001,6 +1023,7 @@ export function registerBrowserAgentActRoutes( } const result = await evaluateChromeMcpScript({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, fn, args: ref ? [ref] : undefined, @@ -1036,7 +1059,7 @@ export function registerBrowserAgentActRoutes( } case "close": { if (isExistingSession) { - await closeChromeMcpTab(profileName, tab.targetId); + await closeChromeMcpTab(profileName, tab.targetId, profileCtx.profile.userDataDir); return res.json({ ok: true, targetId: tab.targetId }); } const pw = await requirePwAi(res, `act:${kind}`); @@ -1151,6 +1174,7 @@ export function registerBrowserAgentActRoutes( if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { await evaluateChromeMcpScript({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, args: [ref], fn: `(el) => { diff --git a/src/browser/routes/agent.snapshot.ts b/src/browser/routes/agent.snapshot.ts index 80c11693a11..7cb73049389 100644 --- a/src/browser/routes/agent.snapshot.ts +++ b/src/browser/routes/agent.snapshot.ts @@ -44,10 +44,12 @@ const CHROME_MCP_OVERLAY_ATTR = "data-openclaw-mcp-overlay"; async function clearChromeMcpOverlay(params: { profileName: string; + userDataDir?: string; targetId: string; }): Promise { await evaluateChromeMcpScript({ profileName: params.profileName, + userDataDir: params.userDataDir, targetId: params.targetId, fn: `() => { document.querySelectorAll("[${CHROME_MCP_OVERLAY_ATTR}]").forEach((node) => node.remove()); @@ -58,12 +60,14 @@ async function clearChromeMcpOverlay(params: { async function renderChromeMcpLabels(params: { profileName: string; + userDataDir?: string; targetId: string; refs: string[]; }): Promise<{ labels: number; skipped: number }> { const refList = JSON.stringify(params.refs); const result = await evaluateChromeMcpScript({ profileName: params.profileName, + userDataDir: params.userDataDir, targetId: params.targetId, args: params.refs, fn: `(...elements) => { @@ -231,6 +235,7 @@ export function registerBrowserAgentSnapshotRoutes( await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); const result = await navigateChromeMcpPage({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, url, }); @@ -322,6 +327,7 @@ export function registerBrowserAgentSnapshotRoutes( } const buffer = await takeChromeMcpScreenshot({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, uid: ref, fullPage, @@ -406,6 +412,7 @@ export function registerBrowserAgentSnapshotRoutes( } const snapshot = await takeChromeMcpSnapshot({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, }); if (plan.format === "aria") { @@ -430,12 +437,14 @@ export function registerBrowserAgentSnapshotRoutes( const refs = Object.keys(built.refs); const labelResult = await renderChromeMcpLabels({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, refs, }); try { const labeled = await takeChromeMcpScreenshot({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, format: "png", }); @@ -465,6 +474,7 @@ export function registerBrowserAgentSnapshotRoutes( } finally { await clearChromeMcpOverlay({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, }); } diff --git a/src/browser/routes/basic.existing-session.test.ts b/src/browser/routes/basic.existing-session.test.ts index 34bcd9ee00b..b96596c6fbe 100644 --- a/src/browser/routes/basic.existing-session.test.ts +++ b/src/browser/routes/basic.existing-session.test.ts @@ -27,6 +27,7 @@ describe("basic browser routes", () => { driver: "existing-session", cdpPort: 0, cdpUrl: "", + userDataDir: "/tmp/brave-profile", color: "#00AA00", attachOnly: true, }, @@ -66,6 +67,7 @@ describe("basic browser routes", () => { driver: "existing-session", cdpPort: 0, cdpUrl: "", + userDataDir: "/tmp/brave-profile", color: "#00AA00", attachOnly: true, }, @@ -88,6 +90,7 @@ describe("basic browser routes", () => { running: true, cdpPort: null, cdpUrl: null, + userDataDir: "/tmp/brave-profile", pid: 4321, }); }); diff --git a/src/browser/routes/basic.ts b/src/browser/routes/basic.ts index c4f5db47a59..b781bc62694 100644 --- a/src/browser/routes/basic.ts +++ b/src/browser/routes/basic.ts @@ -112,7 +112,7 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow detectedBrowser, detectedExecutablePath, detectError, - userDataDir: profileState?.running?.userDataDir ?? null, + userDataDir: profileState?.running?.userDataDir ?? profileCtx.profile.userDataDir ?? null, color: profileCtx.profile.color, headless: current.resolved.headless, noSandbox: current.resolved.noSandbox, @@ -176,6 +176,7 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow const name = toStringOrEmpty((req.body as { name?: unknown })?.name); const color = toStringOrEmpty((req.body as { color?: unknown })?.color); const cdpUrl = toStringOrEmpty((req.body as { cdpUrl?: unknown })?.cdpUrl); + const userDataDir = toStringOrEmpty((req.body as { userDataDir?: unknown })?.userDataDir); const driver = toStringOrEmpty((req.body as { driver?: unknown })?.driver); if (!name) { @@ -197,6 +198,7 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow name, color: color || undefined, cdpUrl: cdpUrl || undefined, + userDataDir: userDataDir || undefined, driver: driver === "existing-session" ? "existing-session" diff --git a/src/browser/server-context.availability.ts b/src/browser/server-context.availability.ts index d7d33fd0fde..6630c17a4c0 100644 --- a/src/browser/server-context.availability.ts +++ b/src/browser/server-context.availability.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import { PROFILE_ATTACH_RETRY_TIMEOUT_MS, PROFILE_POST_RESTART_WS_TIMEOUT_MS, @@ -63,7 +64,7 @@ export function createProfileAvailability({ const isReachable = async (timeoutMs?: number) => { if (capabilities.usesChromeMcp) { // listChromeMcpTabs creates the session if needed — no separate ensureChromeMcpAvailable call required - await listChromeMcpTabs(profile.name); + await listChromeMcpTabs(profile.name, profile.userDataDir); return true; } const { httpTimeoutMs, wsTimeoutMs } = resolveTimeouts(timeoutMs); @@ -153,7 +154,12 @@ export function createProfileAvailability({ const ensureBrowserAvailable = async (): Promise => { await reconcileProfileRuntime(); if (capabilities.usesChromeMcp) { - await ensureChromeMcpAvailable(profile.name); + if (profile.userDataDir && !fs.existsSync(profile.userDataDir)) { + throw new BrowserProfileUnavailableError( + `Browser user data directory not found for profile "${profile.name}": ${profile.userDataDir}`, + ); + } + await ensureChromeMcpAvailable(profile.name, profile.userDataDir); return; } const current = state(); diff --git a/src/browser/server-context.existing-session.test.ts b/src/browser/server-context.existing-session.test.ts index abbd222342e..7092bbf1fd9 100644 --- a/src/browser/server-context.existing-session.test.ts +++ b/src/browser/server-context.existing-session.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createBrowserRouteContext } from "./server-context.js"; import type { BrowserServerState } from "./server-context.js"; @@ -47,6 +48,7 @@ function makeState(): BrowserServerState { color: "#0066CC", driver: "existing-session", attachOnly: true, + userDataDir: "/tmp/brave-profile", }, }, extraArgs: [], @@ -62,6 +64,7 @@ afterEach(() => { describe("browser server-context existing-session profile", () => { it("routes tab operations through the Chrome MCP backend", async () => { + fs.mkdirSync("/tmp/brave-profile", { recursive: true }); const state = makeState(); const ctx = createBrowserRouteContext({ getState: () => state }); const live = ctx.forProfile("chrome-live"); @@ -93,10 +96,21 @@ describe("browser server-context existing-session profile", () => { await live.focusTab("7"); await live.stopRunningBrowser(); - expect(chromeMcp.ensureChromeMcpAvailable).toHaveBeenCalledWith("chrome-live"); - expect(chromeMcp.listChromeMcpTabs).toHaveBeenCalledWith("chrome-live"); - expect(chromeMcp.openChromeMcpTab).toHaveBeenCalledWith("chrome-live", "https://openclaw.ai"); - expect(chromeMcp.focusChromeMcpTab).toHaveBeenCalledWith("chrome-live", "7"); + expect(chromeMcp.ensureChromeMcpAvailable).toHaveBeenCalledWith( + "chrome-live", + "/tmp/brave-profile", + ); + expect(chromeMcp.listChromeMcpTabs).toHaveBeenCalledWith("chrome-live", "/tmp/brave-profile"); + expect(chromeMcp.openChromeMcpTab).toHaveBeenCalledWith( + "chrome-live", + "https://openclaw.ai", + "/tmp/brave-profile", + ); + expect(chromeMcp.focusChromeMcpTab).toHaveBeenCalledWith( + "chrome-live", + "7", + "/tmp/brave-profile", + ); expect(chromeMcp.closeChromeMcpSession).toHaveBeenCalledWith("chrome-live"); }); }); diff --git a/src/browser/server-context.selection.ts b/src/browser/server-context.selection.ts index 1a744e06b09..24248cebfd8 100644 --- a/src/browser/server-context.selection.ts +++ b/src/browser/server-context.selection.ts @@ -94,7 +94,7 @@ export function createProfileSelectionOps({ const resolvedTargetId = await resolveTargetIdOrThrow(targetId); if (capabilities.usesChromeMcp) { - await focusChromeMcpTab(profile.name, resolvedTargetId); + await focusChromeMcpTab(profile.name, resolvedTargetId, profile.userDataDir); const profileState = getProfileState(); profileState.lastTargetId = resolvedTargetId; return; @@ -124,7 +124,7 @@ export function createProfileSelectionOps({ const resolvedTargetId = await resolveTargetIdOrThrow(targetId); if (capabilities.usesChromeMcp) { - await closeChromeMcpTab(profile.name, resolvedTargetId); + await closeChromeMcpTab(profile.name, resolvedTargetId, profile.userDataDir); return; } diff --git a/src/browser/server-context.tab-ops.ts b/src/browser/server-context.tab-ops.ts index 66a134564c6..747082a7ff5 100644 --- a/src/browser/server-context.tab-ops.ts +++ b/src/browser/server-context.tab-ops.ts @@ -67,7 +67,7 @@ export function createProfileTabOps({ const listTabs = async (): Promise => { if (capabilities.usesChromeMcp) { - return await listChromeMcpTabs(profile.name); + return await listChromeMcpTabs(profile.name, profile.userDataDir); } if (capabilities.usesPersistentPlaywright) { @@ -141,7 +141,7 @@ export function createProfileTabOps({ if (capabilities.usesChromeMcp) { await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); - const page = await openChromeMcpTab(profile.name, url); + const page = await openChromeMcpTab(profile.name, url, profile.userDataDir); const profileState = getProfileState(); profileState.lastTargetId = page.targetId; await assertBrowserNavigationResultAllowed({ url: page.url, ...ssrfPolicyOpts }); diff --git a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts index 5ad1d5f7bd2..8b997b8ac30 100644 --- a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts +++ b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import { fetch as realFetch } from "undici"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -126,10 +127,47 @@ describe("profile CRUD endpoints", () => { profile?: string; transport?: string; cdpPort?: number | null; + userDataDir?: string | null; }; expect(createClawdBody.profile).toBe("legacyclawd"); expect(createClawdBody.transport).toBe("cdp"); expect(createClawdBody.cdpPort).toBeTypeOf("number"); + expect(createClawdBody.userDataDir).toBeNull(); + + const explicitUserDataDir = "/tmp/openclaw-brave-profile"; + await fs.promises.mkdir(explicitUserDataDir, { recursive: true }); + const createExistingSession = await realFetch(`${base}/profiles/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "brave-live", + driver: "existing-session", + userDataDir: explicitUserDataDir, + }), + }); + expect(createExistingSession.status).toBe(200); + const createExistingSessionBody = (await createExistingSession.json()) as { + profile?: string; + transport?: string; + userDataDir?: string | null; + }; + expect(createExistingSessionBody.profile).toBe("brave-live"); + expect(createExistingSessionBody.transport).toBe("chrome-mcp"); + expect(createExistingSessionBody.userDataDir).toBe(explicitUserDataDir); + + const createBadExistingSession = await realFetch(`${base}/profiles/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "bad-live", + userDataDir: explicitUserDataDir, + }), + }); + expect(createBadExistingSession.status).toBe(400); + const createBadExistingSessionBody = (await createBadExistingSession.json()) as { + error: string; + }; + expect(createBadExistingSessionBody.error).toContain("driver=existing-session is required"); const createLegacyDriver = await realFetch(`${base}/profiles/create`, { method: "POST", diff --git a/src/cli/browser-cli-manage.test.ts b/src/cli/browser-cli-manage.test.ts index deeb0d9e73a..86c10ac75ae 100644 --- a/src/cli/browser-cli-manage.test.ts +++ b/src/cli/browser-cli-manage.test.ts @@ -91,6 +91,42 @@ describe("browser manage output", () => { expect(output).not.toContain("cdpUrl:"); }); + it("shows configured userDataDir for existing-session status", async () => { + mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) => + req.path === "/" + ? { + enabled: true, + profile: "brave-live", + driver: "existing-session", + transport: "chrome-mcp", + running: true, + cdpReady: true, + cdpHttp: true, + pid: 4321, + cdpPort: null, + cdpUrl: null, + chosenBrowser: null, + userDataDir: "/Users/test/Library/Application Support/BraveSoftware/Brave-Browser", + color: "#FB542B", + headless: false, + noSandbox: false, + executablePath: null, + attachOnly: true, + } + : {}, + ); + + const program = createProgram(); + await program.parseAsync(["browser", "--browser-profile", "brave-live", "status"], { + from: "user", + }); + + const output = mocks.runtimeLog.mock.calls.at(-1)?.[0] as string; + expect(output).toContain( + "userDataDir: /Users/test/Library/Application Support/BraveSoftware/Brave-Browser", + ); + }); + it("shows chrome-mcp transport in browser profiles output", async () => { mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) => req.path === "/profiles" @@ -131,6 +167,7 @@ describe("browser manage output", () => { transport: "chrome-mcp", cdpPort: null, cdpUrl: null, + userDataDir: null, color: "#00AA00", isRemote: false, } diff --git a/src/cli/browser-cli-manage.ts b/src/cli/browser-cli-manage.ts index e13b7af003a..1c096b1a73b 100644 --- a/src/cli/browser-cli-manage.ts +++ b/src/cli/browser-cli-manage.ts @@ -116,9 +116,13 @@ function formatBrowserConnectionSummary(params: { isRemote?: boolean; cdpPort?: number | null; cdpUrl?: string | null; + userDataDir?: string | null; }): string { if (usesChromeMcpTransport(params)) { - return "transport: chrome-mcp"; + const userDataDir = params.userDataDir ? shortenHomePath(params.userDataDir) : null; + return userDataDir + ? `transport: chrome-mcp, userDataDir: ${userDataDir}` + : "transport: chrome-mcp"; } if (params.isRemote) { return `cdpUrl: ${params.cdpUrl ?? "(unset)"}`; @@ -155,7 +159,9 @@ export function registerBrowserManageCommands( `cdpPort: ${status.cdpPort ?? "(unset)"}`, `cdpUrl: ${redactCdpUrl(status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`)}`, ] - : []), + : status.userDataDir + ? [`userDataDir: ${shortenHomePath(status.userDataDir)}`] + : []), `browser: ${status.chosenBrowser ?? "unknown"}`, `detectedBrowser: ${status.detectedBrowser ?? "unknown"}`, `detectedPath: ${detectedDisplay}`, @@ -455,9 +461,19 @@ export function registerBrowserManageCommands( .requiredOption("--name ", "Profile name (lowercase, numbers, hyphens)") .option("--color ", "Profile color (hex format, e.g. #0066CC)") .option("--cdp-url ", "CDP URL for remote Chrome (http/https)") + .option("--user-data-dir ", "User data dir for existing-session Chromium attach") .option("--driver ", "Profile driver (openclaw|existing-session). Default: openclaw") .action( - async (opts: { name: string; color?: string; cdpUrl?: string; driver?: string }, cmd) => { + async ( + opts: { + name: string; + color?: string; + cdpUrl?: string; + userDataDir?: string; + driver?: string; + }, + cmd, + ) => { const parent = parentOpts(cmd); await runBrowserCommand(async () => { const result = await callBrowserRequest( @@ -469,6 +485,7 @@ export function registerBrowserManageCommands( name: opts.name, color: opts.color, cdpUrl: opts.cdpUrl, + userDataDir: opts.userDataDir, driver: opts.driver === "existing-session" ? "existing-session" : undefined, }, }, @@ -481,8 +498,8 @@ export function registerBrowserManageCommands( defaultRuntime.log( info( `🦞 Created profile "${result.profile}"\n${loc}\n color: ${result.color}${ - opts.driver === "existing-session" ? "\n driver: existing-session" : "" - }`, + result.userDataDir ? `\n userDataDir: ${shortenHomePath(result.userDataDir)}` : "" + }${opts.driver === "existing-session" ? "\n driver: existing-session" : ""}`, ), ); }); diff --git a/src/cli/plugin-registry.ts b/src/cli/plugin-registry.ts index f51a57d7fda..bff91129204 100644 --- a/src/cli/plugin-registry.ts +++ b/src/cli/plugin-registry.ts @@ -1,9 +1,11 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { listPotentialConfiguredChannelIds } from "../channels/config-presence.js"; import { loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging.js"; +import { + resolveChannelPluginIds, + resolveConfiguredChannelPluginIds, +} from "../plugins/channel-plugin-ids.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; -import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; import type { PluginLogger } from "../plugins/types.js"; @@ -25,34 +27,6 @@ function scopeRank(scope: typeof pluginRegistryLoaded): number { } } -function resolveChannelPluginIds(params: { - config: ReturnType; - workspaceDir?: string; - env: NodeJS.ProcessEnv; -}): string[] { - return loadPluginManifestRegistry({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }) - .plugins.filter((plugin) => plugin.channels.length > 0) - .map((plugin) => plugin.id); -} - -function resolveConfiguredChannelPluginIds(params: { - config: ReturnType; - workspaceDir?: string; - env: NodeJS.ProcessEnv; -}): string[] { - const configuredChannelIds = new Set( - listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()), - ); - if (configuredChannelIds.size === 0) { - return []; - } - return resolveChannelPluginIds(params).filter((pluginId) => configuredChannelIds.has(pluginId)); -} - export function ensurePluginRegistryLoaded(options?: { scope?: PluginRegistryScope }): void { const scope = options?.scope ?? "all"; if (scopeRank(pluginRegistryLoaded) >= scopeRank(scope)) { diff --git a/src/commands/doctor-browser.test.ts b/src/commands/doctor-browser.test.ts index da59fe5ed9a..948562eaf17 100644 --- a/src/commands/doctor-browser.test.ts +++ b/src/commands/doctor-browser.test.ts @@ -36,7 +36,7 @@ describe("doctor browser readiness", () => { expect(noteFn).toHaveBeenCalledTimes(1); expect(String(noteFn.mock.calls[0]?.[0])).toContain("Google Chrome was not found"); - expect(String(noteFn.mock.calls[0]?.[0])).toContain("chrome://inspect/#remote-debugging"); + expect(String(noteFn.mock.calls[0]?.[0])).toContain("brave://inspect/#remote-debugging"); }); it("warns when detected Chrome is too old for Chrome MCP", async () => { @@ -93,4 +93,31 @@ describe("doctor browser readiness", () => { "Detected Chrome Google Chrome 144.0.7534.0", ); }); + + it("skips Chrome auto-detection when profiles use explicit userDataDir", async () => { + const noteFn = vi.fn(); + await noteChromeMcpBrowserReadiness( + { + browser: { + profiles: { + braveLive: { + driver: "existing-session", + userDataDir: "/Users/test/Library/Application Support/BraveSoftware/Brave-Browser", + color: "#FB542B", + }, + }, + }, + }, + { + noteFn, + resolveChromeExecutable: () => { + throw new Error("should not look up Chrome"); + }, + }, + ); + + expect(noteFn).toHaveBeenCalledTimes(1); + expect(String(noteFn.mock.calls[0]?.[0])).toContain("explicit Chromium user data directory"); + expect(String(noteFn.mock.calls[0]?.[0])).toContain("brave://inspect/#remote-debugging"); + }); }); diff --git a/src/commands/doctor-browser.ts b/src/commands/doctor-browser.ts index 482e370b052..028bfc50fb0 100644 --- a/src/commands/doctor-browser.ts +++ b/src/commands/doctor-browser.ts @@ -7,6 +7,11 @@ import type { OpenClawConfig } from "../config/config.js"; import { note } from "../terminal/note.js"; const CHROME_MCP_MIN_MAJOR = 144; +const REMOTE_DEBUGGING_PAGES = [ + "chrome://inspect/#remote-debugging", + "brave://inspect/#remote-debugging", + "edge://inspect/#remote-debugging", +].join(", "); function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) @@ -14,33 +19,40 @@ function asRecord(value: unknown): Record | null { : null; } -function collectChromeMcpProfileNames(cfg: OpenClawConfig): string[] { +type ExistingSessionProfile = { + name: string; + userDataDir?: string; +}; + +function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[] { const browser = asRecord(cfg.browser); if (!browser) { return []; } - const names = new Set(); + const profiles = new Map(); const defaultProfile = typeof browser.defaultProfile === "string" ? browser.defaultProfile.trim() : ""; if (defaultProfile === "user") { - names.add("user"); + profiles.set("user", { name: "user" }); } - const profiles = asRecord(browser.profiles); - if (!profiles) { - return [...names]; + const configuredProfiles = asRecord(browser.profiles); + if (!configuredProfiles) { + return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name)); } - for (const [profileName, rawProfile] of Object.entries(profiles)) { + for (const [profileName, rawProfile] of Object.entries(configuredProfiles)) { const profile = asRecord(rawProfile); const driver = typeof profile?.driver === "string" ? profile.driver.trim() : ""; if (driver === "existing-session") { - names.add(profileName); + const userDataDir = + typeof profile?.userDataDir === "string" ? profile.userDataDir.trim() : undefined; + profiles.set(profileName, { name: profileName, userDataDir: userDataDir || undefined }); } } - return [...names].toSorted((a, b) => a.localeCompare(b)); + return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name)); } export async function noteChromeMcpBrowserReadiness( @@ -52,7 +64,7 @@ export async function noteChromeMcpBrowserReadiness( readVersion?: (executablePath: string) => string | null; }, ) { - const profiles = collectChromeMcpProfileNames(cfg); + const profiles = collectChromeMcpProfiles(cfg); if (profiles.length === 0) { return; } @@ -62,24 +74,47 @@ export async function noteChromeMcpBrowserReadiness( const resolveChromeExecutable = deps?.resolveChromeExecutable ?? resolveGoogleChromeExecutableForPlatform; const readVersion = deps?.readVersion ?? readBrowserVersion; - const chrome = resolveChromeExecutable(platform); - const profileLabel = profiles.join(", "); + const explicitProfiles = profiles.filter((profile) => profile.userDataDir); + const autoConnectProfiles = profiles.filter((profile) => !profile.userDataDir); + const profileLabel = profiles.map((profile) => profile.name).join(", "); - if (!chrome) { + if (autoConnectProfiles.length === 0) { noteFn( [ `- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`, - "- Google Chrome was not found on this host. OpenClaw does not bundle Chrome.", - `- Install Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node.`, - "- In Chrome, enable remote debugging at chrome://inspect/#remote-debugging.", - "- Keep Chrome running and accept the attach consent prompt the first time OpenClaw connects.", - "- Docker, headless, and sandbox browser flows stay on raw CDP; this check only applies to host-local Chrome MCP attach.", + "- These profiles use an explicit Chromium user data directory instead of Chrome's default auto-connect path.", + `- Verify the matching Chromium-based browser is version ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node.`, + `- Enable remote debugging in that browser's inspect page (${REMOTE_DEBUGGING_PAGES}).`, + "- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.", ].join("\n"), "Browser", ); return; } + const chrome = resolveChromeExecutable(platform); + const autoProfileLabel = autoConnectProfiles.map((profile) => profile.name).join(", "); + + if (!chrome) { + const lines = [ + `- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`, + `- Google Chrome was not found on this host for auto-connect profile(s): ${autoProfileLabel}. OpenClaw does not bundle Chrome.`, + `- Install Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node, or set browser.profiles..userDataDir for a different Chromium-based browser.`, + `- Enable remote debugging in the browser inspect page (${REMOTE_DEBUGGING_PAGES}).`, + "- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.", + "- Docker, headless, and sandbox browser flows stay on raw CDP; this check only applies to host-local Chrome MCP attach.", + ]; + if (explicitProfiles.length > 0) { + lines.push( + `- Profiles with explicit userDataDir skip Chrome auto-detection: ${explicitProfiles + .map((profile) => profile.name) + .join(", ")}.`, + ); + } + noteFn(lines.join("\n"), "Browser"); + return; + } + const versionRaw = readVersion(chrome.path); const major = parseBrowserMajorVersion(versionRaw); const lines = [ @@ -99,10 +134,17 @@ export async function noteChromeMcpBrowserReadiness( lines.push(`- Detected Chrome ${versionRaw}.`); } - lines.push("- In Chrome, enable remote debugging at chrome://inspect/#remote-debugging."); + lines.push(`- Enable remote debugging in the browser inspect page (${REMOTE_DEBUGGING_PAGES}).`); lines.push( - "- Keep Chrome running and accept the attach consent prompt the first time OpenClaw connects.", + "- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.", ); + if (explicitProfiles.length > 0) { + lines.push( + `- Profiles with explicit userDataDir still need manual validation of the matching Chromium-based browser: ${explicitProfiles + .map((profile) => profile.name) + .join(", ")}.`, + ); + } noteFn(lines.join("\n"), "Browser"); } diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 2680013a717..e915350ee62 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -271,6 +271,7 @@ const TARGET_KEYS = [ "browser.headless", "browser.noSandbox", "browser.profiles", + "browser.profiles.*.userDataDir", "browser.profiles.*.driver", "browser.profiles.*.attachOnly", "tools", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 3054b3f2ed2..1b048bc9aa1 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -260,8 +260,10 @@ export const FIELD_HELP: Record = { "Per-profile local CDP port used when connecting to browser instances by port instead of URL. Use unique ports per profile to avoid connection collisions.", "browser.profiles.*.cdpUrl": "Per-profile CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.", + "browser.profiles.*.userDataDir": + "Per-profile Chromium user data directory for existing-session attachment through Chrome DevTools MCP. Use this for host-local Brave, Edge, Chromium, or non-default Chrome profiles when the built-in auto-connect path would pick the wrong browser data directory.", "browser.profiles.*.driver": - 'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for host-local Chrome MCP attachment.', + 'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for host-local Chrome DevTools MCP attachment.', "browser.profiles.*.attachOnly": "Per-profile attach-only override that skips local browser launch and only attaches to an existing CDP endpoint. Useful when one profile is externally managed but others are locally launched.", "browser.profiles.*.color": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 2e9ebe1189c..a88cdc1ded5 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -123,6 +123,7 @@ export const FIELD_LABELS: Record = { "browser.profiles": "Browser Profiles", "browser.profiles.*.cdpPort": "Browser Profile CDP Port", "browser.profiles.*.cdpUrl": "Browser Profile CDP URL", + "browser.profiles.*.userDataDir": "Browser Profile User Data Dir", "browser.profiles.*.driver": "Browser Profile Driver", "browser.profiles.*.attachOnly": "Browser Profile Attach-only Mode", "browser.profiles.*.color": "Browser Profile Accent Color", diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index b50795fd9d0..558b0ed529f 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -3,6 +3,8 @@ export type BrowserProfileConfig = { cdpPort?: number; /** CDP URL for this profile (use for remote Chrome). */ cdpUrl?: string; + /** Explicit user data directory for existing-session Chrome MCP attachment. */ + userDataDir?: string; /** Profile driver (default: openclaw). */ driver?: "openclaw" | "clawd" | "existing-session"; /** If true, never launch a browser for this profile; only attach. Falls back to browser.attachOnly. */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index d1bce17b575..817183cab5d 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -359,6 +359,7 @@ export const OpenClawSchema = z .object({ cdpPort: z.number().int().min(1).max(65535).optional(), cdpUrl: z.string().optional(), + userDataDir: z.string().optional(), driver: z .union([z.literal("openclaw"), z.literal("clawd"), z.literal("existing-session")]) .optional(), @@ -371,7 +372,10 @@ export const OpenClawSchema = z { message: "Profile must set cdpPort or cdpUrl", }, - ), + ) + .refine((value) => value.driver === "existing-session" || !value.userDataDir, { + message: 'Profile userDataDir is only supported with driver="existing-session"', + }), ) .optional(), extraArgs: z.array(z.string()).optional(), diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 2db21cccde1..489ce365d61 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -162,6 +162,65 @@ describe("loadGatewayPlugins", () => { expect(typeof subagent?.getSession).toBe("function"); }); + test("can prefer setup-runtime channel plugins during startup loads", async () => { + const { loadGatewayPlugins } = await importServerPluginsModule(); + loadOpenClawPlugins.mockReturnValue(createRegistry([])); + + const log = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + + loadGatewayPlugins({ + cfg: {}, + workspaceDir: "/tmp", + log, + coreGatewayHandlers: {}, + baseMethods: [], + preferSetupRuntimeForChannelPlugins: true, + }); + + expect(loadOpenClawPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + preferSetupRuntimeForChannelPlugins: true, + }), + ); + }); + + test("can suppress duplicate diagnostics when reloading full runtime plugins", async () => { + const { loadGatewayPlugins } = await importServerPluginsModule(); + const diagnostics: PluginDiagnostic[] = [ + { + level: "error", + pluginId: "telegram", + source: "/tmp/telegram/index.ts", + message: "failed to load plugin: boom", + }, + ]; + loadOpenClawPlugins.mockReturnValue(createRegistry(diagnostics)); + + const log = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + + loadGatewayPlugins({ + cfg: {}, + workspaceDir: "/tmp", + log, + coreGatewayHandlers: {}, + baseMethods: [], + logDiagnostics: false, + }); + + expect(log.error).not.toHaveBeenCalled(); + expect(log.info).not.toHaveBeenCalled(); + }); + test("shares fallback context across module reloads for existing runtimes", async () => { const first = await importServerPluginsModule(); const runtime = createSubagentRuntime(first); diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 7d8b2a8a051..4bcf8fa8d08 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -172,6 +172,8 @@ export function loadGatewayPlugins(params: { }; coreGatewayHandlers: Record; baseMethods: string[]; + preferSetupRuntimeForChannelPlugins?: boolean; + logDiagnostics?: boolean; }) { const pluginRegistry = loadOpenClawPlugins({ config: params.cfg, @@ -186,10 +188,11 @@ export function loadGatewayPlugins(params: { runtimeOptions: { subagent: createGatewaySubagentRuntime(), }, + preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins, }); const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers); const gatewayMethods = Array.from(new Set([...params.baseMethods, ...pluginMethods])); - if (pluginRegistry.diagnostics.length > 0) { + if ((params.logDiagnostics ?? true) && pluginRegistry.diagnostics.length > 0) { for (const diag of pluginRegistry.diagnostics) { const details = [ diag.pluginId ? `plugin=${diag.pluginId}` : null, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 4c22e94bddf..350172bcee4 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -45,6 +45,7 @@ import { enqueueSystemEvent } from "../infra/system-events.js"; import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js"; import { startDiagnosticHeartbeat, stopDiagnosticHeartbeat } from "../logging/diagnostic.js"; import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js"; +import { resolveConfiguredDeferredChannelPluginIds } from "../plugins/channel-plugin-ids.js"; import { getGlobalHookRunner, runGlobalGatewayStopSafely } from "../plugins/hook-runner-global.js"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { createPluginRuntime } from "../plugins/runtime/index.js"; @@ -473,17 +474,27 @@ export async function startGatewayServer( initSubagentRegistry(); const defaultAgentId = resolveDefaultAgentId(cfgAtStart); const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId); + const deferredConfiguredChannelPluginIds = minimalTestGateway + ? [] + : resolveConfiguredDeferredChannelPluginIds({ + config: cfgAtStart, + workspaceDir: defaultWorkspaceDir, + env: process.env, + }); const baseMethods = listGatewayMethods(); const emptyPluginRegistry = createEmptyPluginRegistry(); - const { pluginRegistry, gatewayMethods: baseGatewayMethods } = minimalTestGateway - ? { pluginRegistry: emptyPluginRegistry, gatewayMethods: baseMethods } - : loadGatewayPlugins({ - cfg: cfgAtStart, - workspaceDir: defaultWorkspaceDir, - log, - coreGatewayHandlers, - baseMethods, - }); + let pluginRegistry = emptyPluginRegistry; + let baseGatewayMethods = baseMethods; + if (!minimalTestGateway) { + ({ pluginRegistry, gatewayMethods: baseGatewayMethods } = loadGatewayPlugins({ + cfg: cfgAtStart, + workspaceDir: defaultWorkspaceDir, + log, + coreGatewayHandlers, + baseMethods, + preferSetupRuntimeForChannelPlugins: deferredConfiguredChannelPluginIds.length > 0, + })); + } const channelLogs = Object.fromEntries( listChannelPlugins().map((plugin) => [plugin.id, logChannels.child(plugin.id)]), ) as Record>; @@ -940,6 +951,16 @@ export async function startGatewayServer( let browserControl: Awaited> = null; if (!minimalTestGateway) { + if (deferredConfiguredChannelPluginIds.length > 0) { + ({ pluginRegistry } = loadGatewayPlugins({ + cfg: cfgAtStart, + workspaceDir: defaultWorkspaceDir, + log, + coreGatewayHandlers, + baseMethods, + logDiagnostics: false, + })); + } ({ browserControl, pluginServices } = await startGatewaySidecars({ cfg: cfgAtStart, pluginRegistry, diff --git a/src/plugins/channel-plugin-ids.ts b/src/plugins/channel-plugin-ids.ts new file mode 100644 index 00000000000..b5a22f15b63 --- /dev/null +++ b/src/plugins/channel-plugin-ids.ts @@ -0,0 +1,55 @@ +import { listPotentialConfiguredChannelIds } from "../channels/config-presence.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { loadPluginManifestRegistry } from "./manifest-registry.js"; + +export function resolveChannelPluginIds(params: { + config: OpenClawConfig; + workspaceDir?: string; + env: NodeJS.ProcessEnv; +}): string[] { + return loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }) + .plugins.filter((plugin) => plugin.channels.length > 0) + .map((plugin) => plugin.id); +} + +export function resolveConfiguredChannelPluginIds(params: { + config: OpenClawConfig; + workspaceDir?: string; + env: NodeJS.ProcessEnv; +}): string[] { + const configuredChannelIds = new Set( + listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()), + ); + if (configuredChannelIds.size === 0) { + return []; + } + return resolveChannelPluginIds(params).filter((pluginId) => configuredChannelIds.has(pluginId)); +} + +export function resolveConfiguredDeferredChannelPluginIds(params: { + config: OpenClawConfig; + workspaceDir?: string; + env: NodeJS.ProcessEnv; +}): string[] { + const configuredChannelIds = new Set( + listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()), + ); + if (configuredChannelIds.size === 0) { + return []; + } + return loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }) + .plugins.filter( + (plugin) => + plugin.channels.some((channelId) => configuredChannelIds.has(channelId)) && + plugin.startupDeferConfiguredChannelFullLoadUntilAfterListen === true, + ) + .map((plugin) => plugin.id); +} diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 5c5b0ee4717..a91b6c939ab 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -2030,6 +2030,223 @@ module.exports = { expect(registry.channels).toHaveLength(1); }); + it("can prefer setupEntry for configured channel loads during startup", () => { + useNoBundledPlugins(); + const pluginDir = makeTempDir(); + const fullMarker = path.join(pluginDir, "full-loaded.txt"); + const setupMarker = path.join(pluginDir, "setup-loaded.txt"); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/setup-runtime-preferred-test", + openclaw: { + extensions: ["./index.cjs"], + setupEntry: "./setup-entry.cjs", + startup: { + deferConfiguredChannelFullLoadUntilAfterListen: true, + }, + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "setup-runtime-preferred-test", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["setup-runtime-preferred-test"], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); +module.exports = { + id: "setup-runtime-preferred-test", + register(api) { + api.registerChannel({ + plugin: { + id: "setup-runtime-preferred-test", + meta: { + id: "setup-runtime-preferred-test", + label: "Setup Runtime Preferred Test", + selectionLabel: "Setup Runtime Preferred Test", + docsPath: "/channels/setup-runtime-preferred-test", + blurb: "full entry should be deferred while startup is still cold", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ accountId: "default", token: "configured" }), + }, + outbound: { deliveryMode: "direct" }, + }, + }); + }, +};`, + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); +module.exports = { + plugin: { + id: "setup-runtime-preferred-test", + meta: { + id: "setup-runtime-preferred-test", + label: "Setup Runtime Preferred Test", + selectionLabel: "Setup Runtime Preferred Test", + docsPath: "/channels/setup-runtime-preferred-test", + blurb: "setup runtime preferred", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ accountId: "default", token: "configured" }), + }, + outbound: { deliveryMode: "direct" }, + }, +};`, + "utf-8", + ); + + const registry = loadOpenClawPlugins({ + cache: false, + preferSetupRuntimeForChannelPlugins: true, + config: { + channels: { + "setup-runtime-preferred-test": { + enabled: true, + }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-runtime-preferred-test"], + }, + }, + }); + + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); + expect(registry.channelSetups).toHaveLength(1); + expect(registry.channels).toHaveLength(1); + }); + + it("does not prefer setupEntry for configured channel loads without startup opt-in", () => { + useNoBundledPlugins(); + const pluginDir = makeTempDir(); + const fullMarker = path.join(makeTempDir(), "full-loaded.txt"); + const setupMarker = path.join(makeTempDir(), "setup-loaded.txt"); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/setup-runtime-not-preferred-test", + openclaw: { + extensions: ["./index.cjs"], + setupEntry: "./setup-entry.cjs", + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "setup-runtime-not-preferred-test", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["setup-runtime-not-preferred-test"], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); +module.exports = { + id: "setup-runtime-not-preferred-test", + register(api) { + api.registerChannel({ + plugin: { + id: "setup-runtime-not-preferred-test", + meta: { + id: "setup-runtime-not-preferred-test", + label: "Setup Runtime Not Preferred Test", + selectionLabel: "Setup Runtime Not Preferred Test", + docsPath: "/channels/setup-runtime-not-preferred-test", + blurb: "full entry should still load without explicit startup opt-in", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ accountId: "default", token: "configured" }), + }, + outbound: { deliveryMode: "direct" }, + }, + }); + }, +};`, + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); +module.exports = { + plugin: { + id: "setup-runtime-not-preferred-test", + meta: { + id: "setup-runtime-not-preferred-test", + label: "Setup Runtime Not Preferred Test", + selectionLabel: "Setup Runtime Not Preferred Test", + docsPath: "/channels/setup-runtime-not-preferred-test", + blurb: "setup runtime not preferred", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ accountId: "default", token: "configured" }), + }, + outbound: { deliveryMode: "direct" }, + }, +};`, + "utf-8", + ); + + const registry = loadOpenClawPlugins({ + cache: false, + preferSetupRuntimeForChannelPlugins: true, + config: { + channels: { + "setup-runtime-not-preferred-test": { + enabled: true, + }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-runtime-not-preferred-test"], + }, + }, + }); + + expect(fs.existsSync(fullMarker)).toBe(true); + expect(fs.existsSync(setupMarker)).toBe(false); + expect(registry.channelSetups).toHaveLength(1); + expect(registry.channels).toHaveLength(1); + }); + it("blocks before_prompt_build but preserves legacy model overrides when prompt injection is disabled", async () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 2fff62b0b95..dc3bf5139c6 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -54,6 +54,11 @@ export type PluginLoadOptions = { mode?: "full" | "validate"; onlyPluginIds?: string[]; includeSetupOnlyChannelPlugins?: boolean; + /** + * Prefer `setupEntry` for configured channel plugins that explicitly opt in + * via package metadata because their setup entry covers the pre-listen startup surface. + */ + preferSetupRuntimeForChannelPlugins?: boolean; activate?: boolean; }; @@ -336,6 +341,7 @@ function buildCacheKey(params: { env: NodeJS.ProcessEnv; onlyPluginIds?: string[]; includeSetupOnlyChannelPlugins?: boolean; + preferSetupRuntimeForChannelPlugins?: boolean; }): string { const { roots, loadPaths } = resolvePluginCacheInputs({ workspaceDir: params.workspaceDir, @@ -360,11 +366,13 @@ function buildCacheKey(params: { ); const scopeKey = JSON.stringify(params.onlyPluginIds ?? []); const setupOnlyKey = params.includeSetupOnlyChannelPlugins === true ? "setup-only" : "runtime"; + const startupChannelMode = + params.preferSetupRuntimeForChannelPlugins === true ? "prefer-setup" : "full"; return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({ ...params.plugins, installs, loadPaths, - })}::${scopeKey}::${setupOnlyKey}`; + })}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}`; } function normalizeScopedPluginIds(ids?: string[]): string[] | undefined { @@ -445,12 +453,20 @@ function resolveSetupChannelRegistration(moduleExport: unknown): { function shouldLoadChannelPluginInSetupRuntime(params: { manifestChannels: string[]; setupSource?: string; + startupDeferConfiguredChannelFullLoadUntilAfterListen?: boolean; cfg: OpenClawConfig; env: NodeJS.ProcessEnv; + preferSetupRuntimeForChannelPlugins?: boolean; }): boolean { if (!params.setupSource || params.manifestChannels.length === 0) { return false; } + if ( + params.preferSetupRuntimeForChannelPlugins && + params.startupDeferConfiguredChannelFullLoadUntilAfterListen === true + ) { + return true; + } return !params.manifestChannels.some((channelId) => isChannelConfigured(params.cfg, channelId, params.env), ); @@ -800,6 +816,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const onlyPluginIds = normalizeScopedPluginIds(options.onlyPluginIds); const onlyPluginIdSet = onlyPluginIds ? new Set(onlyPluginIds) : null; const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true; + const preferSetupRuntimeForChannelPlugins = options.preferSetupRuntimeForChannelPlugins === true; const shouldActivate = options.activate !== false; // NOTE: `activate` is intentionally excluded from the cache key. All non-activating // (snapshot) callers pass `cache: false` via loadOnboardingPluginRegistry(), so they @@ -812,6 +829,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi env, onlyPluginIds, includeSetupOnlyChannelPlugins, + preferSetupRuntimeForChannelPlugins, }); const cacheEnabled = options.cache !== false; if (cacheEnabled) { @@ -1066,8 +1084,11 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi shouldLoadChannelPluginInSetupRuntime({ manifestChannels: manifestRecord.channels, setupSource: manifestRecord.setupSource, + startupDeferConfiguredChannelFullLoadUntilAfterListen: + manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen, cfg, env, + preferSetupRuntimeForChannelPlugins, }) ? "setup-runtime" : "full" diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index ea646f38797..7a5c10d67f0 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -51,6 +51,7 @@ export type PluginManifestRecord = { rootDir: string; source: string; setupSource?: string; + startupDeferConfiguredChannelFullLoadUntilAfterListen?: boolean; manifestPath: string; schemaCacheKey?: string; configSchema?: Record; @@ -168,6 +169,9 @@ function buildRecord(params: { rootDir: params.candidate.rootDir, source: params.candidate.source, setupSource: params.candidate.setupSource, + startupDeferConfiguredChannelFullLoadUntilAfterListen: + params.candidate.packageManifest?.startup?.deferConfiguredChannelFullLoadUntilAfterListen === + true, manifestPath: params.manifestPath, schemaCacheKey: params.schemaCacheKey, configSchema: params.configSchema, diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index d330b982ce1..dd8615d7350 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -242,11 +242,20 @@ export type PluginPackageInstall = { defaultChoice?: "npm" | "local"; }; +export type OpenClawPackageStartup = { + /** + * Opt-in for channel plugins whose `setupEntry` fully covers the gateway + * startup surface needed before the server starts listening. + */ + deferConfiguredChannelFullLoadUntilAfterListen?: boolean; +}; + export type OpenClawPackageManifest = { extensions?: string[]; setupEntry?: string; channel?: PluginPackageChannel; install?: PluginPackageInstall; + startup?: OpenClawPackageStartup; }; export const DEFAULT_PLUGIN_ENTRY_CANDIDATES = [ diff --git a/ui/src/local-storage.ts b/ui/src/local-storage.ts index a1e80d9d32a..e0de8c8cee5 100644 --- a/ui/src/local-storage.ts +++ b/ui/src/local-storage.ts @@ -9,7 +9,7 @@ function isStorage(value: unknown): value is Storage { export function getSafeLocalStorage(): Storage | null { const descriptor = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); - if (process.env.VITEST) { + if (typeof process !== "undefined" && process.env?.VITEST) { return descriptor && !descriptor.get && isStorage(descriptor.value) ? descriptor.value : null; }