Merge branch 'main' into fix/tts-tool-no-channel-hang

This commit is contained in:
Hiago Silva 2026-03-16 11:08:56 -03:00 committed by GitHub
commit f1c0cf1057
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
59 changed files with 1548 additions and 328 deletions

View File

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

View File

@ -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() {

View File

@ -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<NodeRuntime?>(null)
private var foreground = true
val canvas: CanvasController = runtime.canvas
val canvasCurrentUrl: StateFlow<String?> = runtime.canvas.currentUrl
val canvasA2uiHydrated: StateFlow<Boolean> = runtime.canvasA2uiHydrated
val canvasRehydratePending: StateFlow<Boolean> = runtime.canvasRehydratePending
val canvasRehydrateErrorText: StateFlow<String?> = 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<List<GatewayEndpoint>> = runtime.gateways
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
private fun <T> runtimeState(
initial: T,
selector: (NodeRuntime) -> StateFlow<T>,
): StateFlow<T> =
runtimeRef
.flatMapLatest { runtime -> runtime?.let(selector) ?: flowOf(initial) }
.stateIn(viewModelScope, SharingStarted.Eagerly, initial)
val isConnected: StateFlow<Boolean> = runtime.isConnected
val isNodeConnected: StateFlow<Boolean> = runtime.nodeConnected
val statusText: StateFlow<String> = runtime.statusText
val serverName: StateFlow<String?> = runtime.serverName
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtime.pendingGatewayTrust
val isForeground: StateFlow<Boolean> = runtime.isForeground
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
val mainSessionKey: StateFlow<String> = runtime.mainSessionKey
val runtimeInitialized: StateFlow<Boolean> =
runtimeRef
.flatMapLatest { runtime -> flowOf(runtime != null) }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
val canvasCurrentUrl: StateFlow<String?> = runtimeState(initial = null) { it.canvas.currentUrl }
val canvasA2uiHydrated: StateFlow<Boolean> = runtimeState(initial = false) { it.canvasA2uiHydrated }
val canvasRehydratePending: StateFlow<Boolean> = runtimeState(initial = false) { it.canvasRehydratePending }
val canvasRehydrateErrorText: StateFlow<String?> = runtimeState(initial = null) { it.canvasRehydrateErrorText }
val instanceId: StateFlow<String> = runtime.instanceId
val displayName: StateFlow<String> = runtime.displayName
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
val locationMode: StateFlow<LocationMode> = runtime.locationMode
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
val preventSleep: StateFlow<Boolean> = runtime.preventSleep
val micEnabled: StateFlow<Boolean> = runtime.micEnabled
val micCooldown: StateFlow<Boolean> = runtime.micCooldown
val micStatusText: StateFlow<String> = runtime.micStatusText
val micLiveTranscript: StateFlow<String?> = runtime.micLiveTranscript
val micIsListening: StateFlow<Boolean> = runtime.micIsListening
val micQueuedMessages: StateFlow<List<String>> = runtime.micQueuedMessages
val micConversation: StateFlow<List<VoiceConversationEntry>> = runtime.micConversation
val micInputLevel: StateFlow<Float> = runtime.micInputLevel
val micIsSending: StateFlow<Boolean> = runtime.micIsSending
val speakerEnabled: StateFlow<Boolean> = runtime.speakerEnabled
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
val manualHost: StateFlow<String> = runtime.manualHost
val manualPort: StateFlow<Int> = runtime.manualPort
val manualTls: StateFlow<Boolean> = runtime.manualTls
val gatewayToken: StateFlow<String> = runtime.gatewayToken
val onboardingCompleted: StateFlow<Boolean> = runtime.onboardingCompleted
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
val gateways: StateFlow<List<GatewayEndpoint>> = runtimeState(initial = emptyList()) { it.gateways }
val discoveryStatusText: StateFlow<String> = runtimeState(initial = "Searching…") { it.discoveryStatusText }
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
val chatSessionId: StateFlow<String?> = runtime.chatSessionId
val chatMessages = runtime.chatMessages
val chatError: StateFlow<String?> = runtime.chatError
val chatHealthOk: StateFlow<Boolean> = runtime.chatHealthOk
val chatThinkingLevel: StateFlow<String> = runtime.chatThinkingLevel
val chatStreamingAssistantText: StateFlow<String?> = runtime.chatStreamingAssistantText
val chatPendingToolCalls = runtime.chatPendingToolCalls
val chatSessions = runtime.chatSessions
val pendingRunCount: StateFlow<Int> = runtime.pendingRunCount
val isConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.isConnected }
val isNodeConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.nodeConnected }
val statusText: StateFlow<String> = runtimeState(initial = "Offline") { it.statusText }
val serverName: StateFlow<String?> = runtimeState(initial = null) { it.serverName }
val remoteAddress: StateFlow<String?> = runtimeState(initial = null) { it.remoteAddress }
val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtimeState(initial = null) { it.pendingGatewayTrust }
val seamColorArgb: StateFlow<Long> = runtimeState(initial = 0xFF0EA5E9) { it.seamColorArgb }
val mainSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.mainSessionKey }
val cameraHud: StateFlow<CameraHudState?> = runtimeState(initial = null) { it.cameraHud }
val cameraFlashToken: StateFlow<Long> = runtimeState(initial = 0L) { it.cameraFlashToken }
val instanceId: StateFlow<String> = prefs.instanceId
val displayName: StateFlow<String> = prefs.displayName
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
val locationMode: StateFlow<LocationMode> = prefs.locationMode
val locationPreciseEnabled: StateFlow<Boolean> = prefs.locationPreciseEnabled
val preventSleep: StateFlow<Boolean> = prefs.preventSleep
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
val manualHost: StateFlow<String> = prefs.manualHost
val manualPort: StateFlow<Int> = prefs.manualPort
val manualTls: StateFlow<Boolean> = prefs.manualTls
val gatewayToken: StateFlow<String> = prefs.gatewayToken
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
val speakerEnabled: StateFlow<Boolean> = prefs.speakerEnabled
val micEnabled: StateFlow<Boolean> = prefs.talkEnabled
val micCooldown: StateFlow<Boolean> = runtimeState(initial = false) { it.micCooldown }
val micStatusText: StateFlow<String> = runtimeState(initial = "Mic off") { it.micStatusText }
val micLiveTranscript: StateFlow<String?> = runtimeState(initial = null) { it.micLiveTranscript }
val micIsListening: StateFlow<Boolean> = runtimeState(initial = false) { it.micIsListening }
val micQueuedMessages: StateFlow<List<String>> = runtimeState(initial = emptyList()) { it.micQueuedMessages }
val micConversation: StateFlow<List<VoiceConversationEntry>> = runtimeState(initial = emptyList()) { it.micConversation }
val micInputLevel: StateFlow<Float> = runtimeState(initial = 0f) { it.micInputLevel }
val micIsSending: StateFlow<Boolean> = runtimeState(initial = false) { it.micIsSending }
val chatSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.chatSessionKey }
val chatSessionId: StateFlow<String?> = runtimeState(initial = null) { it.chatSessionId }
val chatMessages: StateFlow<List<ChatMessage>> = runtimeState(initial = emptyList()) { it.chatMessages }
val chatError: StateFlow<String?> = runtimeState(initial = null) { it.chatError }
val chatHealthOk: StateFlow<Boolean> = runtimeState(initial = false) { it.chatHealthOk }
val chatThinkingLevel: StateFlow<String> = runtimeState(initial = "off") { it.chatThinkingLevel }
val chatStreamingAssistantText: StateFlow<String?> = runtimeState(initial = null) { it.chatStreamingAssistantText }
val chatPendingToolCalls: StateFlow<List<ChatPendingToolCall>> = runtimeState(initial = emptyList()) { it.chatPendingToolCalls }
val chatSessions: StateFlow<List<ChatSessionEntry>> = runtimeState(initial = emptyList()) { it.chatSessions }
val pendingRunCount: StateFlow<Int> = 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<OutgoingAttachment>) {
runtime.sendChat(message = message, thinking = thinking, attachments = attachments)
ensureRuntime().sendChat(message = message, thinking = thinking, attachments = attachments)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<String, Bitmap>(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
}

View File

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

View File

@ -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(""))
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.<name>.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=<name>`; 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

View File

@ -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 <npm-spec>` uses `npm pack`, extracts into `~/.openclaw/extensions/<id>/`, and enables it in config.
- Config key stability: scoped packages are normalized to the **unscoped** id for `plugins.entries.*`.

