Merge branch 'main' into fix/tts-tool-no-channel-hang
This commit is contained in:
commit
f1c0cf1057
@ -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
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@ -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(""))
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.*`.
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 () => {}),
|
||||
}));
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 } : {}),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -22,6 +22,9 @@ function changedProfileInvariants(
|
||||
if (current.cdpIsLoopback !== next.cdpIsLoopback) {
|
||||
changed.push("cdpIsLoopback");
|
||||
}
|
||||
if ((current.userDataDir ?? "") !== (next.userDataDir ?? "")) {
|
||||
changed.push("userDataDir");
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
|
||||
@ -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 ??= {});
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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" : ""}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -271,6 +271,7 @@ const TARGET_KEYS = [
|
||||
"browser.headless",
|
||||
"browser.noSandbox",
|
||||
"browser.profiles",
|
||||
"browser.profiles.*.userDataDir",
|
||||
"browser.profiles.*.driver",
|
||||
"browser.profiles.*.attachOnly",
|
||||
"tools",
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
55
src/plugins/channel-plugin-ids.ts
Normal file
55
src/plugins/channel-plugin-ids.ts
Normal 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);
|
||||
}
|
||||
@ -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({
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user