View File

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

View File

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

View File

@ -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<DispatchReplyWithBufferedBlockDispatcherFn>
>;
type RecordInboundSessionMetaSafeFn =
typeof import("../../../src/channels/session-meta.js").recordInboundSessionMetaSafe;
type AnyMock = MockFn<(...args: unknown[]) => unknown>;
type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise<unknown>>;
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<DispatchReplyWithBufferedBlockDispatcherFn>(
async () => dispatchReplyResult,
),
createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })),
recordInboundSessionMetaSafe: vi.fn<RecordInboundSessionMetaSafeFn>(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 () => {}),
}));

View File

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

View File

@ -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=<id|name> 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.',

View File

@ -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<ReturnType<typeof vi.fn>> = [];
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 () => {

View File

@ -26,7 +26,10 @@ type ChromeMcpSession = {
ready: Promise<void>;
};
type ChromeMcpSessionFactory = (profileName: string) => Promise<ChromeMcpSession>;
type ChromeMcpSessionFactory = (
profileName: string,
userDataDir?: string,
) => Promise<ChromeMcpSession>;
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<ChromeMcpSession> {
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<boolean> {
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<ChromeMcpSession> {
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<ChromeMcpSession>
}
} 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<ChromeMcpSession>
};
}
async function getSession(profileName: string): Promise<ChromeMcpSession> {
let session = sessions.get(profileName);
async function getSession(profileName: string, userDataDir?: string): Promise<ChromeMcpSession> {
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<ChromeMcpSession> {
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<ChromeMcpSession> {
async function callTool(
profileName: string,
userDataDir: string | undefined,
name: string,
args: Record<string, unknown> = {},
): Promise<ChromeMcpToolResult> {
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<T>(fn: (filePath: string) => Promise<T>): Promise<T>
}
}
async function findPageById(profileName: string, pageId: number): Promise<ChromeMcpStructuredPage> {
const pages = await listChromeMcpPages(profileName);
async function findPageById(
profileName: string,
pageId: number,
userDataDir?: string,
): Promise<ChromeMcpStructuredPage> {
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<Chrome
return page;
}
export async function ensureChromeMcpAvailable(profileName: string): Promise<void> {
await getSession(profileName);
export async function ensureChromeMcpAvailable(
profileName: string,
userDataDir?: string,
): Promise<void> {
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<boolean> {
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<void> {
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<ChromeMcpStructuredPage[]> {
const result = await callTool(profileName, "list_pages");
export async function listChromeMcpPages(
profileName: string,
userDataDir?: string,
): Promise<ChromeMcpStructuredPage[]> {
const result = await callTool(profileName, userDataDir, "list_pages");
return extractStructuredPages(result);
}
export async function listChromeMcpTabs(profileName: string): Promise<BrowserTab[]> {
return toBrowserTabs(await listChromeMcpPages(profileName));
export async function listChromeMcpTabs(
profileName: string,
userDataDir?: string,
): Promise<BrowserTab[]> {
return toBrowserTabs(await listChromeMcpPages(profileName, userDataDir));
}
export async function openChromeMcpTab(profileName: string, url: string): Promise<BrowserTab> {
const result = await callTool(profileName, "new_page", { url });
export async function openChromeMcpTab(
profileName: string,
url: string,
userDataDir?: string,
): Promise<BrowserTab> {
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<void> {
await callTool(profileName, "select_page", {
export async function focusChromeMcpTab(
profileName: string,
targetId: string,
userDataDir?: string,
): Promise<void> {
await callTool(profileName, userDataDir, "select_page", {
pageId: parsePageId(targetId),
bringToFront: true,
});
}
export async function closeChromeMcpTab(profileName: string, targetId: string): Promise<void> {
await callTool(profileName, "close_page", { pageId: parsePageId(targetId) });
export async function closeChromeMcpTab(
profileName: string,
targetId: string,
userDataDir?: string,
): Promise<void> {
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<ChromeMcpSnapshotNode> {
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<Buffer> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<unknown> {
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<void> {
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 } : {}),

View File

@ -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<BrowserCreateProfileResult> {
@ -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,

View File

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

View File

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

View File

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

View File

@ -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<CreateProfileResult> => {
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<typeof parseHttpUrl>;
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,
};

View File

@ -22,6 +22,9 @@ function changedProfileInvariants(
if (current.cdpIsLoopback !== next.cdpIsLoopback) {
changed.push("cdpIsLoopback");
}
if ((current.userDataDir ?? "") !== (next.userDataDir ?? "")) {
changed.push("userDataDir");
}
return changed;
}

View File

@ -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 ??= {});

View File

@ -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) => {

View File

@ -44,10 +44,12 @@ const CHROME_MCP_OVERLAY_ATTR = "data-openclaw-mcp-overlay";
async function clearChromeMcpOverlay(params: {
profileName: string;
userDataDir?: string;
targetId: string;
}): Promise<void> {
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,
});
}

View File

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

View File

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

View File

@ -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<void> => {
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();

View File

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

View File

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

View File

@ -67,7 +67,7 @@ export function createProfileTabOps({
const listTabs = async (): Promise<BrowserTab[]> => {
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 });

View File

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

View File

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

View File

@ -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 <name>", "Profile name (lowercase, numbers, hyphens)")
.option("--color <hex>", "Profile color (hex format, e.g. #0066CC)")
.option("--cdp-url <url>", "CDP URL for remote Chrome (http/https)")
.option("--user-data-dir <path>", "User data dir for existing-session Chromium attach")
.option("--driver <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<BrowserCreateProfileResult>(
@ -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" : ""}`,
),
);
});

View File

@ -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<typeof loadConfig>;
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<typeof loadConfig>;
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)) {

View File

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

View File

@ -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<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
@ -14,33 +19,40 @@ function asRecord(value: unknown): Record<string, unknown> | 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<string>();
const profiles = new Map<string, ExistingSessionProfile>();
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.<name>.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");
}

View File

@ -271,6 +271,7 @@ const TARGET_KEYS = [
"browser.headless",
"browser.noSandbox",
"browser.profiles",
"browser.profiles.*.userDataDir",
"browser.profiles.*.driver",
"browser.profiles.*.attachOnly",
"tools",

View File

@ -260,8 +260,10 @@ export const FIELD_HELP: Record<string, string> = {
"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":

View File

@ -123,6 +123,7 @@ export const FIELD_LABELS: Record<string, string> = {
"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",

View File

@ -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. */

View File

@ -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(),

View File

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

View File

@ -172,6 +172,8 @@ export function loadGatewayPlugins(params: {
};
coreGatewayHandlers: Record<string, GatewayRequestHandler>;
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,

View File

@ -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<ChannelId, ReturnType<typeof createSubsystemLogger>>;
@ -940,6 +951,16 @@ export async function startGatewayServer(
let browserControl: Awaited<ReturnType<typeof startBrowserControlServerIfEnabled>> = 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,

View File

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

View File

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

View File

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

View File

@ -51,6 +51,7 @@ export type PluginManifestRecord = {
rootDir: string;
source: string;
setupSource?: string;
startupDeferConfiguredChannelFullLoadUntilAfterListen?: boolean;
manifestPath: string;
schemaCacheKey?: string;
configSchema?: Record<string, unknown>;
@ -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,

View File

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

View File

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