Merge branch 'main' into web-tools

This commit is contained in:
ide-rea 2026-03-20 16:11:57 +08:00 committed by GitHub
commit d6b0673714
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
107 changed files with 3513 additions and 1589 deletions

View File

@ -215,26 +215,37 @@ jobs:
- runtime: bun
task: test
command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts
- runtime: node
task: compat-node22
node_version: "22.x"
cache_key_suffix: "node22"
command: |
pnpm build
pnpm test
node scripts/stage-bundled-plugin-runtime-deps.mjs
node --import tsx scripts/release-check.ts
steps:
- name: Skip bun lane on pull requests
if: github.event_name == 'pull_request' && matrix.runtime == 'bun'
run: echo "Skipping Bun compatibility lane on pull requests."
- name: Skip compatibility lanes on pull requests
if: github.event_name == 'pull_request' && (matrix.runtime == 'bun' || matrix.task == 'compat-node22')
run: echo "Skipping push-only lane on pull requests."
- name: Checkout
if: github.event_name != 'pull_request' || matrix.runtime != 'bun'
if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
if: matrix.runtime != 'bun' || github.event_name != 'pull_request'
if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')
uses: ./.github/actions/setup-node-env
with:
node-version: "${{ matrix.node_version || '24.x' }}"
cache-key-suffix: "${{ matrix.cache_key_suffix || 'node24' }}"
install-bun: "${{ matrix.runtime == 'bun' }}"
use-sticky-disk: "false"
- name: Configure Node test resources
if: (github.event_name != 'pull_request' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
if: (github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')) && matrix.runtime == 'node' && (matrix.task == 'test' || matrix.task == 'compat-node22')
env:
SHARD_COUNT: ${{ matrix.shard_count || '' }}
SHARD_INDEX: ${{ matrix.shard_index || '' }}
@ -249,11 +260,11 @@ jobs:
fi
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
if: matrix.runtime != 'bun' || github.event_name != 'pull_request'
if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')
run: ${{ matrix.command }}
extension-fast:
name: "extension-fast (${{ matrix.extension }})"
name: "extension-fast"
needs: [docs-scope, changed-scope, changed-extensions]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' && needs.changed-extensions.outputs.has_changed_extensions == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
@ -464,45 +475,9 @@ jobs:
- name: Check docs
run: pnpm check:docs
compat-node22:
name: "compat-node22"
needs: [docs-scope, changed-scope]
if: github.event_name == 'push' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node 22 compatibility environment
uses: ./.github/actions/setup-node-env
with:
node-version: "22.x"
cache-key-suffix: "node22"
install-bun: "false"
use-sticky-disk: "false"
- name: Configure Node 22 test resources
run: |
# Keep the compatibility lane aligned with the default Node test lane.
echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV"
- name: Build under Node 22
run: pnpm build
- name: Run tests under Node 22
run: pnpm test
- name: Verify npm pack under Node 22
run: |
node scripts/stage-bundled-plugin-runtime-deps.mjs
node --import tsx scripts/release-check.ts
skills-python:
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_skills_python == 'true'
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_skills_python == 'true')
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
@ -972,10 +947,14 @@ jobs:
fail-fast: false
matrix:
include:
- task: test
command: ./gradlew --no-daemon :app:testDebugUnitTest
- task: build
command: ./gradlew --no-daemon :app:assembleDebug
- task: test-play
command: ./gradlew --no-daemon :app:testPlayDebugUnitTest
- task: test-third-party
command: ./gradlew --no-daemon :app:testThirdPartyDebugUnitTest
- task: build-play
command: ./gradlew --no-daemon :app:assemblePlayDebug
- task: build-third-party
command: ./gradlew --no-daemon :app:assembleThirdPartyDebug
steps:
- name: Checkout
uses: actions/checkout@v6

View File

@ -116,7 +116,7 @@ jobs:
- name: Build Android for CodeQL
if: matrix.language == 'java-kotlin'
working-directory: apps/android
run: ./gradlew --no-daemon :app:assembleDebug
run: ./gradlew --no-daemon :app:assemblePlayDebug
- name: Build Swift for CodeQL
if: matrix.language == 'swift'

View File

@ -27,14 +27,34 @@ Status: **extremely alpha**. The app is actively being rebuilt from the ground u
```bash
cd apps/android
./gradlew :app:assembleDebug
./gradlew :app:installDebug
./gradlew :app:testDebugUnitTest
./gradlew :app:assemblePlayDebug
./gradlew :app:installPlayDebug
./gradlew :app:testPlayDebugUnitTest
cd ../..
bun run android:bundle:release
```
`bun run android:bundle:release` auto-bumps Android `versionName`/`versionCode` in `apps/android/app/build.gradle.kts`, then builds a signed release `.aab`.
Third-party debug flavor:
```bash
cd apps/android
./gradlew :app:assembleThirdPartyDebug
./gradlew :app:installThirdPartyDebug
./gradlew :app:testThirdPartyDebugUnitTest
```
`bun run android:bundle:release` auto-bumps Android `versionName`/`versionCode` in `apps/android/app/build.gradle.kts`, then builds two signed release bundles:
- Play build: `apps/android/build/release-bundles/openclaw-<version>-play-release.aab`
- Third-party build: `apps/android/build/release-bundles/openclaw-<version>-third-party-release.aab`
Flavor-specific direct Gradle tasks:
```bash
cd apps/android
./gradlew :app:bundlePlayRelease
./gradlew :app:bundleThirdPartyRelease
```
## Kotlin Lint + Format
@ -194,6 +214,9 @@ Current OpenClaw Android implication:
- APK / sideload build can keep SMS and Call Log features.
- Google Play build should exclude SMS send/search and Call Log search unless the product is intentionally positioned and approved as a default-handler exception case.
- The repo now ships this split as Android product flavors:
- `play`: removes `READ_SMS`, `SEND_SMS`, and `READ_CALL_LOG`, and hides SMS / Call Log surfaces in onboarding, settings, and advertised node capabilities.
- `thirdParty`: keeps the full permission set and the existing SMS / Call Log functionality.
Policy links:

View File

@ -65,14 +65,29 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026031400
versionName = "2026.3.14"
versionCode = 2026032000
versionName = "2026.3.20"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
}
}
flavorDimensions += "store"
productFlavors {
create("play") {
dimension = "store"
buildConfigField("boolean", "OPENCLAW_ENABLE_SMS", "false")
buildConfigField("boolean", "OPENCLAW_ENABLE_CALL_LOG", "false")
}
create("thirdParty") {
dimension = "store"
buildConfigField("boolean", "OPENCLAW_ENABLE_SMS", "true")
buildConfigField("boolean", "OPENCLAW_ENABLE_CALL_LOG", "true")
}
}
buildTypes {
release {
if (hasAndroidReleaseSigning) {
@ -140,8 +155,13 @@ androidComponents {
.forEach { output ->
val versionName = output.versionName.orNull ?: "0"
val buildType = variant.buildType
val outputFileName = "openclaw-$versionName-$buildType.apk"
val flavorName = variant.flavorName?.takeIf { it.isNotBlank() }
val outputFileName =
if (flavorName == null) {
"openclaw-$versionName-$buildType.apk"
} else {
"openclaw-$versionName-$flavorName-$buildType.apk"
}
output.outputFileName = outputFileName
}
}

View File

@ -89,6 +89,8 @@ class NodeRuntime(
private val deviceHandler: DeviceHandler = DeviceHandler(
appContext = appContext,
smsEnabled = BuildConfig.OPENCLAW_ENABLE_SMS,
callLogEnabled = BuildConfig.OPENCLAW_ENABLE_CALL_LOG,
)
private val notificationsHandler: NotificationsHandler = NotificationsHandler(
@ -137,8 +139,9 @@ class NodeRuntime(
voiceWakeMode = { VoiceWakeMode.Off },
motionActivityAvailable = { motionHandler.isActivityAvailable() },
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
sendSmsAvailable = { sms.canSendSms() },
readSmsAvailable = { sms.canReadSms() },
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
hasRecordAudioPermission = { hasRecordAudioPermission() },
manualTls = { manualTls.value },
)
@ -161,8 +164,9 @@ class NodeRuntime(
isForeground = { _isForeground.value },
cameraEnabled = { cameraEnabled.value },
locationEnabled = { locationMode.value != LocationMode.Off },
sendSmsAvailable = { sms.canSendSms() },
readSmsAvailable = { sms.canReadSms() },
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
debugBuild = { BuildConfig.DEBUG },
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
onCanvasA2uiPush = {

View File

@ -19,6 +19,7 @@ class ConnectionManager(
private val motionPedometerAvailable: () -> Boolean,
private val sendSmsAvailable: () -> Boolean,
private val readSmsAvailable: () -> Boolean,
private val callLogAvailable: () -> Boolean,
private val hasRecordAudioPermission: () -> Boolean,
private val manualTls: () -> Boolean,
) {
@ -81,6 +82,7 @@ class ConnectionManager(
locationEnabled = locationMode() != LocationMode.Off,
sendSmsAvailable = sendSmsAvailable(),
readSmsAvailable = readSmsAvailable(),
callLogAvailable = callLogAvailable(),
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
motionActivityAvailable = motionActivityAvailable(),
motionPedometerAvailable = motionPedometerAvailable(),

View File

@ -25,6 +25,8 @@ import kotlinx.serialization.json.put
class DeviceHandler(
private val appContext: Context,
private val smsEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_SMS,
private val callLogEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_CALL_LOG,
) {
private data class BatterySnapshot(
val status: Int,
@ -173,8 +175,8 @@ class DeviceHandler(
put(
"sms",
permissionStateJson(
granted = hasPermission(Manifest.permission.SEND_SMS) && canSendSms,
promptableWhenDenied = canSendSms,
granted = smsEnabled && hasPermission(Manifest.permission.SEND_SMS) && canSendSms,
promptableWhenDenied = smsEnabled && canSendSms,
),
)
put(
@ -215,8 +217,8 @@ class DeviceHandler(
put(
"callLog",
permissionStateJson(
granted = hasPermission(Manifest.permission.READ_CALL_LOG),
promptableWhenDenied = true,
granted = callLogEnabled && hasPermission(Manifest.permission.READ_CALL_LOG),
promptableWhenDenied = callLogEnabled,
),
)
put(

View File

@ -20,6 +20,7 @@ data class NodeRuntimeFlags(
val locationEnabled: Boolean,
val sendSmsAvailable: Boolean,
val readSmsAvailable: Boolean,
val callLogAvailable: Boolean,
val voiceWakeEnabled: Boolean,
val motionActivityAvailable: Boolean,
val motionPedometerAvailable: Boolean,
@ -32,6 +33,7 @@ enum class InvokeCommandAvailability {
LocationEnabled,
SendSmsAvailable,
ReadSmsAvailable,
CallLogAvailable,
MotionActivityAvailable,
MotionPedometerAvailable,
DebugBuild,
@ -42,6 +44,7 @@ enum class NodeCapabilityAvailability {
CameraEnabled,
LocationEnabled,
SmsAvailable,
CallLogAvailable,
VoiceWakeEnabled,
MotionAvailable,
}
@ -87,7 +90,10 @@ object InvokeCommandRegistry {
name = OpenClawCapability.Motion.rawValue,
availability = NodeCapabilityAvailability.MotionAvailable,
),
NodeCapabilitySpec(name = OpenClawCapability.CallLog.rawValue),
NodeCapabilitySpec(
name = OpenClawCapability.CallLog.rawValue,
availability = NodeCapabilityAvailability.CallLogAvailable,
),
)
val all: List<InvokeCommandSpec> =
@ -197,6 +203,7 @@ object InvokeCommandRegistry {
),
InvokeCommandSpec(
name = OpenClawCallLogCommand.Search.rawValue,
availability = InvokeCommandAvailability.CallLogAvailable,
),
InvokeCommandSpec(
name = "debug.logs",
@ -220,6 +227,7 @@ object InvokeCommandRegistry {
NodeCapabilityAvailability.CameraEnabled -> flags.cameraEnabled
NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled
NodeCapabilityAvailability.SmsAvailable -> flags.sendSmsAvailable || flags.readSmsAvailable
NodeCapabilityAvailability.CallLogAvailable -> flags.callLogAvailable
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable
}
@ -236,6 +244,7 @@ object InvokeCommandRegistry {
InvokeCommandAvailability.LocationEnabled -> flags.locationEnabled
InvokeCommandAvailability.SendSmsAvailable -> flags.sendSmsAvailable
InvokeCommandAvailability.ReadSmsAvailable -> flags.readSmsAvailable
InvokeCommandAvailability.CallLogAvailable -> flags.callLogAvailable
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
InvokeCommandAvailability.DebugBuild -> flags.debugBuild

View File

@ -34,6 +34,7 @@ class InvokeDispatcher(
private val locationEnabled: () -> Boolean,
private val sendSmsAvailable: () -> Boolean,
private val readSmsAvailable: () -> Boolean,
private val callLogAvailable: () -> Boolean,
private val debugBuild: () -> Boolean,
private val refreshNodeCanvasCapability: suspend () -> Boolean,
private val onCanvasA2uiPush: () -> Unit,
@ -276,6 +277,15 @@ class InvokeDispatcher(
message = "SMS_UNAVAILABLE: SMS not available on this device",
)
}
InvokeCommandAvailability.CallLogAvailable ->
if (callLogAvailable()) {
null
} else {
GatewaySession.InvokeResult.error(
code = "CALL_LOG_UNAVAILABLE",
message = "CALL_LOG_UNAVAILABLE: call log not available on this build",
)
}
InvokeCommandAvailability.DebugBuild ->
if (debugBuild()) {
null

View File

@ -93,6 +93,7 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.node.DeviceNotificationListenerService
@ -238,8 +239,10 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
val smsAvailable =
remember(context) {
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
BuildConfig.OPENCLAW_ENABLE_SMS &&
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
}
val callLogAvailable = remember { BuildConfig.OPENCLAW_ENABLE_CALL_LOG }
val motionAvailable =
remember(context) {
hasMotionCapabilities(context)
@ -297,7 +300,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
}
var enableCallLog by
rememberSaveable {
mutableStateOf(isPermissionGranted(context, Manifest.permission.READ_CALL_LOG))
mutableStateOf(callLogAvailable && isPermissionGranted(context, Manifest.permission.READ_CALL_LOG))
}
var pendingPermissionToggle by remember { mutableStateOf<PermissionToggle?>(null) }
@ -315,7 +318,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
PermissionToggle.Calendar -> enableCalendar = enabled
PermissionToggle.Motion -> enableMotion = enabled && motionAvailable
PermissionToggle.Sms -> enableSms = enabled && smsAvailable
PermissionToggle.CallLog -> enableCallLog = enabled
PermissionToggle.CallLog -> enableCallLog = enabled && callLogAvailable
}
}
@ -345,7 +348,8 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
!smsAvailable ||
(isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
isPermissionGranted(context, Manifest.permission.READ_SMS))
PermissionToggle.CallLog -> isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)
PermissionToggle.CallLog ->
!callLogAvailable || isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)
}
fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) {
@ -369,6 +373,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
enableSms,
enableCallLog,
smsAvailable,
callLogAvailable,
motionAvailable,
) {
val enabled = mutableListOf<String>()
@ -383,7 +388,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
if (enableCalendar) enabled += "Calendar"
if (enableMotion && motionAvailable) enabled += "Motion"
if (smsAvailable && enableSms) enabled += "SMS"
if (enableCallLog) enabled += "Call Log"
if (callLogAvailable && enableCallLog) enabled += "Call Log"
if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ")
}
@ -612,6 +617,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
motionPermissionRequired = motionPermissionRequired,
enableSms = enableSms,
smsAvailable = smsAvailable,
callLogAvailable = callLogAvailable,
enableCallLog = enableCallLog,
context = context,
onDiscoveryChange = { checked ->
@ -711,11 +717,15 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
}
},
onCallLogChange = { checked ->
requestPermissionToggle(
PermissionToggle.CallLog,
checked,
listOf(Manifest.permission.READ_CALL_LOG),
)
if (!callLogAvailable) {
setPermissionToggleEnabled(PermissionToggle.CallLog, false)
} else {
requestPermissionToggle(
PermissionToggle.CallLog,
checked,
listOf(Manifest.permission.READ_CALL_LOG),
)
}
},
)
OnboardingStep.FinalCheck ->
@ -1307,6 +1317,7 @@ private fun PermissionsStep(
motionPermissionRequired: Boolean,
enableSms: Boolean,
smsAvailable: Boolean,
callLogAvailable: Boolean,
enableCallLog: Boolean,
context: Context,
onDiscoveryChange: (Boolean) -> Unit,
@ -1453,14 +1464,16 @@ private fun PermissionsStep(
onCheckedChange = onSmsChange,
)
}
InlineDivider()
PermissionToggleRow(
title = "Call Log",
subtitle = "callLog.search",
checked = enableCallLog,
granted = isPermissionGranted(context, Manifest.permission.READ_CALL_LOG),
onCheckedChange = onCallLogChange,
)
if (callLogAvailable) {
InlineDivider()
PermissionToggleRow(
title = "Call Log",
subtitle = "callLog.search",
checked = enableCallLog,
granted = isPermissionGranted(context, Manifest.permission.READ_CALL_LOG),
onCheckedChange = onCallLogChange,
)
}
Text("All settings can be changed later in Settings.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
}
}

View File

@ -149,8 +149,10 @@ fun SettingsSheet(viewModel: MainViewModel) {
val smsPermissionAvailable =
remember {
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
BuildConfig.OPENCLAW_ENABLE_SMS &&
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
}
val callLogPermissionAvailable = remember { BuildConfig.OPENCLAW_ENABLE_CALL_LOG }
val photosPermission =
if (Build.VERSION.SDK_INT >= 33) {
Manifest.permission.READ_MEDIA_IMAGES
@ -622,31 +624,33 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
},
)
HorizontalDivider(color = mobileBorder)
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Call Log", style = mobileHeadline) },
supportingContent = { Text("Search recent call history.", style = mobileCallout) },
trailingContent = {
Button(
onClick = {
if (callLogPermissionGranted) {
openAppSettings(context)
} else {
callLogPermissionLauncher.launch(Manifest.permission.READ_CALL_LOG)
}
},
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text(
if (callLogPermissionGranted) "Manage" else "Grant",
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
)
}
},
)
if (callLogPermissionAvailable) {
HorizontalDivider(color = mobileBorder)
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Call Log", style = mobileHeadline) },
supportingContent = { Text("Search recent call history.", style = mobileCallout) },
trailingContent = {
Button(
onClick = {
if (callLogPermissionGranted) {
openAppSettings(context)
} else {
callLogPermissionLauncher.launch(Manifest.permission.READ_CALL_LOG)
}
},
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text(
if (callLogPermissionGranted) "Manage" else "Grant",
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
)
}
},
)
}
if (motionAvailable) {
HorizontalDivider(color = mobileBorder)
ListItem(

View File

@ -0,0 +1,13 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission
android:name="android.permission.SEND_SMS"
tools:node="remove" />
<uses-permission
android:name="android.permission.READ_SMS"
tools:node="remove" />
<uses-permission
android:name="android.permission.READ_CALL_LOG"
tools:node="remove" />
</manifest>

View File

@ -26,7 +26,6 @@ class InvokeCommandRegistryTest {
OpenClawCapability.Photos.rawValue,
OpenClawCapability.Contacts.rawValue,
OpenClawCapability.Calendar.rawValue,
OpenClawCapability.CallLog.rawValue,
)
private val optionalCapabilities =
@ -34,6 +33,7 @@ class InvokeCommandRegistryTest {
OpenClawCapability.Camera.rawValue,
OpenClawCapability.Location.rawValue,
OpenClawCapability.Sms.rawValue,
OpenClawCapability.CallLog.rawValue,
OpenClawCapability.VoiceWake.rawValue,
OpenClawCapability.Motion.rawValue,
)
@ -52,7 +52,6 @@ class InvokeCommandRegistryTest {
OpenClawContactsCommand.Add.rawValue,
OpenClawCalendarCommand.Events.rawValue,
OpenClawCalendarCommand.Add.rawValue,
OpenClawCallLogCommand.Search.rawValue,
)
private val optionalCommands =
@ -65,6 +64,7 @@ class InvokeCommandRegistryTest {
OpenClawMotionCommand.Pedometer.rawValue,
OpenClawSmsCommand.Send.rawValue,
OpenClawSmsCommand.Search.rawValue,
OpenClawCallLogCommand.Search.rawValue,
)
private val debugCommands = setOf("debug.logs", "debug.ed25519")
@ -86,6 +86,7 @@ class InvokeCommandRegistryTest {
locationEnabled = true,
sendSmsAvailable = true,
readSmsAvailable = true,
callLogAvailable = true,
voiceWakeEnabled = true,
motionActivityAvailable = true,
motionPedometerAvailable = true,
@ -112,6 +113,7 @@ class InvokeCommandRegistryTest {
locationEnabled = true,
sendSmsAvailable = true,
readSmsAvailable = true,
callLogAvailable = true,
motionActivityAvailable = true,
motionPedometerAvailable = true,
debugBuild = true,
@ -130,6 +132,7 @@ class InvokeCommandRegistryTest {
locationEnabled = false,
sendSmsAvailable = false,
readSmsAvailable = false,
callLogAvailable = false,
voiceWakeEnabled = false,
motionActivityAvailable = true,
motionPedometerAvailable = false,
@ -173,11 +176,26 @@ class InvokeCommandRegistryTest {
assertTrue(sendOnlyCapabilities.contains(OpenClawCapability.Sms.rawValue))
}
@Test
fun advertisedCommands_excludesCallLogWhenUnavailable() {
val commands = InvokeCommandRegistry.advertisedCommands(defaultFlags(callLogAvailable = false))
assertFalse(commands.contains(OpenClawCallLogCommand.Search.rawValue))
}
@Test
fun advertisedCapabilities_excludesCallLogWhenUnavailable() {
val capabilities = InvokeCommandRegistry.advertisedCapabilities(defaultFlags(callLogAvailable = false))
assertFalse(capabilities.contains(OpenClawCapability.CallLog.rawValue))
}
private fun defaultFlags(
cameraEnabled: Boolean = false,
locationEnabled: Boolean = false,
sendSmsAvailable: Boolean = false,
readSmsAvailable: Boolean = false,
callLogAvailable: Boolean = false,
voiceWakeEnabled: Boolean = false,
motionActivityAvailable: Boolean = false,
motionPedometerAvailable: Boolean = false,
@ -188,6 +206,7 @@ class InvokeCommandRegistryTest {
locationEnabled = locationEnabled,
sendSmsAvailable = sendSmsAvailable,
readSmsAvailable = readSmsAvailable,
callLogAvailable = callLogAvailable,
voiceWakeEnabled = voiceWakeEnabled,
motionActivityAvailable = motionActivityAvailable,
motionPedometerAvailable = motionPedometerAvailable,

View File

@ -7,7 +7,28 @@ import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const androidDir = join(scriptDir, "..");
const buildGradlePath = join(androidDir, "app", "build.gradle.kts");
const bundlePath = join(androidDir, "app", "build", "outputs", "bundle", "release", "app-release.aab");
const releaseOutputDir = join(androidDir, "build", "release-bundles");
const releaseVariants = [
{
flavorName: "play",
gradleTask: ":app:bundlePlayRelease",
bundlePath: join(androidDir, "app", "build", "outputs", "bundle", "playRelease", "app-play-release.aab"),
},
{
flavorName: "third-party",
gradleTask: ":app:bundleThirdPartyRelease",
bundlePath: join(
androidDir,
"app",
"build",
"outputs",
"bundle",
"thirdPartyRelease",
"app-thirdParty-release.aab",
),
},
] as const;
type VersionState = {
versionName: string;
@ -88,6 +109,15 @@ async function verifyBundleSignature(path: string): Promise<void> {
await $`jarsigner -verify ${path}`.quiet();
}
async function copyBundle(sourcePath: string, destinationPath: string): Promise<void> {
const sourceFile = Bun.file(sourcePath);
if (!(await sourceFile.exists())) {
throw new Error(`Signed bundle missing at ${sourcePath}`);
}
await Bun.write(destinationPath, sourceFile);
}
async function main() {
const buildGradleFile = Bun.file(buildGradlePath);
const originalText = await buildGradleFile.text();
@ -102,24 +132,28 @@ async function main() {
console.log(`Android versionCode -> ${nextVersion.versionCode}`);
await Bun.write(buildGradlePath, updatedText);
await $`mkdir -p ${releaseOutputDir}`;
try {
await $`./gradlew :app:bundleRelease`.cwd(androidDir);
await $`./gradlew ${releaseVariants[0].gradleTask} ${releaseVariants[1].gradleTask}`.cwd(androidDir);
} catch (error) {
await Bun.write(buildGradlePath, originalText);
throw error;
}
const bundleFile = Bun.file(bundlePath);
if (!(await bundleFile.exists())) {
throw new Error(`Signed bundle missing at ${bundlePath}`);
for (const variant of releaseVariants) {
const outputPath = join(
releaseOutputDir,
`openclaw-${nextVersion.versionName}-${variant.flavorName}-release.aab`,
);
await copyBundle(variant.bundlePath, outputPath);
await verifyBundleSignature(outputPath);
const hash = await sha256Hex(outputPath);
console.log(`Signed AAB (${variant.flavorName}): ${outputPath}`);
console.log(`SHA-256 (${variant.flavorName}): ${hash}`);
}
await verifyBundleSignature(bundlePath);
const hash = await sha256Hex(bundlePath);
console.log(`Signed AAB: ${bundlePath}`);
console.log(`SHA-256: ${hash}`);
}
await main();

View File

@ -53873,6 +53873,169 @@
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
"hasChildren": false
},
{
"path": "plugins.entries.tavily",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "@openclaw/tavily-plugin",
"help": "OpenClaw Tavily plugin (plugin: tavily)",
"hasChildren": true
},
{
"path": "plugins.entries.tavily.config",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "@openclaw/tavily-plugin Config",
"help": "Plugin-defined config payload for tavily.",
"hasChildren": true
},
{
"path": "plugins.entries.tavily.config.webSearch",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
"path": "plugins.entries.tavily.config.webSearch.apiKey",
"kind": "plugin",
"type": [
"object",
"string"
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"security"
],
"label": "Tavily API Key",
"help": "Tavily API key for web search and extraction (fallback: TAVILY_API_KEY env var).",
"hasChildren": false
},
{
"path": "plugins.entries.tavily.config.webSearch.baseUrl",
"kind": "plugin",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Tavily Base URL",
"help": "Tavily API base URL override.",
"hasChildren": false
},
{
"path": "plugins.entries.tavily.enabled",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Enable @openclaw/tavily-plugin",
"hasChildren": false
},
{
"path": "plugins.entries.tavily.hooks",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Plugin Hook Policy",
"help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.",
"hasChildren": true
},
{
"path": "plugins.entries.tavily.hooks.allowPromptInjection",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"access"
],
"label": "Allow Prompt Injection Hooks",
"help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.",
"hasChildren": false
},
{
"path": "plugins.entries.tavily.subagent",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Plugin Subagent Policy",
"help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.",
"hasChildren": true
},
{
"path": "plugins.entries.tavily.subagent.allowedModels",
"kind": "plugin",
"type": "array",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"access"
],
"label": "Plugin Subagent Allowed Models",
"help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.",
"hasChildren": true
},
{
"path": "plugins.entries.tavily.subagent.allowedModels.*",
"kind": "plugin",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "plugins.entries.tavily.subagent.allowModelOverride",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"access"
],
"label": "Allow Plugin Subagent Model Override",
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
"hasChildren": false
},
{
"path": "plugins.entries.telegram",
"kind": "plugin",

View File

@ -1,4 +1,4 @@
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5537}
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5549}
{"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}
@ -4661,6 +4661,18 @@
{"recordType":"path","path":"plugins.entries.talk-voice.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.talk-voice.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.talk-voice.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.tavily","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/tavily-plugin","help":"OpenClaw Tavily plugin (plugin: tavily)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.tavily.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/tavily-plugin Config","help":"Plugin-defined config payload for tavily.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.tavily.config.webSearch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"plugins.entries.tavily.config.webSearch.apiKey","kind":"plugin","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Tavily API Key","help":"Tavily API key for web search and extraction (fallback: TAVILY_API_KEY env var).","hasChildren":false}
{"recordType":"path","path":"plugins.entries.tavily.config.webSearch.baseUrl","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Tavily Base URL","help":"Tavily API base URL override.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.tavily.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/tavily-plugin","hasChildren":false}
{"recordType":"path","path":"plugins.entries.tavily.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.tavily.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.tavily.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.tavily.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.tavily.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.tavily.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.telegram","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/telegram","help":"OpenClaw Telegram channel plugin (plugin: telegram)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.telegram.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/telegram Config","help":"Plugin-defined config payload for telegram.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.telegram.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/telegram","hasChildren":false}

View File

@ -13,7 +13,7 @@ title: "Polls"
- Telegram
- WhatsApp (web channel)
- Discord
- MS Teams (Adaptive Cards)
- Microsoft Teams (Adaptive Cards)
## CLI
@ -37,7 +37,7 @@ openclaw message poll --channel discord --target channel:123456789 \
openclaw message poll --channel discord --target channel:123456789 \
--poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48
# MS Teams
# Microsoft Teams
openclaw message poll --channel msteams --target conversation:19:abc@thread.tacv2 \
--poll-question "Lunch?" --poll-option "Pizza" --poll-option "Sushi"
```
@ -71,7 +71,7 @@ Params:
- Telegram: 2-10 options. Supports forum topics via `threadId` or `:topic:` targets. Uses `durationSeconds` instead of `durationHours`, limited to 5-600 seconds. Supports anonymous and public polls.
- WhatsApp: 2-12 options, `maxSelections` must be within option count, ignores `durationHours`.
- Discord: 2-10 options, `durationHours` clamped to 1-768 hours (default 24). `maxSelections > 1` enables multi-select; Discord does not support a strict selection count.
- MS Teams: Adaptive Card polls (OpenClaw-managed). No native poll API; `durationHours` is ignored.
- Microsoft Teams: Adaptive Card polls (OpenClaw-managed). No native poll API; `durationHours` is ignored.
## Agent tool (Message)

View File

@ -85,7 +85,7 @@ Payload:
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `mattermost` (plugin), `signal`, `imessage`, `msteams`. Defaults to `last`.
- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack/Mattermost (plugin), conversation ID for MS Teams). Defaults to the last recipient in the main session.
- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack/Mattermost (plugin), conversation ID for Microsoft Teams). Defaults to the last recipient in the main session.
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
- `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds.

View File

@ -290,7 +290,7 @@ Example (Telegram):
Notes:
- Group/channel tool restrictions are applied in addition to global/agent tool policy (deny still wins).
- Some channels use different nesting for rooms/channels (e.g., Discord `guilds.*.channels.*`, Slack `channels.*`, MS Teams `teams.*.channels.*`).
- Some channels use different nesting for rooms/channels (e.g., Discord `guilds.*.channels.*`, Slack `channels.*`, Microsoft Teams `teams.*.channels.*`).
## Group allowlists

View File

@ -1,7 +1,7 @@
---
summary: "Microsoft Teams bot support status, capabilities, and configuration"
read_when:
- Working on MS Teams channel features
- Working on Microsoft Teams channel features
title: "Microsoft Teams"
---
@ -17,9 +17,9 @@ Status: text + DM attachments are supported; channel/group file sending requires
Microsoft Teams ships as a plugin and is not bundled with the core install.
**Breaking change (2026.1.15):** MS Teams moved out of core. If you use it, you must install the plugin.
**Breaking change (2026.1.15):** Microsoft Teams moved out of core. If you use it, you must install the plugin.
Explainable: keeps core installs lighter and lets MS Teams dependencies update independently.
Explainable: keeps core installs lighter and lets Microsoft Teams dependencies update independently.
Install via CLI (npm registry):

View File

@ -83,7 +83,7 @@ Notes:
- `--channel` is optional; omit it to list every channel (including extensions).
- `--target` accepts `channel:<id>` or a raw numeric channel id and only applies to Discord.
- Probes are provider-specific: Discord intents + optional channel permissions; Slack bot + user scopes; Telegram bot flags + webhook; Signal daemon version; MS Teams app token + Graph roles/scopes (annotated where known). Channels without probes report `Probe: unavailable`.
- Probes are provider-specific: Discord intents + optional channel permissions; Slack bot + user scopes; Telegram bot flags + webhook; Signal daemon version; Microsoft Teams app token + Graph roles/scopes (annotated where known). Channels without probes report `Probe: unavailable`.
## Resolve names to IDs

View File

@ -424,7 +424,7 @@ Options:
### `channels`
Manage chat channel accounts (WhatsApp/Telegram/Discord/Google Chat/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams).
Manage chat channel accounts (WhatsApp/Telegram/Discord/Google Chat/Slack/Mattermost (plugin)/Signal/iMessage/Microsoft Teams).
Subcommands:

View File

@ -9,7 +9,7 @@ title: "message"
# `openclaw message`
Single outbound command for sending messages and channel actions
(Discord/Google Chat/Slack/Mattermost (plugin)/Telegram/WhatsApp/Signal/iMessage/MS Teams).
(Discord/Google Chat/Slack/Mattermost (plugin)/Telegram/WhatsApp/Signal/iMessage/Microsoft Teams).
## Usage
@ -33,7 +33,7 @@ Target formats (`--target`):
- Mattermost (plugin): `channel:<id>`, `user:<id>`, or `@username` (bare ids are treated as channels)
- Signal: `+E.164`, `group:<id>`, `signal:+E.164`, `signal:group:<id>`, or `username:<name>`/`u:<name>`
- iMessage: handle, `chat_id:<id>`, `chat_guid:<guid>`, or `chat_identifier:<id>`
- MS Teams: conversation id (`19:...@thread.tacv2`) or `conversation:<id>` or `user:<aad-object-id>`
- Microsoft Teams: conversation id (`19:...@thread.tacv2`) or `conversation:<id>` or `user:<aad-object-id>`
Name lookup:
@ -65,7 +65,7 @@ Name lookup:
### Core
- `send`
- Channels: WhatsApp/Telegram/Discord/Google Chat/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams
- Channels: WhatsApp/Telegram/Discord/Google Chat/Slack/Mattermost (plugin)/Signal/iMessage/Microsoft Teams
- Required: `--target`, plus `--message` or `--media`
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
- Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it)
@ -75,7 +75,7 @@ Name lookup:
- WhatsApp only: `--gif-playback`
- `poll`
- Channels: WhatsApp/Telegram/Discord/Matrix/MS Teams
- Channels: WhatsApp/Telegram/Discord/Matrix/Microsoft Teams
- Required: `--target`, `--poll-question`, `--poll-option` (repeat)
- Optional: `--poll-multi`
- Discord only: `--poll-duration-hours`, `--silent`, `--message`

View File

@ -37,7 +37,7 @@ It also warns when sandbox browser uses Docker `bridge` network without `sandbox
It also flags dangerous sandbox Docker network modes (including `host` and `container:*` namespace joins).
It also warns when existing sandbox browser Docker containers have missing/stale hash labels (for example pre-migration containers missing `openclaw.browserConfigEpoch`) and recommends `openclaw sandbox recreate --browser --all`.
It also warns when npm-based plugin/hook install records are unpinned, missing integrity metadata, or drift from currently installed package versions.
It warns when channel allowlists rely on mutable names/emails/tags instead of stable IDs (Discord, Slack, Google Chat, MS Teams, Mattermost, IRC scopes where applicable).
It warns when channel allowlists rely on mutable names/emails/tags instead of stable IDs (Discord, Slack, Google Chat, Microsoft Teams, Mattermost, IRC scopes where applicable).
It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable without a shared secret (`/tools/invoke` plus any enabled `/v1/*` endpoint).
Settings prefixed with `dangerous`/`dangerously` are explicit break-glass operator overrides; enabling one is not, by itself, a security vulnerability report.
For the complete dangerous-parameter inventory, see the "Insecure or dangerous flags summary" section in [Security](/gateway/security).

View File

@ -35,7 +35,7 @@ title: "Features"
**Channels:**
- WhatsApp, Telegram, Discord, iMessage (built-in)
- Mattermost, Matrix, MS Teams, Nostr, and more (plugins)
- Mattermost, Matrix, Microsoft Teams, Nostr, and more (plugins)
- Group chat support with mention-based activation
- DM safety with allowlists and pairing

View File

@ -57,7 +57,7 @@ IR (schematic):
## Where it is used
- Slack, Telegram, and Signal outbound adapters render from the IR.
- Other channels (WhatsApp, iMessage, MS Teams, Discord) still use plain text or
- Other channels (WhatsApp, iMessage, Microsoft Teams, Discord) still use plain text or
their own formatting rules, with Markdown table conversion applied before
chunking when enabled.

View File

@ -494,7 +494,7 @@ If more than one person can DM your bot (multiple entries in `allowFrom`, pairin
}
```
For Discord/Slack/Google Chat/MS Teams/Mattermost/IRC, sender authorization is ID-first by default.
For Discord/Slack/Google Chat/Microsoft Teams/Mattermost/IRC, sender authorization is ID-first by default.
Only enable direct mutable name/email/nick matching with each channel's `dangerouslyAllowNameMatching: true` if you explicitly accept that risk.
### OAuth with API key failover

View File

@ -85,7 +85,7 @@ When validation fails:
- [iMessage](/channels/imessage) — `channels.imessage`
- [Google Chat](/channels/googlechat) — `channels.googlechat`
- [Mattermost](/channels/mattermost) — `channels.mattermost`
- [MS Teams](/channels/msteams) — `channels.msteams`
- [Microsoft Teams](/channels/msteams) — `channels.msteams`
All channels share the same DM policy pattern:

View File

@ -9,7 +9,7 @@ title: "Android App"
# Android App (Node)
> **Note:** The Android app has not been publicly released yet. The source code is available in the [OpenClaw repository](https://github.com/openclaw/openclaw) under `apps/android`. You can build it yourself using Java 17 and the Android SDK (`./gradlew :app:assembleDebug`). See [apps/android/README.md](https://github.com/openclaw/openclaw/blob/main/apps/android/README.md) for build instructions.
> **Note:** The Android app has not been publicly released yet. The source code is available in the [OpenClaw repository](https://github.com/openclaw/openclaw) under `apps/android`. You can build it yourself using Java 17 and the Android SDK (`./gradlew :app:assemblePlayDebug`). See [apps/android/README.md](https://github.com/openclaw/openclaw/blob/main/apps/android/README.md) for build instructions.
## Support snapshot

View File

@ -448,12 +448,12 @@ For full behavior, limits, config, and examples, see [PDF tool](/tools/pdf).
### `message`
Send messages and channel actions across Discord/Google Chat/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams.
Send messages and channel actions across Discord/Google Chat/Slack/Telegram/WhatsApp/Signal/iMessage/Microsoft Teams.
Core actions:
- `send` (text + optional media; MS Teams also supports `card` for Adaptive Cards)
- `poll` (WhatsApp/Discord/MS Teams polls)
- `send` (text + optional media; Microsoft Teams also supports `card` for Adaptive Cards)
- `poll` (WhatsApp/Discord/Microsoft Teams polls)
- `react` / `reactions` / `read` / `edit` / `delete`
- `pin` / `unpin` / `list-pins`
- `permissions`
@ -471,7 +471,7 @@ Core actions:
Notes:
- `send` routes WhatsApp via the Gateway; other channels go direct.
- `poll` uses the Gateway for WhatsApp and MS Teams; Discord polls go direct.
- `poll` uses the Gateway for WhatsApp and Microsoft Teams; Discord polls go direct.
- When a message tool call is bound to an active chat session, sends are constrained to that sessions target to avoid cross-context leaks.
### `cron`

View File

@ -65,14 +65,14 @@ marketplace source with `--marketplace`.
These are published to npm and installed with `openclaw plugins install`:
| Plugin | Package | Docs |
| --------------- | ---------------------- | ---------------------------------- |
| Matrix | `@openclaw/matrix` | [Matrix](/channels/matrix) |
| Microsoft Teams | `@openclaw/msteams` | [MS Teams](/channels/msteams) |
| Nostr | `@openclaw/nostr` | [Nostr](/channels/nostr) |
| Voice Call | `@openclaw/voice-call` | [Voice Call](/plugins/voice-call) |
| Zalo | `@openclaw/zalo` | [Zalo](/channels/zalo) |
| Zalo Personal | `@openclaw/zalouser` | [Zalo Personal](/plugins/zalouser) |
| Plugin | Package | Docs |
| --------------- | ---------------------- | ------------------------------------ |
| Matrix | `@openclaw/matrix` | [Matrix](/channels/matrix) |
| Microsoft Teams | `@openclaw/msteams` | [Microsoft Teams](/channels/msteams) |
| Nostr | `@openclaw/nostr` | [Nostr](/channels/nostr) |
| Voice Call | `@openclaw/voice-call` | [Voice Call](/plugins/voice-call) |
| Zalo | `@openclaw/zalo` | [Zalo](/channels/zalo) |
| Zalo Personal | `@openclaw/zalouser` | [Zalo Personal](/plugins/zalouser) |
Microsoft Teams is plugin-only as of 2026.1.15.

View File

@ -50,7 +50,7 @@ They run immediately, are stripped before the model sees the message, and the re
```
- `commands.text` (default `true`) enables parsing `/...` in chat messages.
- On surfaces without native commands (WhatsApp/WebChat/Signal/iMessage/Google Chat/MS Teams), text commands still work even if you set this to `false`.
- On surfaces without native commands (WhatsApp/WebChat/Signal/iMessage/Google Chat/Microsoft Teams), text commands still work even if you set this to `false`.
- `commands.native` (default `"auto"`) registers native commands.
- Auto: on for Discord/Telegram; off for Slack (until you add slash commands); ignored for providers without native support.
- Set `channels.discord.commands.native`, `channels.telegram.commands.native`, or `channels.slack.commands.native` to override per provider (bool or `"auto"`).

View File

@ -4,6 +4,7 @@ import {
DEFAULT_SEARCH_COUNT,
MAX_SEARCH_COUNT,
formatCliCommand,
mergeScopedSearchConfig,
normalizeFreshness,
normalizeToIsoDate,
readCachedSearchPayload,
@ -607,21 +608,12 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
},
createTool: (ctx) =>
createBraveToolDefinition(
(() => {
const searchConfig = ctx.searchConfig as SearchConfigRecord | undefined;
const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "brave");
if (!pluginConfig) {
return searchConfig;
}
return {
...(searchConfig ?? {}),
...(pluginConfig.apiKey === undefined ? {} : { apiKey: pluginConfig.apiKey }),
brave: {
...resolveBraveConfig(searchConfig),
...pluginConfig,
},
} as SearchConfigRecord;
})(),
mergeScopedSearchConfig(
ctx.searchConfig as SearchConfigRecord | undefined,
"brave",
resolveProviderWebSearchPluginConfig(ctx.config, "brave"),
{ mirrorApiKeyToTopLevel: true },
) as SearchConfigRecord | undefined,
),
};
}

View File

@ -88,20 +88,17 @@ describe("monitorDiscordProvider", () => {
const getConstructedEventQueue = (): { listenerTimeout?: number } | undefined => {
expect(clientConstructorOptionsMock).toHaveBeenCalledTimes(1);
const opts = clientConstructorOptionsMock.mock.calls[0]?.[0] as {
commandDeploymentMode?: string;
eventQueue?: { listenerTimeout?: number };
};
return opts.eventQueue;
};
const getConstructedClientOptions = (): {
commandDeploymentMode?: string;
eventQueue?: { listenerTimeout?: number };
} => {
expect(clientConstructorOptionsMock).toHaveBeenCalledTimes(1);
return (
(clientConstructorOptionsMock.mock.calls[0]?.[0] as {
commandDeploymentMode?: string;
eventQueue?: { listenerTimeout?: number };
}) ?? {}
);
@ -553,7 +550,7 @@ describe("monitorDiscordProvider", () => {
);
});
it("configures Carbon reconcile deployment by default", async () => {
it("configures Carbon native deploy by default", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
await monitorDiscordProvider({
@ -562,7 +559,7 @@ describe("monitorDiscordProvider", () => {
});
expect(clientHandleDeployRequestMock).toHaveBeenCalledTimes(1);
expect(getConstructedClientOptions().commandDeploymentMode).toBe("reconcile");
expect(getConstructedClientOptions().eventQueue?.listenerTimeout).toBe(120_000);
});
it("reports connected status on startup and shutdown", async () => {

View File

@ -763,7 +763,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
baseUrl: "http://localhost",
deploySecret: "a",
clientId: applicationId,
commandDeploymentMode: "reconcile",
publicKey: "a",
token,
autoDeploy: false,

View File

@ -166,13 +166,6 @@ function createTopicEvent(messageId: string) {
};
}
async function settleAsyncWork(): Promise<void> {
for (let i = 0; i < 6; i += 1) {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
async function setupLifecycleMonitor() {
const register = vi.fn((registered: Record<string, (data: unknown) => Promise<void>>) => {
handlers = registered;
@ -201,6 +194,7 @@ async function setupLifecycleMonitor() {
describe("Feishu ACP-init failure lifecycle", () => {
beforeEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
handlers = {};
lastRuntime = null;
@ -334,6 +328,7 @@ describe("Feishu ACP-init failure lifecycle", () => {
});
afterEach(() => {
vi.useRealTimers();
if (originalStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
return;
@ -346,9 +341,13 @@ describe("Feishu ACP-init failure lifecycle", () => {
const event = createTopicEvent("om_topic_msg_1");
await onMessage(event);
await settleAsyncWork();
await vi.waitFor(() => {
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
});
await onMessage(event);
await settleAsyncWork();
await vi.waitFor(() => {
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
});
expect(lastRuntime?.error).not.toHaveBeenCalled();
expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1);
@ -371,9 +370,13 @@ describe("Feishu ACP-init failure lifecycle", () => {
const event = createTopicEvent("om_topic_msg_2");
await onMessage(event);
await settleAsyncWork();
await vi.waitFor(() => {
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
});
await onMessage(event);
await settleAsyncWork();
await vi.waitFor(() => {
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
});
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
expect(lastRuntime?.error).not.toHaveBeenCalled();

View File

@ -155,13 +155,6 @@ function createBotMenuEvent(params: { eventKey: string; timestamp: string }) {
};
}
async function settleAsyncWork(): Promise<void> {
for (let i = 0; i < 6; i += 1) {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
async function setupLifecycleMonitor() {
const register = vi.fn((registered: Record<string, (data: unknown) => Promise<void>>) => {
handlers = registered;
@ -190,6 +183,7 @@ async function setupLifecycleMonitor() {
describe("Feishu bot-menu lifecycle", () => {
beforeEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
handlers = {};
lastRuntime = null;
@ -292,6 +286,7 @@ describe("Feishu bot-menu lifecycle", () => {
});
afterEach(() => {
vi.useRealTimers();
if (originalStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
return;
@ -307,9 +302,13 @@ describe("Feishu bot-menu lifecycle", () => {
});
await onBotMenu(event);
await settleAsyncWork();
await vi.waitFor(() => {
expect(sendCardFeishuMock).toHaveBeenCalledTimes(1);
});
await onBotMenu(event);
await settleAsyncWork();
await vi.waitFor(() => {
expect(sendCardFeishuMock).toHaveBeenCalledTimes(1);
});
expect(lastRuntime?.error).not.toHaveBeenCalled();
expect(sendCardFeishuMock).toHaveBeenCalledTimes(1);
@ -332,9 +331,16 @@ describe("Feishu bot-menu lifecycle", () => {
sendCardFeishuMock.mockRejectedValueOnce(new Error("boom"));
await onBotMenu(event);
await settleAsyncWork();
await vi.waitFor(() => {
expect(sendCardFeishuMock).toHaveBeenCalledTimes(1);
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
});
await onBotMenu(event);
await settleAsyncWork();
await vi.waitFor(() => {
expect(sendCardFeishuMock).toHaveBeenCalledTimes(1);
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
expect(createFeishuReplyDispatcherMock).toHaveBeenCalledTimes(1);
});
expect(lastRuntime?.error).not.toHaveBeenCalled();
expect(sendCardFeishuMock).toHaveBeenCalledTimes(1);

View File

@ -184,13 +184,6 @@ function createBroadcastEvent(messageId: string) {
};
}
async function settleAsyncWork(): Promise<void> {
for (let i = 0; i < 6; i += 1) {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
async function setupLifecycleMonitor(accountId: "account-A" | "account-B") {
const register = vi.fn((registered: Record<string, (data: unknown) => Promise<void>>) => {
handlersByAccount.set(accountId, registered);
@ -220,6 +213,7 @@ async function setupLifecycleMonitor(accountId: "account-A" | "account-B") {
describe("Feishu broadcast reply-once lifecycle", () => {
beforeEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
handlersByAccount = new Map();
runtimesByAccount = new Map();
@ -327,6 +321,7 @@ describe("Feishu broadcast reply-once lifecycle", () => {
});
afterEach(() => {
vi.useRealTimers();
if (originalStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
return;
@ -340,9 +335,14 @@ describe("Feishu broadcast reply-once lifecycle", () => {
const event = createBroadcastEvent("om_broadcast_once");
await onMessageA(event);
await settleAsyncWork();
await vi.waitFor(() => {
expect(dispatchReplyFromConfigMock.mock.calls.length).toBeGreaterThan(0);
});
await onMessageB(event);
await settleAsyncWork();
await vi.waitFor(() => {
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(2);
expect(createFeishuReplyDispatcherMock).toHaveBeenCalledTimes(1);
});
expect(runtimesByAccount.get("account-A")?.error).not.toHaveBeenCalled();
expect(runtimesByAccount.get("account-B")?.error).not.toHaveBeenCalled();
@ -383,9 +383,13 @@ describe("Feishu broadcast reply-once lifecycle", () => {
});
await onMessageA(event);
await settleAsyncWork();
await vi.waitFor(() => {
expect(dispatchReplyFromConfigMock.mock.calls.length).toBeGreaterThan(0);
});
await onMessageB(event);
await settleAsyncWork();
await vi.waitFor(() => {
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(2);
});
expect(runtimesByAccount.get("account-A")?.error).not.toHaveBeenCalled();
expect(runtimesByAccount.get("account-B")?.error).not.toHaveBeenCalled();

View File

@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js";
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
import { resetProcessedFeishuCardActionTokensForTests } from "./card-action.js";
import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
import { monitorSingleAccount } from "./monitor.account.js";
import { setFeishuRuntime } from "./runtime.js";
@ -181,13 +182,6 @@ function createCardActionEvent(params: {
};
}
async function settleAsyncWork(): Promise<void> {
for (let i = 0; i < 6; i += 1) {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
async function setupLifecycleMonitor() {
const register = vi.fn((registered: Record<string, (data: unknown) => Promise<void>>) => {
handlers = registered;
@ -216,9 +210,11 @@ async function setupLifecycleMonitor() {
describe("Feishu card-action lifecycle", () => {
beforeEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
handlers = {};
lastRuntime = null;
resetProcessedFeishuCardActionTokensForTests();
process.env.OPENCLAW_STATE_DIR = `/tmp/openclaw-feishu-card-action-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const dispatcher = {
@ -318,6 +314,8 @@ describe("Feishu card-action lifecycle", () => {
});
afterEach(() => {
vi.useRealTimers();
resetProcessedFeishuCardActionTokensForTests();
if (originalStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
return;
@ -334,9 +332,14 @@ describe("Feishu card-action lifecycle", () => {
});
await onCardAction(event);
await settleAsyncWork();
await vi.waitFor(() => {
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
});
await onCardAction(event);
await settleAsyncWork();
await vi.waitFor(() => {
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
expect(createFeishuReplyDispatcherMock).toHaveBeenCalledTimes(1);
});
expect(lastRuntime?.error).not.toHaveBeenCalled();
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
@ -379,9 +382,15 @@ describe("Feishu card-action lifecycle", () => {
});
await onCardAction(event);
await settleAsyncWork();
await vi.waitFor(() => {
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
expect(lastRuntime?.error).toHaveBeenCalledTimes(1);
});
await onCardAction(event);
await settleAsyncWork();
await vi.waitFor(() => {
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
expect(lastRuntime?.error).toHaveBeenCalledTimes(1);
});
expect(lastRuntime?.error).toHaveBeenCalledTimes(1);
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);

View File

@ -167,13 +167,6 @@ function createTextEvent(messageId: string) {
};
}
async function settleAsyncWork(): Promise<void> {
for (let i = 0; i < 6; i += 1) {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
async function setupLifecycleMonitor() {
const register = vi.fn((registered: Record<string, (data: unknown) => Promise<void>>) => {
handlers = registered;
@ -202,6 +195,7 @@ async function setupLifecycleMonitor() {
describe("Feishu reply-once lifecycle", () => {
beforeEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
handlers = {};
lastRuntime = null;
@ -304,6 +298,7 @@ describe("Feishu reply-once lifecycle", () => {
});
afterEach(() => {
vi.useRealTimers();
if (originalStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
return;
@ -316,9 +311,14 @@ describe("Feishu reply-once lifecycle", () => {
const event = createTextEvent("om_lifecycle_once");
await onMessage(event);
await settleAsyncWork();
await vi.waitFor(() => {
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
});
await onMessage(event);
await settleAsyncWork();
await vi.waitFor(() => {
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
expect(createFeishuReplyDispatcherMock).toHaveBeenCalledTimes(1);
});
expect(lastRuntime?.error).not.toHaveBeenCalled();
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
@ -358,9 +358,15 @@ describe("Feishu reply-once lifecycle", () => {
});
await onMessage(event);
await settleAsyncWork();
await vi.waitFor(() => {
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
expect(lastRuntime?.error).toHaveBeenCalledTimes(1);
});
await onMessage(event);
await settleAsyncWork();
await vi.waitFor(() => {
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
expect(lastRuntime?.error).toHaveBeenCalledTimes(1);
});
expect(lastRuntime?.error).toHaveBeenCalledTimes(1);
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);

View File

@ -1,7 +1,9 @@
import { Type } from "@sinclair/typebox";
import {
enablePluginInConfig,
getScopedCredentialValue,
resolveProviderWebSearchPluginConfig,
setScopedCredentialValue,
setProviderWebSearchPluginConfigValue,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search";
@ -21,26 +23,6 @@ const GenericFirecrawlSearchSchema = Type.Object(
{ additionalProperties: false },
);
function getScopedCredentialValue(searchConfig?: Record<string, unknown>): unknown {
const scoped = searchConfig?.firecrawl;
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
return undefined;
}
return (scoped as Record<string, unknown>).apiKey;
}
function setScopedCredentialValue(
searchConfigTarget: Record<string, unknown>,
value: unknown,
): void {
const scoped = searchConfigTarget.firecrawl;
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
searchConfigTarget.firecrawl = { apiKey: value };
return;
}
(scoped as Record<string, unknown>).apiKey = value;
}
export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin {
return {
id: "firecrawl",
@ -53,8 +35,9 @@ export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin {
autoDetectOrder: 60,
credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.firecrawl.config.webSearch.apiKey"],
getCredentialValue: getScopedCredentialValue,
setCredentialValue: setScopedCredentialValue,
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "firecrawl"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "firecrawl", value),
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "firecrawl")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {

View File

@ -1,8 +1,11 @@
import { Type } from "@sinclair/typebox";
import {
buildSearchCacheKey,
buildUnsupportedSearchFilterResponse,
DEFAULT_SEARCH_COUNT,
getScopedCredentialValue,
MAX_SEARCH_COUNT,
mergeScopedSearchConfig,
readCachedSearchPayload,
readConfiguredSecretString,
readNumberParam,
@ -13,6 +16,7 @@ import {
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
setScopedCredentialValue,
setProviderWebSearchPluginConfigValue,
type SearchConfigRecord,
type WebSearchProviderPlugin,
@ -177,22 +181,9 @@ function createGeminiToolDefinition(
parameters: createGeminiSchema(),
execute: async (args) => {
const params = args as Record<string, unknown>;
for (const name of ["country", "language", "freshness", "date_after", "date_before"]) {
if (readStringParam(params, name)) {
const label =
name === "country"
? "country filtering"
: name === "language"
? "language filtering"
: name === "freshness"
? "freshness filtering"
: "date_after/date_before filtering";
return {
error: name.startsWith("date_") ? "unsupported_date_filter" : `unsupported_${name}`,
message: `${label} is not supported by the gemini provider. Only Brave and Perplexity support ${name === "country" ? "country filtering" : name === "language" ? "language filtering" : name === "freshness" ? "freshness" : "date filtering"}.`,
docs: "https://docs.openclaw.ai/tools/web",
};
}
const unsupportedResponse = buildUnsupportedSearchFilterResponse(params, "gemini");
if (unsupportedResponse) {
return unsupportedResponse;
}
const geminiConfig = resolveGeminiConfig(searchConfig);
@ -262,20 +253,9 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin {
autoDetectOrder: 20,
credentialPath: "plugins.entries.google.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.google.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => {
const gemini = searchConfig?.gemini;
return gemini && typeof gemini === "object" && !Array.isArray(gemini)
? (gemini as Record<string, unknown>).apiKey
: undefined;
},
setCredentialValue: (searchConfigTarget, value) => {
const scoped = searchConfigTarget.gemini;
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
searchConfigTarget.gemini = { apiKey: value };
return;
}
(scoped as Record<string, unknown>).apiKey = value;
},
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "gemini"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "gemini", value),
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "google")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
@ -283,20 +263,11 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin {
},
createTool: (ctx) =>
createGeminiToolDefinition(
(() => {
const searchConfig = ctx.searchConfig as SearchConfigRecord | undefined;
const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "google");
if (!pluginConfig) {
return searchConfig;
}
return {
...(searchConfig ?? {}),
gemini: {
...resolveGeminiConfig(searchConfig),
...pluginConfig,
},
} as SearchConfigRecord;
})(),
mergeScopedSearchConfig(
ctx.searchConfig as SearchConfigRecord | undefined,
"gemini",
resolveProviderWebSearchPluginConfig(ctx.config, "google"),
) as SearchConfigRecord | undefined,
),
};
}

View File

@ -1,8 +1,11 @@
import { Type } from "@sinclair/typebox";
import {
buildSearchCacheKey,
buildUnsupportedSearchFilterResponse,
DEFAULT_SEARCH_COUNT,
getScopedCredentialValue,
MAX_SEARCH_COUNT,
mergeScopedSearchConfig,
readCachedSearchPayload,
readConfiguredSecretString,
readNumberParam,
@ -12,6 +15,7 @@ import {
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
setScopedCredentialValue,
setProviderWebSearchPluginConfigValue,
type SearchConfigRecord,
type WebSearchProviderPlugin,
@ -246,22 +250,9 @@ function createKimiToolDefinition(
parameters: createKimiSchema(),
execute: async (args) => {
const params = args as Record<string, unknown>;
for (const name of ["country", "language", "freshness", "date_after", "date_before"]) {
if (readStringParam(params, name)) {
const label =
name === "country"
? "country filtering"
: name === "language"
? "language filtering"
: name === "freshness"
? "freshness filtering"
: "date_after/date_before filtering";
return {
error: name.startsWith("date_") ? "unsupported_date_filter" : `unsupported_${name}`,
message: `${label} is not supported by the kimi provider. Only Brave and Perplexity support ${name === "country" ? "country filtering" : name === "language" ? "language filtering" : name === "freshness" ? "freshness" : "date filtering"}.`,
docs: "https://docs.openclaw.ai/tools/web",
};
}
const unsupportedResponse = buildUnsupportedSearchFilterResponse(params, "kimi");
if (unsupportedResponse) {
return unsupportedResponse;
}
const kimiConfig = resolveKimiConfig(searchConfig);
@ -334,20 +325,9 @@ export function createKimiWebSearchProvider(): WebSearchProviderPlugin {
autoDetectOrder: 40,
credentialPath: "plugins.entries.moonshot.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.moonshot.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => {
const kimi = searchConfig?.kimi;
return kimi && typeof kimi === "object" && !Array.isArray(kimi)
? (kimi as Record<string, unknown>).apiKey
: undefined;
},
setCredentialValue: (searchConfigTarget, value) => {
const scoped = searchConfigTarget.kimi;
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
searchConfigTarget.kimi = { apiKey: value };
return;
}
(scoped as Record<string, unknown>).apiKey = value;
},
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "kimi"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "kimi", value),
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "moonshot")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
@ -355,20 +335,11 @@ export function createKimiWebSearchProvider(): WebSearchProviderPlugin {
},
createTool: (ctx) =>
createKimiToolDefinition(
(() => {
const searchConfig = ctx.searchConfig as SearchConfigRecord | undefined;
const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "moonshot");
if (!pluginConfig) {
return searchConfig;
}
return {
...(searchConfig ?? {}),
kimi: {
...resolveKimiConfig(searchConfig),
...pluginConfig,
},
} as SearchConfigRecord;
})(),
mergeScopedSearchConfig(
ctx.searchConfig as SearchConfigRecord | undefined,
"kimi",
resolveProviderWebSearchPluginConfig(ctx.config, "moonshot"),
) as SearchConfigRecord | undefined,
),
};
}

View File

@ -7,8 +7,10 @@ import {
import {
buildSearchCacheKey,
DEFAULT_SEARCH_COUNT,
getScopedCredentialValue,
MAX_SEARCH_COUNT,
isoToPerplexityDate,
mergeScopedSearchConfig,
normalizeFreshness,
normalizeToIsoDate,
readCachedSearchPayload,
@ -19,6 +21,7 @@ import {
resolveSearchCount,
resolveSearchTimeoutSeconds,
resolveSiteName,
setScopedCredentialValue,
setProviderWebSearchPluginConfigValue,
throwWebSearchApiError,
type SearchConfigRecord,
@ -658,20 +661,9 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
autoDetectOrder: 50,
credentialPath: "plugins.entries.perplexity.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.perplexity.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => {
const perplexity = searchConfig?.perplexity;
return perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
? (perplexity as Record<string, unknown>).apiKey
: undefined;
},
setCredentialValue: (searchConfigTarget, value) => {
const scoped = searchConfigTarget.perplexity;
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
searchConfigTarget.perplexity = { apiKey: value };
return;
}
(scoped as Record<string, unknown>).apiKey = value;
},
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "perplexity"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "perplexity", value),
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "perplexity")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
@ -679,17 +671,11 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
},
resolveRuntimeMetadata: (ctx) => ({
perplexityTransport: resolveRuntimeTransport({
searchConfig: {
...(ctx.searchConfig as SearchConfigRecord | undefined),
perplexity: {
...((ctx.searchConfig as SearchConfigRecord | undefined)?.perplexity as
| Record<string, unknown>
| undefined),
...(resolveProviderWebSearchPluginConfig(ctx.config, "perplexity") as
| Record<string, unknown>
| undefined),
},
},
searchConfig: mergeScopedSearchConfig(
ctx.searchConfig as SearchConfigRecord | undefined,
"perplexity",
resolveProviderWebSearchPluginConfig(ctx.config, "perplexity"),
) as SearchConfigRecord | undefined,
resolvedKey: ctx.resolvedCredential?.value,
keySource: ctx.resolvedCredential?.source ?? "missing",
fallbackEnvVar: ctx.resolvedCredential?.fallbackEnvVar,
@ -697,20 +683,11 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
}),
createTool: (ctx) =>
createPerplexityToolDefinition(
(() => {
const searchConfig = ctx.searchConfig as SearchConfigRecord | undefined;
const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "perplexity");
if (!pluginConfig) {
return searchConfig;
}
return {
...(searchConfig ?? {}),
perplexity: {
...resolvePerplexityConfig(searchConfig),
...pluginConfig,
},
} as SearchConfigRecord;
})(),
mergeScopedSearchConfig(
ctx.searchConfig as SearchConfigRecord | undefined,
"perplexity",
resolveProviderWebSearchPluginConfig(ctx.config, "perplexity"),
) as SearchConfigRecord | undefined,
ctx.runtimeMetadata?.perplexityTransport as PerplexityTransport | undefined,
),
};

View File

@ -1,7 +1,9 @@
import { Type } from "@sinclair/typebox";
import {
enablePluginInConfig,
getScopedCredentialValue,
resolveProviderWebSearchPluginConfig,
setScopedCredentialValue,
setProviderWebSearchPluginConfigValue,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search";
@ -21,26 +23,6 @@ const GenericTavilySearchSchema = Type.Object(
{ additionalProperties: false },
);
function getScopedCredentialValue(searchConfig?: Record<string, unknown>): unknown {
const scoped = searchConfig?.tavily;
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
return undefined;
}
return (scoped as Record<string, unknown>).apiKey;
}
function setScopedCredentialValue(
searchConfigTarget: Record<string, unknown>,
value: unknown,
): void {
const scoped = searchConfigTarget.tavily;
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
searchConfigTarget.tavily = { apiKey: value };
return;
}
(scoped as Record<string, unknown>).apiKey = value;
}
export function createTavilyWebSearchProvider(): WebSearchProviderPlugin {
return {
id: "tavily",
@ -53,8 +35,9 @@ export function createTavilyWebSearchProvider(): WebSearchProviderPlugin {
autoDetectOrder: 70,
credentialPath: "plugins.entries.tavily.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.tavily.config.webSearch.apiKey"],
getCredentialValue: getScopedCredentialValue,
setCredentialValue: setScopedCredentialValue,
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "tavily"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "tavily", value),
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "tavily")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {

View File

@ -15,12 +15,13 @@ import {
describe("telegram thread bindings", () => {
let stateDirOverride: string | undefined;
beforeEach(() => {
__testing.resetTelegramThreadBindingsForTests();
beforeEach(async () => {
await __testing.resetTelegramThreadBindingsForTests();
});
afterEach(() => {
afterEach(async () => {
vi.useRealTimers();
await __testing.resetTelegramThreadBindingsForTests();
if (stateDirOverride) {
delete process.env.OPENCLAW_STATE_DIR;
fs.rmSync(stateDirOverride, { recursive: true, force: true });
@ -90,7 +91,7 @@ describe("telegram thread bindings", () => {
"./thread-bindings.js?scope=shared-b",
);
bindingsA.__testing.resetTelegramThreadBindingsForTests();
await bindingsA.__testing.resetTelegramThreadBindingsForTests();
try {
const managerA = bindingsA.createTelegramThreadBindingManager({
@ -123,7 +124,7 @@ describe("telegram thread bindings", () => {
?.getByConversationId("-100200300:topic:44")?.targetSessionKey,
).toBe("agent:main:subagent:child-shared");
} finally {
bindingsA.__testing.resetTelegramThreadBindingsForTests();
await bindingsA.__testing.resetTelegramThreadBindingsForTests();
}
});
@ -237,7 +238,7 @@ describe("telegram thread bindings", () => {
reason: "test-detach",
});
__testing.resetTelegramThreadBindingsForTests();
await __testing.resetTelegramThreadBindingsForTests();
const reloaded = createTelegramThreadBindingManager({
accountId: "default",
@ -247,4 +248,45 @@ describe("telegram thread bindings", () => {
expect(reloaded.getByConversationId("8460800771")).toBeUndefined();
});
it("flushes pending lifecycle update persists before test reset", async () => {
stateDirOverride = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-bindings-"));
process.env.OPENCLAW_STATE_DIR = stateDirOverride;
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-06T10:00:00.000Z"));
createTelegramThreadBindingManager({
accountId: "persist-reset",
persist: true,
enableSweeper: false,
});
await getSessionBindingService().bind({
targetSessionKey: "agent:main:subagent:child-3",
targetKind: "subagent",
conversation: {
channel: "telegram",
accountId: "persist-reset",
conversationId: "-100200300:topic:99",
},
});
setTelegramThreadBindingIdleTimeoutBySessionKey({
accountId: "persist-reset",
targetSessionKey: "agent:main:subagent:child-3",
idleTimeoutMs: 90_000,
});
await __testing.resetTelegramThreadBindingsForTests();
const statePath = path.join(
resolveStateDir(process.env, os.homedir),
"telegram",
"thread-bindings-persist-reset.json",
);
const persisted = JSON.parse(fs.readFileSync(statePath, "utf8")) as {
bindings?: Array<{ idleTimeoutMs?: number }>;
};
expect(persisted.bindings?.[0]?.idleTimeoutMs).toBe(90_000);
});
});

View File

@ -67,6 +67,7 @@ export type TelegramThreadBindingManager = {
type TelegramThreadBindingsState = {
managersByAccountId: Map<string, TelegramThreadBindingManager>;
bindingsByAccountConversation: Map<string, TelegramThreadBindingRecord>;
persistQueueByAccountId: Map<string, Promise<void>>;
};
/**
@ -80,10 +81,12 @@ const threadBindingsState = resolveGlobalSingleton<TelegramThreadBindingsState>(
() => ({
managersByAccountId: new Map<string, TelegramThreadBindingManager>(),
bindingsByAccountConversation: new Map<string, TelegramThreadBindingRecord>(),
persistQueueByAccountId: new Map<string, Promise<void>>(),
}),
);
const MANAGERS_BY_ACCOUNT_ID = threadBindingsState.managersByAccountId;
const BINDINGS_BY_ACCOUNT_CONVERSATION = threadBindingsState.bindingsByAccountConversation;
const PERSIST_QUEUE_BY_ACCOUNT_ID = threadBindingsState.persistQueueByAccountId;
function normalizeDurationMs(raw: unknown, fallback: number): number {
if (typeof raw !== "number" || !Number.isFinite(raw)) {
@ -323,16 +326,18 @@ function loadBindingsFromDisk(accountId: string): TelegramThreadBindingRecord[]
async function persistBindingsToDisk(params: {
accountId: string;
persist: boolean;
bindings?: TelegramThreadBindingRecord[];
}): Promise<void> {
if (!params.persist) {
return;
}
const bindings = [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter(
(entry) => entry.accountId === params.accountId,
);
const payload: StoredTelegramBindingState = {
version: STORE_VERSION,
bindings,
bindings:
params.bindings ??
[...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter(
(entry) => entry.accountId === params.accountId,
),
};
await writeJsonAtomic(resolveBindingsPath(params.accountId), payload, {
mode: 0o600,
@ -341,6 +346,48 @@ async function persistBindingsToDisk(params: {
});
}
function listBindingsForAccount(accountId: string): TelegramThreadBindingRecord[] {
return [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter(
(entry) => entry.accountId === accountId,
);
}
function enqueuePersistBindings(params: {
accountId: string;
persist: boolean;
bindings?: TelegramThreadBindingRecord[];
}): Promise<void> {
if (!params.persist) {
return Promise.resolve();
}
const previous = PERSIST_QUEUE_BY_ACCOUNT_ID.get(params.accountId) ?? Promise.resolve();
const next = previous
.catch(() => undefined)
.then(async () => {
await persistBindingsToDisk(params);
});
PERSIST_QUEUE_BY_ACCOUNT_ID.set(params.accountId, next);
void next.finally(() => {
if (PERSIST_QUEUE_BY_ACCOUNT_ID.get(params.accountId) === next) {
PERSIST_QUEUE_BY_ACCOUNT_ID.delete(params.accountId);
}
});
return next;
}
function persistBindingsSafely(params: {
accountId: string;
persist: boolean;
bindings?: TelegramThreadBindingRecord[];
reason: string;
}): void {
void enqueuePersistBindings(params).catch((err) => {
logVerbose(
`telegram thread bindings persist failed (${params.accountId}, ${params.reason}): ${String(err)}`,
);
});
}
function normalizeTimestampMs(raw: unknown): number {
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return Date.now();
@ -414,9 +461,6 @@ export function createTelegramThreadBindingManager(
});
}
const listBindingsForAccount = () =>
[...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter((entry) => entry.accountId === accountId);
let sweepTimer: NodeJS.Timeout | null = null;
const manager: TelegramThreadBindingManager = {
@ -441,11 +485,11 @@ export function createTelegramThreadBindingManager(
if (!targetSessionKey) {
return [];
}
return listBindingsForAccount().filter(
return listBindingsForAccount(accountId).filter(
(entry) => entry.targetSessionKey === targetSessionKey,
);
},
listBindings: () => listBindingsForAccount(),
listBindings: () => listBindingsForAccount(accountId),
touchConversation: (conversationIdRaw, at) => {
const conversationId = normalizeConversationId(conversationIdRaw);
if (!conversationId) {
@ -461,7 +505,12 @@ export function createTelegramThreadBindingManager(
lastActivityAt: normalizeTimestampMs(at ?? Date.now()),
};
BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, nextRecord);
void persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() });
persistBindingsSafely({
accountId,
persist: manager.shouldPersistMutations(),
bindings: listBindingsForAccount(accountId),
reason: "touch",
});
return nextRecord;
},
unbindConversation: (unbindParams) => {
@ -475,7 +524,12 @@ export function createTelegramThreadBindingManager(
return null;
}
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key);
void persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() });
persistBindingsSafely({
accountId,
persist: manager.shouldPersistMutations(),
bindings: listBindingsForAccount(accountId),
reason: "unbind-conversation",
});
return removed;
},
unbindBySessionKey: (unbindParams) => {
@ -484,7 +538,7 @@ export function createTelegramThreadBindingManager(
return [];
}
const removed: TelegramThreadBindingRecord[] = [];
for (const entry of listBindingsForAccount()) {
for (const entry of listBindingsForAccount(accountId)) {
if (entry.targetSessionKey !== targetSessionKey) {
continue;
}
@ -496,7 +550,12 @@ export function createTelegramThreadBindingManager(
removed.push(entry);
}
if (removed.length > 0) {
void persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() });
persistBindingsSafely({
accountId,
persist: manager.shouldPersistMutations(),
bindings: listBindingsForAccount(accountId),
reason: "unbind-session",
});
}
return removed;
},
@ -544,7 +603,11 @@ export function createTelegramThreadBindingManager(
resolveBindingKey({ accountId, conversationId }),
record,
);
await persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() });
await enqueuePersistBindings({
accountId,
persist: manager.shouldPersistMutations(),
bindings: listBindingsForAccount(accountId),
});
logVerbose(
`telegram: bound conversation ${conversationId} -> ${targetSessionKey} (${summarizeLifecycleForLog(
record,
@ -605,7 +668,11 @@ export function createTelegramThreadBindingManager(
sendFarewell: false,
});
if (removed.length > 0) {
await persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() });
await enqueuePersistBindings({
accountId,
persist: manager.shouldPersistMutations(),
bindings: listBindingsForAccount(accountId),
});
}
return removed.map((entry) =>
toSessionBindingRecord(entry, {
@ -627,7 +694,11 @@ export function createTelegramThreadBindingManager(
sendFarewell: false,
});
if (removed) {
await persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() });
await enqueuePersistBindings({
accountId,
persist: manager.shouldPersistMutations(),
bindings: listBindingsForAccount(accountId),
});
}
return removed
? [
@ -644,7 +715,7 @@ export function createTelegramThreadBindingManager(
if (sweeperEnabled) {
sweepTimer = setInterval(() => {
const now = Date.now();
for (const record of listBindingsForAccount()) {
for (const record of listBindingsForAccount(accountId)) {
const idleExpired = shouldExpireByIdle({
now,
record,
@ -699,9 +770,11 @@ function updateTelegramBindingsBySessionKey(params: {
updated.push(next);
}
if (updated.length > 0) {
void persistBindingsToDisk({
persistBindingsSafely({
accountId: params.manager.accountId,
persist: params.manager.shouldPersistMutations(),
bindings: listBindingsForAccount(params.manager.accountId),
reason: "session-lifecycle-update",
});
}
return updated;
@ -750,10 +823,12 @@ export function setTelegramThreadBindingMaxAgeBySessionKey(params: {
}
export const __testing = {
resetTelegramThreadBindingsForTests() {
async resetTelegramThreadBindingsForTests() {
for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) {
manager.stop();
}
await Promise.allSettled(PERSIST_QUEUE_BY_ACCOUNT_ID.values());
PERSIST_QUEUE_BY_ACCOUNT_ID.clear();
MANAGERS_BY_ACCOUNT_ID.clear();
BINDINGS_BY_ACCOUNT_CONVERSATION.clear();
},

View File

@ -1,8 +1,11 @@
import { Type } from "@sinclair/typebox";
import {
buildSearchCacheKey,
buildUnsupportedSearchFilterResponse,
DEFAULT_SEARCH_COUNT,
getScopedCredentialValue,
MAX_SEARCH_COUNT,
mergeScopedSearchConfig,
readCachedSearchPayload,
readConfiguredSecretString,
readNumberParam,
@ -12,6 +15,7 @@ import {
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
setScopedCredentialValue,
setProviderWebSearchPluginConfigValue,
type SearchConfigRecord,
type WebSearchProviderPlugin,
@ -188,22 +192,9 @@ function createGrokToolDefinition(
parameters: createGrokSchema(),
execute: async (args) => {
const params = args as Record<string, unknown>;
for (const name of ["country", "language", "freshness", "date_after", "date_before"]) {
if (readStringParam(params, name)) {
const label =
name === "country"
? "country filtering"
: name === "language"
? "language filtering"
: name === "freshness"
? "freshness filtering"
: "date_after/date_before filtering";
return {
error: name.startsWith("date_") ? "unsupported_date_filter" : `unsupported_${name}`,
message: `${label} is not supported by the grok provider. Only Brave and Perplexity support ${name === "country" ? "country filtering" : name === "language" ? "language filtering" : name === "freshness" ? "freshness" : "date filtering"}.`,
docs: "https://docs.openclaw.ai/tools/web",
};
}
const unsupportedResponse = buildUnsupportedSearchFilterResponse(params, "grok");
if (unsupportedResponse) {
return unsupportedResponse;
}
const grokConfig = resolveGrokConfig(searchConfig);
@ -277,20 +268,9 @@ export function createGrokWebSearchProvider(): WebSearchProviderPlugin {
autoDetectOrder: 30,
credentialPath: "plugins.entries.xai.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.xai.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => {
const grok = searchConfig?.grok;
return grok && typeof grok === "object" && !Array.isArray(grok)
? (grok as Record<string, unknown>).apiKey
: undefined;
},
setCredentialValue: (searchConfigTarget, value) => {
const scoped = searchConfigTarget.grok;
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
searchConfigTarget.grok = { apiKey: value };
return;
}
(scoped as Record<string, unknown>).apiKey = value;
},
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "grok"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "grok", value),
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "xai")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
@ -298,20 +278,11 @@ export function createGrokWebSearchProvider(): WebSearchProviderPlugin {
},
createTool: (ctx) =>
createGrokToolDefinition(
(() => {
const searchConfig = ctx.searchConfig as SearchConfigRecord | undefined;
const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "xai");
if (!pluginConfig) {
return searchConfig;
}
return {
...(searchConfig ?? {}),
grok: {
...resolveGrokConfig(searchConfig),
...pluginConfig,
},
} as SearchConfigRecord;
})(),
mergeScopedSearchConfig(
ctx.searchConfig as SearchConfigRecord | undefined,
"grok",
resolveProviderWebSearchPluginConfig(ctx.config, "xai"),
) as SearchConfigRecord | undefined,
),
};
}

View File

@ -1,10 +1,14 @@
import { vi } from "vitest";
import { createDefaultResolvedZalouserAccount } from "./test-helpers.js";
vi.mock("./accounts.js", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
vi.mock("./accounts.js", () => {
return {
...actual,
listZalouserAccountIds: () => ["default"],
resolveDefaultZalouserAccountId: () => "default",
resolveZalouserAccountSync: () => createDefaultResolvedZalouserAccount(),
resolveZalouserAccount: async () => createDefaultResolvedZalouserAccount(),
listEnabledZalouserAccounts: async () => [createDefaultResolvedZalouserAccount()],
getZcaUserInfo: async () => null,
checkZcaAuthenticated: async () => false,
};
});

View File

@ -1,18 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import "./accounts.test-mocks.js";
import { createZalouserRuntimeEnv } from "./test-helpers.js";
const listZaloGroupMembersMock = vi.hoisted(() => vi.fn(async () => []));
vi.mock("./zalo-js.js", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
listZaloGroupMembers: listZaloGroupMembersMock,
};
});
import "./zalo-js.test-mocks.js";
import { zalouserPlugin } from "./channel.js";
import { createZalouserRuntimeEnv } from "./test-helpers.js";
import { listZaloGroupMembersMock } from "./zalo-js.test-mocks.js";
const runtimeStub = createZalouserRuntimeEnv();

View File

@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { primeChannelOutboundSendMock } from "../../../src/channels/plugins/contracts/suites.js";
import "./accounts.test-mocks.js";
import "./zalo-js.test-mocks.js";
import type { ReplyPayload } from "../runtime-api.js";
import { zalouserPlugin } from "./channel.js";
import { setZalouserRuntime } from "./runtime.js";

View File

@ -4,6 +4,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { withEnvAsync } from "../../../test/helpers/extensions/env.js";
import "./zalo-js.test-mocks.js";
import { zalouserSetupPlugin } from "./channel.setup.js";
const zalouserSetupAdapter = buildChannelSetupWizardAdapterFromSetupWizard({

View File

@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./zalo-js.test-mocks.js";
import { zalouserPlugin } from "./channel.js";
import { setZalouserRuntime } from "./runtime.js";
import { sendMessageZalouser, sendReactionZalouser } from "./send.js";

View File

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
import "./monitor.send-mocks.js";
import "./zalo-js.test-mocks.js";
import { __testing } from "./monitor.js";
import { sendMessageZalouserMock } from "./monitor.send-mocks.js";
import { setZalouserRuntime } from "./runtime.js";

View File

@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
import "./monitor.send-mocks.js";
import "./zalo-js.test-mocks.js";
import { resolveZalouserAccountSync } from "./accounts.js";
import { __testing } from "./monitor.js";
import {

View File

@ -1,4 +1,4 @@
import { Reactions } from "./zca-client.js";
import { Reactions } from "./zca-constants.js";
const REACTION_ALIAS_MAP = new Map<string, string>([
["like", Reactions.LIKE],

View File

@ -17,7 +17,7 @@ import {
sendZaloTextMessage,
sendZaloTypingEvent,
} from "./zalo-js.js";
import { TextStyle } from "./zca-client.js";
import { TextStyle } from "./zca-constants.js";
vi.mock("./zalo-js.js", () => ({
sendZaloTextMessage: vi.fn(),

View File

@ -8,7 +8,7 @@ import {
sendZaloTextMessage,
sendZaloTypingEvent,
} from "./zalo-js.js";
import { TextStyle } from "./zca-client.js";
import { TextStyle } from "./zca-constants.js";
export type ZalouserSendOptions = ZaloSendOptions;
export type ZalouserSendResult = ZaloSendResult;

View File

@ -3,30 +3,7 @@ import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/chan
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
import { createTestWizardPrompter } from "../../../test/helpers/extensions/setup-wizard.js";
import type { OpenClawConfig } from "../runtime-api.js";
vi.mock("./zalo-js.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./zalo-js.js")>();
return {
...actual,
checkZaloAuthenticated: vi.fn(async () => false),
logoutZaloProfile: vi.fn(async () => {}),
startZaloQrLogin: vi.fn(async () => ({
message: "qr pending",
qrDataUrl: undefined,
})),
waitForZaloQrLogin: vi.fn(async () => ({
connected: false,
message: "login pending",
})),
resolveZaloAllowFromEntries: vi.fn(async ({ entries }: { entries: string[] }) =>
entries.map((entry) => ({ input: entry, resolved: true, id: entry, note: undefined })),
),
resolveZaloGroupsByEntries: vi.fn(async ({ entries }: { entries: string[] }) =>
entries.map((entry) => ({ input: entry, resolved: true, id: entry, note: undefined })),
),
};
});
import "./zalo-js.test-mocks.js";
import { zalouserPlugin } from "./channel.js";
const zalouserConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { parseZalouserTextStyles } from "./text-styles.js";
import { TextStyle } from "./zca-client.js";
import { TextStyle } from "./zca-constants.js";
describe("parseZalouserTextStyles", () => {
it("renders inline markdown emphasis as Zalo style ranges", () => {

View File

@ -1,4 +1,4 @@
import { TextStyle, type Style } from "./zca-client.js";
import { TextStyle, type Style } from "./zca-constants.js";
type InlineStyle = (typeof TextStyle)[keyof typeof TextStyle];

View File

@ -1,4 +1,4 @@
import type { Style } from "./zca-client.js";
import type { Style } from "./zca-constants.js";
export type ZcaFriend = {
userId: string;

View File

@ -0,0 +1,60 @@
import { vi } from "vitest";
const zaloJsMocks = vi.hoisted(() => ({
checkZaloAuthenticatedMock: vi.fn(async () => false),
getZaloUserInfoMock: vi.fn(async () => null),
listZaloFriendsMock: vi.fn(async () => []),
listZaloFriendsMatchingMock: vi.fn(async () => []),
listZaloGroupMembersMock: vi.fn(async () => []),
listZaloGroupsMock: vi.fn(async () => []),
listZaloGroupsMatchingMock: vi.fn(async () => []),
logoutZaloProfileMock: vi.fn(async () => {}),
resolveZaloAllowFromEntriesMock: vi.fn(async ({ entries }: { entries: string[] }) =>
entries.map((entry) => ({ input: entry, resolved: true, id: entry, note: undefined })),
),
resolveZaloGroupContextMock: vi.fn(async () => null),
resolveZaloGroupsByEntriesMock: vi.fn(async ({ entries }: { entries: string[] }) =>
entries.map((entry) => ({ input: entry, resolved: true, id: entry, note: undefined })),
),
startZaloListenerMock: vi.fn(async () => ({ stop: vi.fn() })),
startZaloQrLoginMock: vi.fn(async () => ({
message: "qr pending",
qrDataUrl: undefined,
})),
waitForZaloQrLoginMock: vi.fn(async () => ({
connected: false,
message: "login pending",
})),
}));
export const checkZaloAuthenticatedMock = zaloJsMocks.checkZaloAuthenticatedMock;
export const getZaloUserInfoMock = zaloJsMocks.getZaloUserInfoMock;
export const listZaloFriendsMock = zaloJsMocks.listZaloFriendsMock;
export const listZaloFriendsMatchingMock = zaloJsMocks.listZaloFriendsMatchingMock;
export const listZaloGroupMembersMock = zaloJsMocks.listZaloGroupMembersMock;
export const listZaloGroupsMock = zaloJsMocks.listZaloGroupsMock;
export const listZaloGroupsMatchingMock = zaloJsMocks.listZaloGroupsMatchingMock;
export const logoutZaloProfileMock = zaloJsMocks.logoutZaloProfileMock;
export const resolveZaloAllowFromEntriesMock = zaloJsMocks.resolveZaloAllowFromEntriesMock;
export const resolveZaloGroupContextMock = zaloJsMocks.resolveZaloGroupContextMock;
export const resolveZaloGroupsByEntriesMock = zaloJsMocks.resolveZaloGroupsByEntriesMock;
export const startZaloListenerMock = zaloJsMocks.startZaloListenerMock;
export const startZaloQrLoginMock = zaloJsMocks.startZaloQrLoginMock;
export const waitForZaloQrLoginMock = zaloJsMocks.waitForZaloQrLoginMock;
vi.mock("./zalo-js.js", () => ({
checkZaloAuthenticated: checkZaloAuthenticatedMock,
getZaloUserInfo: getZaloUserInfoMock,
listZaloFriends: listZaloFriendsMock,
listZaloFriendsMatching: listZaloFriendsMatchingMock,
listZaloGroupMembers: listZaloGroupMembersMock,
listZaloGroups: listZaloGroupsMock,
listZaloGroupsMatching: listZaloGroupsMatchingMock,
logoutZaloProfile: logoutZaloProfileMock,
resolveZaloAllowFromEntries: resolveZaloAllowFromEntriesMock,
resolveZaloGroupContext: resolveZaloGroupContextMock,
resolveZaloGroupsByEntries: resolveZaloGroupsByEntriesMock,
startZaloListener: startZaloListenerMock,
startZaloQrLogin: startZaloQrLoginMock,
waitForZaloQrLogin: waitForZaloQrLoginMock,
}));

View File

@ -19,17 +19,16 @@ import type {
ZcaUserInfo,
} from "./types.js";
import {
LoginQRCallbackEventType,
TextStyle,
ThreadType,
Zalo,
type API,
type Credentials,
type GroupInfo,
type LoginQRCallbackEvent,
type Message,
type User,
Zalo,
} from "./zca-client.js";
import { LoginQRCallbackEventType, ThreadType } from "./zca-constants.js";
const API_LOGIN_TIMEOUT_MS = 20_000;
const QR_LOGIN_TTL_MS = 3 * 60_000;

View File

@ -1,67 +1,17 @@
import * as zcaJsRuntime from "zca-js";
import {
LoginQRCallbackEventType,
Reactions,
TextStyle,
ThreadType,
type Style,
} from "./zca-constants.js";
const zcaJs = zcaJsRuntime as unknown as {
ThreadType: unknown;
LoginQRCallbackEventType: unknown;
Reactions: unknown;
Zalo: unknown;
};
export const ThreadType = zcaJs.ThreadType as {
User: 0;
Group: 1;
};
export const LoginQRCallbackEventType = zcaJs.LoginQRCallbackEventType as {
QRCodeGenerated: 0;
QRCodeExpired: 1;
QRCodeScanned: 2;
QRCodeDeclined: 3;
GotLoginInfo: 4;
};
export const Reactions = zcaJs.Reactions as Record<string, string> & {
HEART: string;
LIKE: string;
HAHA: string;
WOW: string;
CRY: string;
ANGRY: string;
NONE: string;
};
// Mirror zca-js sendMessage style constants locally because the package root
// typing surface does not consistently expose TextStyle/Style to tsgo.
export const TextStyle = {
Bold: "b",
Italic: "i",
Underline: "u",
StrikeThrough: "s",
Red: "c_db342e",
Orange: "c_f27806",
Yellow: "c_f7b503",
Green: "c_15a85f",
Small: "f_13",
Big: "f_18",
UnorderedList: "lst_1",
OrderedList: "lst_2",
Indent: "ind_$",
} as const;
type TextStyleValue = (typeof TextStyle)[keyof typeof TextStyle];
export type Style =
| {
start: number;
len: number;
st: Exclude<TextStyleValue, typeof TextStyle.Indent>;
}
| {
start: number;
len: number;
st: typeof TextStyle.Indent;
indentSize?: number;
};
export { LoginQRCallbackEventType, Reactions, TextStyle, ThreadType };
export type { Style };
export type Credentials = {
imei: string;

View File

@ -0,0 +1,55 @@
export const ThreadType = {
User: 0,
Group: 1,
} as const;
export const LoginQRCallbackEventType = {
QRCodeGenerated: 0,
QRCodeExpired: 1,
QRCodeScanned: 2,
QRCodeDeclined: 3,
GotLoginInfo: 4,
} as const;
export const Reactions = {
HEART: "/-heart",
LIKE: "/-strong",
HAHA: ":>",
WOW: ":o",
CRY: ":-((",
ANGRY: ":-h",
NONE: "",
} as const;
// Mirror zca-js sendMessage style constants locally because the package root
// typing surface does not consistently expose TextStyle/Style to tsgo.
export const TextStyle = {
Bold: "b",
Italic: "i",
Underline: "u",
StrikeThrough: "s",
Red: "c_db342e",
Orange: "c_f27806",
Yellow: "c_f7b503",
Green: "c_15a85f",
Small: "f_13",
Big: "f_18",
UnorderedList: "lst_1",
OrderedList: "lst_2",
Indent: "ind_$",
} as const;
type TextStyleValue = (typeof TextStyle)[keyof typeof TextStyle];
export type Style =
| {
start: number;
len: number;
st: Exclude<TextStyleValue, typeof TextStyle.Indent>;
}
| {
start: number;
len: number;
st: typeof TextStyle.Indent;
indentSize?: number;
};

View File

@ -528,15 +528,19 @@
"./cli-entry": "./openclaw.mjs"
},
"scripts": {
"android:assemble": "cd apps/android && ./gradlew :app:assembleDebug",
"android:assemble": "cd apps/android && ./gradlew :app:assemblePlayDebug",
"android:assemble:third-party": "cd apps/android && ./gradlew :app:assembleThirdPartyDebug",
"android:bundle:release": "bun apps/android/scripts/build-release-aab.ts",
"android:format": "cd apps/android && ./gradlew :app:ktlintFormat :benchmark:ktlintFormat",
"android:install": "cd apps/android && ./gradlew :app:installDebug",
"android:install": "cd apps/android && ./gradlew :app:installPlayDebug",
"android:install:third-party": "cd apps/android && ./gradlew :app:installThirdPartyDebug",
"android:lint": "cd apps/android && ./gradlew :app:ktlintCheck :benchmark:ktlintCheck",
"android:lint:android": "cd apps/android && ./gradlew :app:lintDebug",
"android:run": "cd apps/android && ./gradlew :app:installDebug && adb shell am start -n ai.openclaw.app/.MainActivity",
"android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest",
"android:run": "cd apps/android && ./gradlew :app:installPlayDebug && adb shell am start -n ai.openclaw.app/.MainActivity",
"android:run:third-party": "cd apps/android && ./gradlew :app:installThirdPartyDebug && adb shell am start -n ai.openclaw.app/.MainActivity",
"android:test": "cd apps/android && ./gradlew :app:testPlayDebugUnitTest",
"android:test:integration": "OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 vitest run --config vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts",
"android:test:third-party": "cd apps/android && ./gradlew :app:testThirdPartyDebugUnitTest",
"build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
"build:docker": "node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
@ -669,6 +673,7 @@
"test:parallels:windows": "bash scripts/e2e/parallels-windows-smoke.sh",
"test:perf:budget": "node scripts/test-perf-budget.mjs",
"test:perf:hotspots": "node scripts/test-hotspots.mjs",
"test:perf:update-memory-hotspots": "node scripts/test-update-memory-hotspots.mjs",
"test:perf:update-timings": "node scripts/test-update-timings.mjs",
"test:sectriage": "pnpm exec vitest run --config vitest.gateway.config.ts && vitest run --config vitest.unit.config.ts --exclude src/daemon/launchd.integration.test.ts --exclude src/process/exec.test.ts",
"test:startup:memory": "node scripts/check-cli-startup-memory.mjs",

View File

@ -4,7 +4,7 @@ import { appendFileSync } from "node:fs";
/** @typedef {{ runNode: boolean; runMacos: boolean; runAndroid: boolean; runWindows: boolean; runSkillsPython: boolean }} ChangedScope */
const DOCS_PATH_RE = /^(docs\/|.*\.mdx?$)/;
const SKILLS_PYTHON_SCOPE_RE = /^skills\//;
const SKILLS_PYTHON_SCOPE_RE = /^(skills\/|pyproject\.toml$)/;
const CI_WORKFLOW_SCOPE_RE = /^\.github\/workflows\/ci\.yml$/;
const MACOS_PROTOCOL_GEN_RE =
/^(apps\/macos\/Sources\/OpenClawProtocol\/|apps\/shared\/OpenClawKit\/Sources\/OpenClawProtocol\/)/;

View File

@ -7,9 +7,14 @@ const ANSI_ESCAPE_PATTERN = new RegExp(
`${ESCAPE}(?:\\][^${BELL}]*(?:${BELL}|${ESCAPE}\\\\)|\\[[0-?]*[ -/]*[@-~]|[@-Z\\\\-_])`,
"g",
);
const GITHUB_ACTIONS_LOG_PREFIX_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\s+/u;
const COMPLETED_TEST_FILE_LINE_PATTERN =
/(?<file>(?:src|extensions|test|ui)\/\S+?\.(?:live\.test|e2e\.test|test)\.ts)\s+\(.*\)\s+(?<duration>\d+(?:\.\d+)?)(?<unit>ms|s)\s*$/;
const MEMORY_TRACE_SUMMARY_PATTERN =
/^\[test-parallel\]\[mem\] summary (?<lane>\S+) files=(?<files>\d+) peak=(?<peak>[0-9]+(?:\.[0-9]+)?(?:GiB|MiB|KiB)) totalDelta=(?<totalDelta>[+-]?[0-9]+(?:\.[0-9]+)?(?:GiB|MiB|KiB)) peakAt=(?<peakAt>\S+) top=(?<top>.*)$/u;
const MEMORY_TRACE_TOP_ENTRY_PATTERN =
/^(?<file>(?:src|extensions|test|ui)\/\S+?\.(?:live\.test|e2e\.test|test)\.ts):(?<delta>[+-]?[0-9]+(?:\.[0-9]+)?(?:GiB|MiB|KiB))$/u;
const PS_COLUMNS = ["pid=", "ppid=", "rss=", "comm="];
@ -21,13 +26,33 @@ function parseDurationMs(rawValue, unit) {
return unit === "s" ? Math.round(parsed * 1000) : Math.round(parsed);
}
export function parseMemoryValueKb(rawValue) {
const match = rawValue.match(/^(?<sign>[+-]?)(?<value>\d+(?:\.\d+)?)(?<unit>GiB|MiB|KiB)$/u);
if (!match?.groups) {
return null;
}
const value = Number.parseFloat(match.groups.value);
if (!Number.isFinite(value)) {
return null;
}
const multiplier =
match.groups.unit === "GiB" ? 1024 ** 2 : match.groups.unit === "MiB" ? 1024 : 1;
const signed = Math.round(value * multiplier);
return match.groups.sign === "-" ? -signed : signed;
}
function stripAnsi(text) {
return text.replaceAll(ANSI_ESCAPE_PATTERN, "");
}
function normalizeLogLine(line) {
return line.replace(GITHUB_ACTIONS_LOG_PREFIX_PATTERN, "");
}
export function parseCompletedTestFileLines(text) {
return stripAnsi(text)
.split(/\r?\n/u)
.map((line) => normalizeLogLine(line))
.map((line) => {
const match = line.match(COMPLETED_TEST_FILE_LINE_PATTERN);
if (!match?.groups) {
@ -41,6 +66,53 @@ export function parseCompletedTestFileLines(text) {
.filter((entry) => entry !== null);
}
export function parseMemoryTraceSummaryLines(text) {
return stripAnsi(text)
.split(/\r?\n/u)
.map((line) => normalizeLogLine(line))
.map((line) => {
const match = line.match(MEMORY_TRACE_SUMMARY_PATTERN);
if (!match?.groups) {
return null;
}
const peakRssKb = parseMemoryValueKb(match.groups.peak);
const totalDeltaKb = parseMemoryValueKb(match.groups.totalDelta);
const fileCount = Number.parseInt(match.groups.files, 10);
if (!Number.isInteger(fileCount) || peakRssKb === null || totalDeltaKb === null) {
return null;
}
const top =
match.groups.top === "none"
? []
: match.groups.top
.split(/,\s+/u)
.map((entry) => {
const topMatch = entry.match(MEMORY_TRACE_TOP_ENTRY_PATTERN);
if (!topMatch?.groups) {
return null;
}
const deltaKb = parseMemoryValueKb(topMatch.groups.delta);
if (deltaKb === null) {
return null;
}
return {
file: topMatch.groups.file,
deltaKb,
};
})
.filter((entry) => entry !== null);
return {
lane: match.groups.lane,
files: fileCount,
peakRssKb,
totalDeltaKb,
peakAt: match.groups.peakAt,
top,
};
})
.filter((entry) => entry !== null);
}
export function getProcessTreeRecords(rootPid) {
if (!Number.isInteger(rootPid) || rootPid <= 0 || process.platform === "win32") {
return null;

View File

@ -15,10 +15,11 @@ import {
resolveTestRunExitCode,
} from "./test-parallel-utils.mjs";
import {
loadUnitMemoryHotspotManifest,
loadTestRunnerBehavior,
loadUnitTimingManifest,
selectUnitHeavyFileGroups,
packFilesByDuration,
selectTimedHeavyFiles,
} from "./test-runner-manifest.mjs";
// On Windows, `.cmd` launchers can fail with `spawn EINVAL` when invoked without a shell
@ -27,6 +28,25 @@ const pnpm = "pnpm";
const behaviorManifest = loadTestRunnerBehavior();
const existingFiles = (entries) =>
entries.map((entry) => entry.file).filter((file) => fs.existsSync(file));
let tempArtifactDir = null;
const ensureTempArtifactDir = () => {
if (tempArtifactDir === null) {
tempArtifactDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-test-parallel-"));
}
return tempArtifactDir;
};
const writeTempJsonArtifact = (name, value) => {
const filePath = path.join(ensureTempArtifactDir(), `${name}.json`);
fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, "utf8");
return filePath;
};
const cleanupTempArtifacts = () => {
if (tempArtifactDir === null) {
return;
}
fs.rmSync(tempArtifactDir, { recursive: true, force: true });
tempArtifactDir = null;
};
const existingUnitConfigFiles = (entries) => existingFiles(entries).filter(isUnitConfigTestFile);
const unitBehaviorIsolatedFiles = existingUnitConfigFiles(behaviorManifest.unit.isolated);
const unitSingletonIsolatedFiles = existingUnitConfigFiles(behaviorManifest.unit.singletonIsolated);
@ -262,6 +282,7 @@ const inferTarget = (fileFilter) => {
return { owner: "base", isolated };
};
const unitTimingManifest = loadUnitTimingManifest();
const unitMemoryHotspotManifest = loadUnitMemoryHotspotManifest();
const parseEnvNumber = (name, fallback) => {
const parsed = Number.parseInt(process.env[name] ?? "", 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
@ -298,21 +319,78 @@ const heavyUnitLaneCount = parseEnvNumber(
defaultHeavyUnitLaneCount,
);
const heavyUnitMinDurationMs = parseEnvNumber("OPENCLAW_TEST_HEAVY_UNIT_MIN_MS", 1200);
const timedHeavyUnitFiles =
shouldSplitUnitRuns && heavyUnitFileLimit > 0
? selectTimedHeavyFiles({
const defaultMemoryHeavyUnitFileLimit =
testProfile === "serial" ? 0 : isCI ? 64 : testProfile === "low" ? 8 : 16;
const memoryHeavyUnitFileLimit = parseEnvNumber(
"OPENCLAW_TEST_MEMORY_HEAVY_UNIT_FILE_LIMIT",
defaultMemoryHeavyUnitFileLimit,
);
const memoryHeavyUnitMinDeltaKb = parseEnvNumber(
"OPENCLAW_TEST_MEMORY_HEAVY_UNIT_MIN_KB",
unitMemoryHotspotManifest.defaultMinDeltaKb,
);
const { memoryHeavyFiles: memoryHeavyUnitFiles, timedHeavyFiles: timedHeavyUnitFiles } =
shouldSplitUnitRuns
? selectUnitHeavyFileGroups({
candidates: allKnownUnitFiles,
limit: heavyUnitFileLimit,
minDurationMs: heavyUnitMinDurationMs,
exclude: unitBehaviorOverrideSet,
behaviorOverrides: unitBehaviorOverrideSet,
timedLimit: heavyUnitFileLimit,
timedMinDurationMs: heavyUnitMinDurationMs,
memoryLimit: memoryHeavyUnitFileLimit,
memoryMinDeltaKb: memoryHeavyUnitMinDeltaKb,
timings: unitTimingManifest,
hotspots: unitMemoryHotspotManifest,
})
: [];
: {
memoryHeavyFiles: [],
timedHeavyFiles: [],
};
const unitSchedulingOverrideSet = new Set([...unitBehaviorOverrideSet, ...memoryHeavyUnitFiles]);
const unitFastExcludedFiles = [
...new Set([...unitBehaviorOverrideSet, ...timedHeavyUnitFiles, ...channelSingletonFiles]),
...new Set([...unitSchedulingOverrideSet, ...timedHeavyUnitFiles, ...channelSingletonFiles]),
];
const unitAutoSingletonFiles = [
...new Set([...unitSingletonIsolatedFiles, ...memoryHeavyUnitFiles]),
];
const estimateUnitDurationMs = (file) =>
unitTimingManifest.files[file]?.durationMs ?? unitTimingManifest.defaultDurationMs;
const unitFastExcludedFileSet = new Set(unitFastExcludedFiles);
const unitFastCandidateFiles = allKnownUnitFiles.filter(
(file) => !unitFastExcludedFileSet.has(file),
);
const defaultUnitFastLaneCount = isCI && !isWindows ? 3 : 1;
const unitFastLaneCount = Math.max(
1,
parseEnvNumber("OPENCLAW_TEST_UNIT_FAST_LANES", defaultUnitFastLaneCount),
);
// Heap snapshots on current main show long-lived unit-fast workers retaining
// transformed Vitest/Vite module graphs rather than app objects. Multiple
// bounded unit-fast lanes only help if we also recycle them serially instead
// of keeping several transform-heavy workers resident at the same time.
const unitFastBuckets =
unitFastLaneCount > 1
? packFilesByDuration(unitFastCandidateFiles, unitFastLaneCount, estimateUnitDurationMs)
: [unitFastCandidateFiles];
const unitFastEntries = unitFastBuckets
.filter((files) => files.length > 0)
.map((files, index) => ({
name: unitFastBuckets.length === 1 ? "unit-fast" : `unit-fast-${String(index + 1)}`,
serialPhase: "unit-fast",
env: {
OPENCLAW_VITEST_INCLUDE_FILE: writeTempJsonArtifact(
`vitest-unit-fast-include-${String(index + 1)}`,
files,
),
},
args: [
"vitest",
"run",
"--config",
"vitest.unit.config.ts",
`--pool=${useVmForks ? "vmForks" : "forks"}`,
...(disableIsolation ? ["--isolate=false"] : []),
],
}));
const heavyUnitBuckets = packFilesByDuration(
timedHeavyUnitFiles,
heavyUnitLaneCount,
@ -325,18 +403,7 @@ const unitHeavyEntries = heavyUnitBuckets.map((files, index) => ({
const baseRuns = [
...(shouldSplitUnitRuns
? [
{
name: "unit-fast",
args: [
"vitest",
"run",
"--config",
"vitest.unit.config.ts",
`--pool=${useVmForks ? "vmForks" : "forks"}`,
...(disableIsolation ? ["--isolate=false"] : []),
...unitFastExcludedFiles.flatMap((file) => ["--exclude", file]),
],
},
...unitFastEntries,
...(unitBehaviorIsolatedFiles.length > 0
? [
{
@ -353,7 +420,7 @@ const baseRuns = [
]
: []),
...unitHeavyEntries,
...unitSingletonIsolatedFiles.map((file) => ({
...unitAutoSingletonFiles.map((file) => ({
name: `${path.basename(file, ".test.ts")}-isolated`,
args: [
"vitest",
@ -616,6 +683,8 @@ const keepGatewaySerial =
!parallelGatewayEnabled;
const parallelRuns = keepGatewaySerial ? runs.filter((entry) => entry.name !== "gateway") : runs;
const serialRuns = keepGatewaySerial ? runs.filter((entry) => entry.name === "gateway") : [];
const serialPrefixRuns = parallelRuns.filter((entry) => entry.serialPhase);
const deferredParallelRuns = parallelRuns.filter((entry) => !entry.serialPhase);
const baseLocalWorkers = Math.max(4, Math.min(16, hostCpuCount));
const loadAwareDisabledRaw = process.env.OPENCLAW_TEST_LOAD_AWARE?.trim().toLowerCase();
const loadAwareDisabled = loadAwareDisabledRaw === "0" || loadAwareDisabledRaw === "false";
@ -960,7 +1029,12 @@ const runOnce = (entry, extraArgs = []) =>
try {
child = spawn(pnpm, args, {
stdio: ["inherit", "pipe", "pipe"],
env: { ...process.env, VITEST_GROUP: entry.name, NODE_OPTIONS: resolvedNodeOptions },
env: {
...process.env,
...entry.env,
VITEST_GROUP: entry.name,
NODE_OPTIONS: resolvedNodeOptions,
},
shell: isWindows,
});
captureTreeSample("spawn");
@ -1112,6 +1186,7 @@ const shutdown = (signal) => {
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("exit", cleanupTempArtifacts);
if (process.env.OPENCLAW_TEST_LIST_LANES === "1") {
const entriesToPrint = targetedEntries.length > 0 ? targetedEntries : runs;
@ -1166,15 +1241,29 @@ if (passthroughRequiresSingleRun && passthroughOptionArgs.length > 0) {
process.exit(2);
}
if (isMacMiniProfile && targetedEntries.length === 0) {
const unitFastEntry = parallelRuns.find((entry) => entry.name === "unit-fast");
if (unitFastEntry) {
const unitFastCode = await run(unitFastEntry, passthroughOptionArgs);
if (serialPrefixRuns.length > 0) {
const failedSerialPrefix = await runEntriesWithLimit(serialPrefixRuns, passthroughOptionArgs, 1);
if (failedSerialPrefix !== undefined) {
process.exit(failedSerialPrefix);
}
const failedDeferredParallel = isMacMiniProfile
? await runEntriesWithLimit(deferredParallelRuns, passthroughOptionArgs, 3)
: await runEntries(deferredParallelRuns, passthroughOptionArgs);
if (failedDeferredParallel !== undefined) {
process.exit(failedDeferredParallel);
}
} else if (isMacMiniProfile && targetedEntries.length === 0) {
const unitFastEntriesForMacMini = parallelRuns.filter((entry) =>
entry.name.startsWith("unit-fast"),
);
for (const entry of unitFastEntriesForMacMini) {
// eslint-disable-next-line no-await-in-loop
const unitFastCode = await run(entry, passthroughOptionArgs);
if (unitFastCode !== 0) {
process.exit(unitFastCode);
}
}
const deferredEntries = parallelRuns.filter((entry) => entry.name !== "unit-fast");
const deferredEntries = parallelRuns.filter((entry) => !entry.name.startsWith("unit-fast"));
const failedMacMiniParallel = await runEntriesWithLimit(
deferredEntries,
passthroughOptionArgs,

View File

@ -3,12 +3,18 @@ import path from "node:path";
export const behaviorManifestPath = "test/fixtures/test-parallel.behavior.json";
export const unitTimingManifestPath = "test/fixtures/test-timings.unit.json";
export const unitMemoryHotspotManifestPath = "test/fixtures/test-memory-hotspots.unit.json";
const defaultTimingManifest = {
config: "vitest.unit.config.ts",
defaultDurationMs: 250,
files: {},
};
const defaultMemoryHotspotManifest = {
config: "vitest.unit.config.ts",
defaultMinDeltaKb: 256 * 1024,
files: {},
};
const readJson = (filePath, fallback) => {
try {
@ -82,6 +88,46 @@ export function loadUnitTimingManifest() {
};
}
export function loadUnitMemoryHotspotManifest() {
const raw = readJson(unitMemoryHotspotManifestPath, defaultMemoryHotspotManifest);
const defaultMinDeltaKb =
Number.isFinite(raw.defaultMinDeltaKb) && raw.defaultMinDeltaKb > 0
? raw.defaultMinDeltaKb
: defaultMemoryHotspotManifest.defaultMinDeltaKb;
const files = Object.fromEntries(
Object.entries(raw.files ?? {})
.map(([file, value]) => {
const normalizedFile = normalizeRepoPath(file);
const deltaKb =
Number.isFinite(value?.deltaKb) && value.deltaKb > 0 ? Math.round(value.deltaKb) : null;
const sources = Array.isArray(value?.sources)
? value.sources.filter((source) => typeof source === "string" && source.length > 0)
: [];
if (deltaKb === null) {
return [normalizedFile, null];
}
return [
normalizedFile,
{
deltaKb,
...(sources.length > 0 ? { sources } : {}),
},
];
})
.filter(([, value]) => value !== null),
);
return {
config:
typeof raw.config === "string" && raw.config
? raw.config
: defaultMemoryHotspotManifest.config,
generatedAt: typeof raw.generatedAt === "string" ? raw.generatedAt : "",
defaultMinDeltaKb,
files,
};
}
export function selectTimedHeavyFiles({
candidates,
limit,
@ -102,6 +148,64 @@ export function selectTimedHeavyFiles({
.map((entry) => entry.file);
}
export function selectMemoryHeavyFiles({
candidates,
limit,
minDeltaKb,
exclude = new Set(),
hotspots,
}) {
return candidates
.filter((file) => !exclude.has(file))
.map((file) => ({
file,
deltaKb: hotspots.files[file]?.deltaKb ?? 0,
known: Boolean(hotspots.files[file]),
}))
.filter((entry) => entry.known && entry.deltaKb >= minDeltaKb)
.toSorted((a, b) => b.deltaKb - a.deltaKb)
.slice(0, limit)
.map((entry) => entry.file);
}
export function selectUnitHeavyFileGroups({
candidates,
behaviorOverrides = new Set(),
timedLimit,
timedMinDurationMs,
memoryLimit,
memoryMinDeltaKb,
timings,
hotspots,
}) {
const memoryHeavyFiles =
memoryLimit > 0
? selectMemoryHeavyFiles({
candidates,
limit: memoryLimit,
minDeltaKb: memoryMinDeltaKb,
exclude: behaviorOverrides,
hotspots,
})
: [];
const schedulingOverrides = new Set([...behaviorOverrides, ...memoryHeavyFiles]);
const timedHeavyFiles =
timedLimit > 0
? selectTimedHeavyFiles({
candidates,
limit: timedLimit,
minDurationMs: timedMinDurationMs,
exclude: schedulingOverrides,
timings,
})
: [];
return {
memoryHeavyFiles,
timedHeavyFiles,
};
}
export function packFilesByDuration(files, bucketCount, estimateDurationMs) {
const normalizedBucketCount = Math.max(0, Math.floor(bucketCount));
if (normalizedBucketCount <= 0 || files.length === 0) {

View File

@ -0,0 +1,152 @@
import fs from "node:fs";
import path from "node:path";
import { parseMemoryTraceSummaryLines } from "./test-parallel-memory.mjs";
import { unitMemoryHotspotManifestPath } from "./test-runner-manifest.mjs";
function parseArgs(argv) {
const args = {
config: "vitest.unit.config.ts",
out: unitMemoryHotspotManifestPath,
lane: "unit-fast",
logs: [],
minDeltaKb: 256 * 1024,
limit: 64,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--config") {
args.config = argv[i + 1] ?? args.config;
i += 1;
continue;
}
if (arg === "--out") {
args.out = argv[i + 1] ?? args.out;
i += 1;
continue;
}
if (arg === "--lane") {
args.lane = argv[i + 1] ?? args.lane;
i += 1;
continue;
}
if (arg === "--log") {
const logPath = argv[i + 1];
if (typeof logPath === "string" && logPath.length > 0) {
args.logs.push(logPath);
}
i += 1;
continue;
}
if (arg === "--min-delta-kb") {
const parsed = Number.parseInt(argv[i + 1] ?? "", 10);
if (Number.isFinite(parsed) && parsed > 0) {
args.minDeltaKb = parsed;
}
i += 1;
continue;
}
if (arg === "--limit") {
const parsed = Number.parseInt(argv[i + 1] ?? "", 10);
if (Number.isFinite(parsed) && parsed > 0) {
args.limit = parsed;
}
i += 1;
continue;
}
}
return args;
}
function mergeHotspotEntry(aggregated, file, value) {
if (!(Number.isFinite(value?.deltaKb) && value.deltaKb > 0)) {
return;
}
const normalizeSourceLabel = (source) => {
const separator = source.lastIndexOf(":");
if (separator === -1) {
return source.endsWith(".log") ? source.slice(0, -4) : source;
}
const name = source.slice(0, separator);
const lane = source.slice(separator + 1);
return `${name.endsWith(".log") ? name.slice(0, -4) : name}:${lane}`;
};
const nextSources = Array.isArray(value?.sources)
? value.sources
.filter((source) => typeof source === "string" && source.length > 0)
.map(normalizeSourceLabel)
: [];
const previous = aggregated.get(file);
if (!previous) {
aggregated.set(file, {
deltaKb: Math.round(value.deltaKb),
sources: [...new Set(nextSources)],
});
return;
}
previous.deltaKb = Math.max(previous.deltaKb, Math.round(value.deltaKb));
for (const source of nextSources) {
if (!previous.sources.includes(source)) {
previous.sources.push(source);
}
}
}
const opts = parseArgs(process.argv.slice(2));
if (opts.logs.length === 0) {
console.error("[test-update-memory-hotspots] pass at least one --log <path>.");
process.exit(2);
}
const aggregated = new Map();
try {
const existing = JSON.parse(fs.readFileSync(opts.out, "utf8"));
for (const [file, value] of Object.entries(existing.files ?? {})) {
mergeHotspotEntry(aggregated, file, value);
}
} catch {
// Start from scratch when the output file does not exist yet.
}
for (const logPath of opts.logs) {
const text = fs.readFileSync(logPath, "utf8");
const summaries = parseMemoryTraceSummaryLines(text).filter(
(summary) => summary.lane === opts.lane,
);
for (const summary of summaries) {
for (const record of summary.top) {
if (record.deltaKb < opts.minDeltaKb) {
continue;
}
mergeHotspotEntry(aggregated, record.file, {
deltaKb: record.deltaKb,
sources: [`${path.basename(logPath, path.extname(logPath))}:${summary.lane}`],
});
}
}
}
const files = Object.fromEntries(
[...aggregated.entries()]
.toSorted((left, right) => right[1].deltaKb - left[1].deltaKb)
.slice(0, opts.limit)
.map(([file, value]) => [
file,
{
deltaKb: value.deltaKb,
sources: value.sources.toSorted(),
},
]),
);
const output = {
config: opts.config,
generatedAt: new Date().toISOString(),
defaultMinDeltaKb: opts.minDeltaKb,
lane: opts.lane,
files,
};
fs.writeFileSync(opts.out, `${JSON.stringify(output, null, 2)}\n`);
console.log(
`[test-update-memory-hotspots] wrote ${String(Object.keys(files).length)} hotspots to ${opts.out}`,
);

View File

@ -1,6 +1,17 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import type { OpenClawConfig } from "../../config/config.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import {
extractLeadingHttpStatus,
formatRawAssistantErrorForUi,
isCloudflareOrHtmlErrorPage,
} from "../../shared/assistant-error-format.js";
export {
extractLeadingHttpStatus,
formatRawAssistantErrorForUi,
isCloudflareOrHtmlErrorPage,
parseApiErrorInfo,
} from "../../shared/assistant-error-format.js";
import { formatSandboxToolPolicyBlockedMessage } from "../sandbox.js";
import { stableStringify } from "../stable-stringify.js";
import {
@ -220,10 +231,6 @@ const ERROR_PREFIX_RE =
/^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|request failed|failed|exception)(?:\s+\d{3})?[:\s-]+/i;
const CONTEXT_OVERFLOW_ERROR_HEAD_RE =
/^(?:context overflow:|request_too_large\b|request size exceeds\b|request exceeds the maximum size\b|context length exceeded\b|maximum context length\b|prompt is too long\b|exceeds model context window\b)/i;
const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i;
const HTTP_STATUS_CODE_PREFIX_RE = /^(?:http\s*)?(\d{3})(?:\s+([\s\S]+))?$/i;
const HTML_ERROR_PREFIX_RE = /^\s*(?:<!doctype\s+html\b|<html\b)/i;
const CLOUDFLARE_HTML_ERROR_CODES = new Set([521, 522, 523, 524, 525, 526, 530]);
const TRANSIENT_HTTP_ERROR_CODES = new Set([499, 500, 502, 503, 504, 521, 522, 523, 524, 529]);
const HTTP_ERROR_HINTS = [
"error",
@ -348,38 +355,6 @@ function classifyFailoverReasonFrom402Text(raw: string): PaymentRequiredFailover
return classify402Message(raw);
}
function extractLeadingHttpStatus(raw: string): { code: number; rest: string } | null {
const match = raw.match(HTTP_STATUS_CODE_PREFIX_RE);
if (!match) {
return null;
}
const code = Number(match[1]);
if (!Number.isFinite(code)) {
return null;
}
return { code, rest: (match[2] ?? "").trim() };
}
export function isCloudflareOrHtmlErrorPage(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
const status = extractLeadingHttpStatus(trimmed);
if (!status || status.code < 500) {
return false;
}
if (CLOUDFLARE_HTML_ERROR_CODES.has(status.code)) {
return true;
}
return (
status.code < 600 && HTML_ERROR_PREFIX_RE.test(status.rest) && /<\/html>/i.test(status.rest)
);
}
export function isTransientHttpError(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
@ -484,15 +459,14 @@ function isLikelyHttpErrorText(raw: string): boolean {
if (isCloudflareOrHtmlErrorPage(raw)) {
return true;
}
const match = raw.match(HTTP_STATUS_PREFIX_RE);
if (!match) {
const status = extractLeadingHttpStatus(raw);
if (!status) {
return false;
}
const code = Number(match[1]);
if (!Number.isFinite(code) || code < 400) {
if (status.code < 400) {
return false;
}
const message = match[2].toLowerCase();
const message = status.rest.toLowerCase();
return HTTP_ERROR_HINTS.some((hint) => message.includes(hint));
}
@ -580,99 +554,6 @@ export function isRawApiErrorPayload(raw?: string): boolean {
return getApiErrorPayloadFingerprint(raw) !== null;
}
export type ApiErrorInfo = {
httpCode?: string;
type?: string;
message?: string;
requestId?: string;
};
export function parseApiErrorInfo(raw?: string): ApiErrorInfo | null {
if (!raw) {
return null;
}
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
let httpCode: string | undefined;
let candidate = trimmed;
const httpPrefixMatch = candidate.match(/^(\d{3})\s+(.+)$/s);
if (httpPrefixMatch) {
httpCode = httpPrefixMatch[1];
candidate = httpPrefixMatch[2].trim();
}
const payload = parseApiErrorPayload(candidate);
if (!payload) {
return null;
}
const requestId =
typeof payload.request_id === "string"
? payload.request_id
: typeof payload.requestId === "string"
? payload.requestId
: undefined;
const topType = typeof payload.type === "string" ? payload.type : undefined;
const topMessage = typeof payload.message === "string" ? payload.message : undefined;
let errType: string | undefined;
let errMessage: string | undefined;
if (payload.error && typeof payload.error === "object" && !Array.isArray(payload.error)) {
const err = payload.error as Record<string, unknown>;
if (typeof err.type === "string") {
errType = err.type;
}
if (typeof err.code === "string" && !errType) {
errType = err.code;
}
if (typeof err.message === "string") {
errMessage = err.message;
}
}
return {
httpCode,
type: errType ?? topType,
message: errMessage ?? topMessage,
requestId,
};
}
export function formatRawAssistantErrorForUi(raw?: string): string {
const trimmed = (raw ?? "").trim();
if (!trimmed) {
return "LLM request failed with an unknown error.";
}
const leadingStatus = extractLeadingHttpStatus(trimmed);
if (leadingStatus && isCloudflareOrHtmlErrorPage(trimmed)) {
return `The AI service is temporarily unavailable (HTTP ${leadingStatus.code}). Please try again in a moment.`;
}
const httpMatch = trimmed.match(HTTP_STATUS_PREFIX_RE);
if (httpMatch) {
const rest = httpMatch[2].trim();
if (!rest.startsWith("{")) {
return `HTTP ${httpMatch[1]}: ${rest}`;
}
}
const info = parseApiErrorInfo(trimmed);
if (info?.message) {
const prefix = info.httpCode ? `HTTP ${info.httpCode}` : "LLM error";
const type = info.type ? ` ${info.type}` : "";
const requestId = info.requestId ? ` (request_id: ${info.requestId})` : "";
return `${prefix}${type}: ${info.message}${requestId}`;
}
return trimmed.length > 600 ? `${trimmed.slice(0, 600)}` : trimmed;
}
export function formatAssistantErrorText(
msg: AssistantMessage,
opts?: { cfg?: OpenClawConfig; sessionKey?: string; provider?: string; model?: string },

View File

@ -21,6 +21,13 @@ export type SearchConfigRecord = (NonNullable<OpenClawConfig["tools"]>["web"] ex
: never) &
Record<string, unknown>;
type UnsupportedWebSearchFilterName =
| "country"
| "language"
| "freshness"
| "date_after"
| "date_before";
export const DEFAULT_SEARCH_COUNT = 5;
export const MAX_SEARCH_COUNT = 10;
@ -210,3 +217,59 @@ export function writeCachedSearchPayload(
): void {
writeCache(SEARCH_CACHE, cacheKey, payload, ttlMs);
}
function readUnsupportedSearchFilter(
params: Record<string, unknown>,
): UnsupportedWebSearchFilterName | undefined {
for (const name of ["country", "language", "freshness", "date_after", "date_before"] as const) {
const value = params[name];
if (typeof value === "string" && value.trim()) {
return name;
}
}
return undefined;
}
function describeUnsupportedSearchFilter(name: UnsupportedWebSearchFilterName): string {
switch (name) {
case "country":
return "country filtering";
case "language":
return "language filtering";
case "freshness":
return "freshness filtering";
case "date_after":
case "date_before":
return "date_after/date_before filtering";
}
}
export function buildUnsupportedSearchFilterResponse(
params: Record<string, unknown>,
provider: string,
docs = "https://docs.openclaw.ai/tools/web",
):
| {
error: string;
message: string;
docs: string;
}
| undefined {
const unsupported = readUnsupportedSearchFilter(params);
if (!unsupported) {
return undefined;
}
const label = describeUnsupportedSearchFilter(unsupported);
const supportedLabel =
unsupported === "date_after" || unsupported === "date_before" ? "date filtering" : label;
return {
error: unsupported.startsWith("date_")
? "unsupported_date_filter"
: `unsupported_${unsupported}`,
message: `${label} is not supported by the ${provider} provider. Only Brave and Perplexity support ${supportedLabel}.`,
docs,
};
}

View File

@ -71,6 +71,37 @@ export function setScopedCredentialValue(
(scoped as Record<string, unknown>).apiKey = value;
}
export function mergeScopedSearchConfig(
searchConfig: Record<string, unknown> | undefined,
key: string,
pluginConfig: Record<string, unknown> | undefined,
options?: { mirrorApiKeyToTopLevel?: boolean },
): Record<string, unknown> | undefined {
if (!pluginConfig) {
return searchConfig;
}
const currentScoped =
searchConfig?.[key] &&
typeof searchConfig[key] === "object" &&
!Array.isArray(searchConfig[key])
? (searchConfig[key] as Record<string, unknown>)
: {};
const next: Record<string, unknown> = {
...searchConfig,
[key]: {
...currentScoped,
...pluginConfig,
},
};
if (options?.mirrorApiKeyToTopLevel && pluginConfig.apiKey !== undefined) {
next.apiKey = pluginConfig.apiKey;
}
return next;
}
export function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig {
const search = cfg?.tools?.web?.search;
if (!search || typeof search !== "object") {

View File

@ -3,6 +3,10 @@ import { __testing as braveTesting } from "../../../extensions/brave/src/brave-w
import { __testing as moonshotTesting } from "../../../extensions/moonshot/src/kimi-web-search-provider.js";
import { __testing as perplexityTesting } from "../../../extensions/perplexity/web-search-provider.js";
import { __testing as xaiTesting } from "../../../extensions/xai/src/grok-web-search-provider.js";
import {
buildUnsupportedSearchFilterResponse,
mergeScopedSearchConfig,
} from "../../plugin-sdk/provider-web-search.js";
import { withEnv } from "../../test-utils/env.js";
const {
inferPerplexityBaseUrlFromApiKey,
@ -198,6 +202,64 @@ describe("web_search date normalization", () => {
});
});
describe("web_search unsupported filter response", () => {
it("returns undefined when no unsupported filter is set", () => {
expect(buildUnsupportedSearchFilterResponse({ query: "openclaw" }, "gemini")).toBeUndefined();
});
it("maps non-date filters to provider-specific unsupported errors", () => {
expect(buildUnsupportedSearchFilterResponse({ country: "us" }, "grok")).toEqual({
error: "unsupported_country",
message:
"country filtering is not supported by the grok provider. Only Brave and Perplexity support country filtering.",
docs: "https://docs.openclaw.ai/tools/web",
});
});
it("collapses date filters to unsupported_date_filter", () => {
expect(buildUnsupportedSearchFilterResponse({ date_before: "2026-03-19" }, "kimi")).toEqual({
error: "unsupported_date_filter",
message:
"date_after/date_before filtering is not supported by the kimi provider. Only Brave and Perplexity support date filtering.",
docs: "https://docs.openclaw.ai/tools/web",
});
});
});
describe("web_search scoped config merge", () => {
it("returns the original config when no plugin config exists", () => {
const searchConfig = { provider: "grok", grok: { model: "grok-4-1-fast" } };
expect(mergeScopedSearchConfig(searchConfig, "grok", undefined)).toBe(searchConfig);
});
it("merges plugin config into the scoped provider object", () => {
expect(
mergeScopedSearchConfig({ provider: "grok", grok: { model: "old-model" } }, "grok", {
model: "new-model",
apiKey: "xai-test-key",
}),
).toEqual({
provider: "grok",
grok: { model: "new-model", apiKey: "xai-test-key" },
});
});
it("can mirror the plugin apiKey to the top level config", () => {
expect(
mergeScopedSearchConfig(
{ provider: "brave", brave: { count: 5 } },
"brave",
{ apiKey: "brave-test-key" },
{ mirrorApiKeyToTopLevel: true },
),
).toEqual({
provider: "brave",
apiKey: "brave-test-key",
brave: { count: 5, apiKey: "brave-test-key" },
});
});
});
describe("web_search kimi config resolution", () => {
it("uses config apiKey when provided", () => {
expect(resolveKimiApiKey({ apiKey: "kimi-test-key" })).toBe("kimi-test-key");

View File

@ -22,12 +22,12 @@ vi.mock("../../../../extensions/matrix/src/matrix/send.js", async () => {
};
});
beforeEach(() => {
beforeEach(async () => {
sessionBindingTesting.resetSessionBindingAdaptersForTests();
discordThreadBindingTesting.resetThreadBindingsForTests();
feishuThreadBindingTesting.resetFeishuThreadBindingsForTests();
resetMatrixThreadBindingsForTests();
telegramThreadBindingTesting.resetTelegramThreadBindingsForTests();
await telegramThreadBindingTesting.resetTelegramThreadBindingsForTests();
});
for (const entry of sessionBindingContractRegistry) {

View File

@ -394,4 +394,279 @@ describe("normalizeCompatibilityConfigValues", () => {
expect(res.config.skills?.allowBundled).toEqual(["peekaboo"]);
expect(res.changes).toEqual(["Removed nano-banana-pro from skills.allowBundled."]);
});
it("migrates legacy web search provider config to plugin-owned config paths", () => {
const res = normalizeCompatibilityConfigValues({
tools: {
web: {
search: {
provider: "gemini",
maxResults: 5,
apiKey: "brave-key",
gemini: {
apiKey: "gemini-key",
model: "gemini-2.5-flash",
},
firecrawl: {
apiKey: "firecrawl-key",
baseUrl: "https://api.firecrawl.dev",
},
},
},
},
});
expect(res.config.tools?.web?.search).toEqual({
provider: "gemini",
maxResults: 5,
});
expect(res.config.plugins?.entries?.brave).toEqual({
enabled: true,
config: {
webSearch: {
apiKey: "brave-key",
},
},
});
expect(res.config.plugins?.entries?.google).toEqual({
enabled: true,
config: {
webSearch: {
apiKey: "gemini-key",
model: "gemini-2.5-flash",
},
},
});
expect(res.config.plugins?.entries?.firecrawl).toEqual({
enabled: true,
config: {
webSearch: {
apiKey: "firecrawl-key",
baseUrl: "https://api.firecrawl.dev",
},
},
});
expect(res.changes).toEqual([
"Moved tools.web.search.apiKey → plugins.entries.brave.config.webSearch.apiKey.",
"Moved tools.web.search.firecrawl → plugins.entries.firecrawl.config.webSearch.",
"Moved tools.web.search.gemini → plugins.entries.google.config.webSearch.",
]);
});
it("merges legacy web search provider config into explicit plugin config without overriding it", () => {
const res = normalizeCompatibilityConfigValues({
tools: {
web: {
search: {
provider: "gemini",
gemini: {
apiKey: "legacy-gemini-key",
model: "legacy-model",
},
},
},
},
plugins: {
entries: {
google: {
enabled: true,
config: {
webSearch: {
model: "explicit-model",
baseUrl: "https://generativelanguage.googleapis.com",
},
},
},
},
},
});
expect(res.config.tools?.web?.search).toEqual({
provider: "gemini",
});
expect(res.config.plugins?.entries?.google).toEqual({
enabled: true,
config: {
webSearch: {
apiKey: "legacy-gemini-key",
model: "explicit-model",
baseUrl: "https://generativelanguage.googleapis.com",
},
},
});
expect(res.changes).toEqual([
"Merged tools.web.search.gemini → plugins.entries.google.config.webSearch (filled missing fields from legacy; kept explicit plugin config values).",
]);
});
it("migrates legacy talk flat fields to provider/providers", () => {
const res = normalizeCompatibilityConfigValues({
talk: {
voiceId: "voice-123",
voiceAliases: {
Clawd: "EXAVITQu4vr4xnSDxMaL",
},
modelId: "eleven_v3",
outputFormat: "pcm_44100",
apiKey: "secret-key",
interruptOnSpeech: false,
silenceTimeoutMs: 1500,
},
});
expect(res.config.talk).toEqual({
provider: "elevenlabs",
providers: {
elevenlabs: {
voiceId: "voice-123",
voiceAliases: {
Clawd: "EXAVITQu4vr4xnSDxMaL",
},
modelId: "eleven_v3",
outputFormat: "pcm_44100",
apiKey: "secret-key",
},
},
voiceId: "voice-123",
voiceAliases: {
Clawd: "EXAVITQu4vr4xnSDxMaL",
},
modelId: "eleven_v3",
outputFormat: "pcm_44100",
apiKey: "secret-key",
interruptOnSpeech: false,
silenceTimeoutMs: 1500,
});
expect(res.changes).toEqual([
"Moved legacy talk flat fields → talk.provider/talk.providers.elevenlabs.",
]);
});
it("normalizes talk provider ids without overriding explicit provider config", () => {
const res = normalizeCompatibilityConfigValues({
talk: {
provider: " elevenlabs ",
providers: {
" elevenlabs ": {
voiceId: "voice-123",
},
},
apiKey: "secret-key",
},
});
expect(res.config.talk).toEqual({
provider: "elevenlabs",
providers: {
elevenlabs: {
voiceId: "voice-123",
},
},
apiKey: "secret-key",
});
expect(res.changes).toEqual([
"Normalized talk.provider/providers shape (trimmed provider ids and merged missing compatibility fields).",
]);
});
it("migrates tools.message.allowCrossContextSend to canonical crossContext settings", () => {
const res = normalizeCompatibilityConfigValues({
tools: {
message: {
allowCrossContextSend: true,
crossContext: {
allowWithinProvider: false,
allowAcrossProviders: false,
},
},
},
});
expect(res.config.tools?.message).toEqual({
crossContext: {
allowWithinProvider: true,
allowAcrossProviders: true,
},
});
expect(res.changes).toEqual([
"Moved tools.message.allowCrossContextSend → tools.message.crossContext.allowWithinProvider/allowAcrossProviders (true).",
]);
});
it("migrates legacy deepgram media options to providerOptions.deepgram", () => {
const res = normalizeCompatibilityConfigValues({
tools: {
media: {
audio: {
deepgram: {
detectLanguage: true,
smartFormat: true,
},
providerOptions: {
deepgram: {
punctuate: false,
},
},
models: [
{
provider: "deepgram",
deepgram: {
punctuate: true,
},
},
],
},
models: [
{
provider: "deepgram",
deepgram: {
smartFormat: false,
},
providerOptions: {
deepgram: {
detect_language: true,
},
},
},
],
},
},
});
expect(res.config.tools?.media?.audio).toEqual({
providerOptions: {
deepgram: {
detect_language: true,
smart_format: true,
punctuate: false,
},
},
models: [
{
provider: "deepgram",
providerOptions: {
deepgram: {
punctuate: true,
},
},
},
],
});
expect(res.config.tools?.media?.models).toEqual([
{
provider: "deepgram",
providerOptions: {
deepgram: {
smart_format: false,
detect_language: true,
},
},
},
]);
expect(res.changes).toEqual([
"Merged tools.media.audio.deepgram → tools.media.audio.providerOptions.deepgram (filled missing canonical fields from legacy).",
"Moved tools.media.audio.models[0].deepgram → tools.media.audio.models[0].providerOptions.deepgram.",
"Merged tools.media.models[0].deepgram → tools.media.models[0].providerOptions.deepgram (filled missing canonical fields from legacy).",
]);
});
});

View File

@ -8,6 +8,8 @@ import {
resolveSlackStreamingMode,
resolveTelegramPreviewStreamMode,
} from "../config/discord-preview-streaming.js";
import { migrateLegacyWebSearchConfig } from "../config/legacy-web-search.js";
import { DEFAULT_TALK_PROVIDER, normalizeTalkSection } from "../config/talk.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
@ -429,6 +431,11 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
normalizeProvider("discord");
seedMissingDefaultAccountsFromSingleAccountBase();
normalizeLegacyBrowserProfiles();
const webSearchMigration = migrateLegacyWebSearchConfig(next);
if (webSearchMigration.changes.length > 0) {
next = webSearchMigration.config;
changes.push(...webSearchMigration.changes);
}
const normalizeBrowserSsrFPolicyAlias = () => {
const rawBrowser = next.browser;
@ -597,8 +604,207 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
}
};
const normalizeLegacyTalkConfig = () => {
const rawTalk = next.talk;
if (!isRecord(rawTalk)) {
return;
}
const normalizedTalk = normalizeTalkSection(rawTalk as OpenClawConfig["talk"]);
if (!normalizedTalk) {
return;
}
const sameShape = JSON.stringify(normalizedTalk) === JSON.stringify(rawTalk);
if (sameShape) {
return;
}
const hasProviderShape = typeof rawTalk.provider === "string" || isRecord(rawTalk.providers);
next = {
...next,
talk: normalizedTalk,
};
if (hasProviderShape) {
changes.push(
"Normalized talk.provider/providers shape (trimmed provider ids and merged missing compatibility fields).",
);
return;
}
changes.push(
`Moved legacy talk flat fields → talk.provider/talk.providers.${DEFAULT_TALK_PROVIDER}.`,
);
};
const normalizeLegacyCrossContextMessageConfig = () => {
const rawTools = next.tools;
if (!isRecord(rawTools)) {
return;
}
const rawMessage = rawTools.message;
if (!isRecord(rawMessage) || !("allowCrossContextSend" in rawMessage)) {
return;
}
const legacyAllowCrossContextSend = rawMessage.allowCrossContextSend;
if (typeof legacyAllowCrossContextSend !== "boolean") {
return;
}
const nextMessage = { ...rawMessage };
delete nextMessage.allowCrossContextSend;
if (legacyAllowCrossContextSend) {
const rawCrossContext = isRecord(nextMessage.crossContext)
? structuredClone(nextMessage.crossContext)
: {};
rawCrossContext.allowWithinProvider = true;
rawCrossContext.allowAcrossProviders = true;
nextMessage.crossContext = rawCrossContext;
changes.push(
"Moved tools.message.allowCrossContextSend → tools.message.crossContext.allowWithinProvider/allowAcrossProviders (true).",
);
} else {
changes.push(
"Removed tools.message.allowCrossContextSend=false (default cross-context policy already matches canonical settings).",
);
}
next = {
...next,
tools: {
...next.tools,
message: nextMessage,
},
};
};
const mapDeepgramCompatToProviderOptions = (
rawCompat: Record<string, unknown>,
): Record<string, string | number | boolean> => {
const providerOptions: Record<string, string | number | boolean> = {};
if (typeof rawCompat.detectLanguage === "boolean") {
providerOptions.detect_language = rawCompat.detectLanguage;
}
if (typeof rawCompat.punctuate === "boolean") {
providerOptions.punctuate = rawCompat.punctuate;
}
if (typeof rawCompat.smartFormat === "boolean") {
providerOptions.smart_format = rawCompat.smartFormat;
}
return providerOptions;
};
const migrateLegacyDeepgramCompat = (params: {
owner: Record<string, unknown>;
pathPrefix: string;
}): boolean => {
const rawCompat = isRecord(params.owner.deepgram)
? structuredClone(params.owner.deepgram)
: null;
if (!rawCompat) {
return false;
}
const compatProviderOptions = mapDeepgramCompatToProviderOptions(rawCompat);
const currentProviderOptions = isRecord(params.owner.providerOptions)
? structuredClone(params.owner.providerOptions)
: {};
const currentDeepgram = isRecord(currentProviderOptions.deepgram)
? structuredClone(currentProviderOptions.deepgram)
: {};
const mergedDeepgram = { ...compatProviderOptions, ...currentDeepgram };
delete params.owner.deepgram;
currentProviderOptions.deepgram = mergedDeepgram;
params.owner.providerOptions = currentProviderOptions;
const hadCanonicalDeepgram = Object.keys(currentDeepgram).length > 0;
changes.push(
hadCanonicalDeepgram
? `Merged ${params.pathPrefix}.deepgram → ${params.pathPrefix}.providerOptions.deepgram (filled missing canonical fields from legacy).`
: `Moved ${params.pathPrefix}.deepgram → ${params.pathPrefix}.providerOptions.deepgram.`,
);
return true;
};
const normalizeLegacyMediaProviderOptions = () => {
const rawTools = next.tools;
if (!isRecord(rawTools)) {
return;
}
const rawMedia = rawTools.media;
if (!isRecord(rawMedia)) {
return;
}
let mediaChanged = false;
const nextMedia = structuredClone(rawMedia);
const migrateModelList = (models: unknown, pathPrefix: string): boolean => {
if (!Array.isArray(models)) {
return false;
}
let changed = false;
for (const [index, entry] of models.entries()) {
if (!isRecord(entry)) {
continue;
}
if (
migrateLegacyDeepgramCompat({
owner: entry,
pathPrefix: `${pathPrefix}[${index}]`,
})
) {
changed = true;
}
}
return changed;
};
for (const capability of ["audio", "image", "video"] as const) {
const config = isRecord(nextMedia[capability])
? structuredClone(nextMedia[capability])
: null;
if (!config) {
continue;
}
let configChanged = false;
if (migrateLegacyDeepgramCompat({ owner: config, pathPrefix: `tools.media.${capability}` })) {
configChanged = true;
}
if (migrateModelList(config.models, `tools.media.${capability}.models`)) {
configChanged = true;
}
if (configChanged) {
nextMedia[capability] = config;
mediaChanged = true;
}
}
if (migrateModelList(nextMedia.models, "tools.media.models")) {
mediaChanged = true;
}
if (!mediaChanged) {
return;
}
next = {
...next,
tools: {
...next.tools,
media: nextMedia as NonNullable<OpenClawConfig["tools"]>["media"],
},
};
};
normalizeBrowserSsrFPolicyAlias();
normalizeLegacyNanoBananaSkill();
normalizeLegacyTalkConfig();
normalizeLegacyCrossContextMessageConfig();
normalizeLegacyMediaProviderOptions();
const legacyAckReaction = cfg.messages?.ackReaction?.trim();
const hasWhatsAppConfig = cfg.channels?.whatsapp !== undefined;

View File

@ -7,7 +7,7 @@ import {
normalizeSecretInputString,
} from "../config/types.secrets.js";
import type { PluginWebSearchProviderEntry } from "../plugins/types.js";
import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.js";
import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.runtime.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import type { SecretInputMode } from "./onboard-types.js";

View File

@ -16,6 +16,57 @@ vi.mock("../plugins/web-search-providers.js", () => {
| undefined
)?.entries?.[pluginId]?.config?.webSearch?.apiKey;
return {
resolveBundledPluginWebSearchProviders: () => [
{
id: "brave",
envVars: ["BRAVE_API_KEY"],
credentialPath: "plugins.entries.brave.config.webSearch.apiKey",
getCredentialValue: (search?: Record<string, unknown>) => search?.apiKey,
getConfiguredCredentialValue: getConfigured("brave"),
},
{
id: "firecrawl",
envVars: ["FIRECRAWL_API_KEY"],
credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey",
getCredentialValue: getScoped("firecrawl"),
getConfiguredCredentialValue: getConfigured("firecrawl"),
},
{
id: "gemini",
envVars: ["GEMINI_API_KEY"],
credentialPath: "plugins.entries.google.config.webSearch.apiKey",
getCredentialValue: getScoped("gemini"),
getConfiguredCredentialValue: getConfigured("google"),
},
{
id: "grok",
envVars: ["XAI_API_KEY"],
credentialPath: "plugins.entries.xai.config.webSearch.apiKey",
getCredentialValue: getScoped("grok"),
getConfiguredCredentialValue: getConfigured("xai"),
},
{
id: "kimi",
envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
credentialPath: "plugins.entries.moonshot.config.webSearch.apiKey",
getCredentialValue: getScoped("kimi"),
getConfiguredCredentialValue: getConfigured("moonshot"),
},
{
id: "perplexity",
envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"],
credentialPath: "plugins.entries.perplexity.config.webSearch.apiKey",
getCredentialValue: getScoped("perplexity"),
getConfiguredCredentialValue: getConfigured("perplexity"),
},
{
id: "tavily",
envVars: ["TAVILY_API_KEY"],
credentialPath: "plugins.entries.tavily.config.webSearch.apiKey",
getCredentialValue: getScoped("tavily"),
getConfiguredCredentialValue: getConfigured("tavily"),
},
],
resolvePluginWebSearchProviders: () => [
{
id: "brave",

View File

@ -0,0 +1,138 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
buildConfigDocBaseline,
renderConfigDocBaselineStatefile,
writeConfigDocBaselineStatefile,
} from "./doc-baseline.js";
describe("config doc baseline integration", () => {
const tempRoots: string[] = [];
let sharedBaselinePromise: Promise<Awaited<ReturnType<typeof buildConfigDocBaseline>>> | null =
null;
let sharedRenderedPromise: Promise<
Awaited<ReturnType<typeof renderConfigDocBaselineStatefile>>
> | null = null;
function getSharedBaseline() {
sharedBaselinePromise ??= buildConfigDocBaseline();
return sharedBaselinePromise;
}
function getSharedRendered() {
sharedRenderedPromise ??= renderConfigDocBaselineStatefile(getSharedBaseline());
return sharedRenderedPromise;
}
afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map(async (tempRoot) => {
await fs.rm(tempRoot, { recursive: true, force: true });
}),
);
});
it("is deterministic across repeated runs", async () => {
const first = await renderConfigDocBaselineStatefile();
const second = await renderConfigDocBaselineStatefile();
expect(second.json).toBe(first.json);
expect(second.jsonl).toBe(first.jsonl);
});
it("includes core, channel, and plugin config metadata", async () => {
const baseline = await getSharedBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
expect(byPath.get("gateway.auth.token")).toMatchObject({
kind: "core",
sensitive: true,
});
expect(byPath.get("channels.telegram.botToken")).toMatchObject({
kind: "channel",
sensitive: true,
});
expect(byPath.get("plugins.entries.voice-call.config.twilio.authToken")).toMatchObject({
kind: "plugin",
sensitive: true,
});
});
it("preserves help text and tags from merged schema hints", async () => {
const baseline = await getSharedBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
const tokenEntry = byPath.get("gateway.auth.token");
expect(tokenEntry?.help).toContain("gateway access");
expect(tokenEntry?.tags).toContain("auth");
expect(tokenEntry?.tags).toContain("security");
});
it("matches array help hints that still use [] notation", async () => {
const baseline = await getSharedBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
expect(byPath.get("session.sendPolicy.rules.*.match.keyPrefix")).toMatchObject({
help: expect.stringContaining("prefer rawKeyPrefix when exact full-key matching is required"),
sensitive: false,
});
});
it("walks union branches for nested config keys", async () => {
const baseline = await getSharedBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
expect(byPath.get("bindings.*")).toMatchObject({
hasChildren: true,
});
expect(byPath.get("bindings.*.type")).toBeDefined();
expect(byPath.get("bindings.*.match.channel")).toBeDefined();
expect(byPath.get("bindings.*.match.peer.id")).toBeDefined();
});
it("supports check mode for stale generated artifacts", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-doc-baseline-"));
tempRoots.push(tempRoot);
const rendered = getSharedRendered();
const initial = await writeConfigDocBaselineStatefile({
repoRoot: tempRoot,
jsonPath: "docs/.generated/config-baseline.json",
statefilePath: "docs/.generated/config-baseline.jsonl",
rendered,
});
expect(initial.wrote).toBe(true);
const current = await writeConfigDocBaselineStatefile({
repoRoot: tempRoot,
jsonPath: "docs/.generated/config-baseline.json",
statefilePath: "docs/.generated/config-baseline.jsonl",
check: true,
rendered,
});
expect(current.changed).toBe(false);
await fs.writeFile(
path.join(tempRoot, "docs/.generated/config-baseline.json"),
'{"generatedBy":"broken","entries":[]}\n',
"utf8",
);
await fs.writeFile(
path.join(tempRoot, "docs/.generated/config-baseline.jsonl"),
'{"recordType":"meta","generatedBy":"broken","totalPaths":0}\n',
"utf8",
);
const stale = await writeConfigDocBaselineStatefile({
repoRoot: tempRoot,
jsonPath: "docs/.generated/config-baseline.json",
statefilePath: "docs/.generated/config-baseline.jsonl",
check: true,
rendered,
});
expect(stale.changed).toBe(true);
expect(stale.wrote).toBe(false);
});
});

View File

@ -1,107 +1,17 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import {
buildConfigDocBaseline,
collectConfigDocBaselineEntries,
dedupeConfigDocBaselineEntries,
normalizeConfigDocBaselineHelpPath,
renderConfigDocBaselineStatefile,
writeConfigDocBaselineStatefile,
} from "./doc-baseline.js";
describe("config doc baseline", () => {
const tempRoots: string[] = [];
let sharedBaselinePromise: Promise<Awaited<ReturnType<typeof buildConfigDocBaseline>>> | null =
null;
let sharedRenderedPromise: Promise<
Awaited<ReturnType<typeof renderConfigDocBaselineStatefile>>
> | null = null;
function getSharedBaseline() {
sharedBaselinePromise ??= buildConfigDocBaseline();
return sharedBaselinePromise;
}
function getSharedRendered() {
sharedRenderedPromise ??= renderConfigDocBaselineStatefile(getSharedBaseline());
return sharedRenderedPromise;
}
afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map(async (tempRoot) => {
await fs.rm(tempRoot, { recursive: true, force: true });
}),
);
});
it("is deterministic across repeated runs", async () => {
const first = await renderConfigDocBaselineStatefile();
const second = await renderConfigDocBaselineStatefile();
expect(second.json).toBe(first.json);
expect(second.jsonl).toBe(first.jsonl);
});
it("normalizes array and record paths to wildcard form", async () => {
const baseline = await getSharedBaseline();
const paths = new Set(baseline.entries.map((entry) => entry.path));
expect(paths.has("session.sendPolicy.rules.*.match.keyPrefix")).toBe(true);
expect(paths.has("env.*")).toBe(true);
expect(normalizeConfigDocBaselineHelpPath("agents.list[].skills")).toBe("agents.list.*.skills");
});
it("includes core, channel, and plugin config metadata", async () => {
const baseline = await getSharedBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
expect(byPath.get("gateway.auth.token")).toMatchObject({
kind: "core",
sensitive: true,
});
expect(byPath.get("channels.telegram.botToken")).toMatchObject({
kind: "channel",
sensitive: true,
});
expect(byPath.get("plugins.entries.voice-call.config.twilio.authToken")).toMatchObject({
kind: "plugin",
sensitive: true,
});
});
it("preserves help text and tags from merged schema hints", async () => {
const baseline = await getSharedBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
const tokenEntry = byPath.get("gateway.auth.token");
expect(tokenEntry?.help).toContain("gateway access");
expect(tokenEntry?.tags).toContain("auth");
expect(tokenEntry?.tags).toContain("security");
});
it("matches array help hints that still use [] notation", async () => {
const baseline = await getSharedBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
expect(byPath.get("session.sendPolicy.rules.*.match.keyPrefix")).toMatchObject({
help: expect.stringContaining("prefer rawKeyPrefix when exact full-key matching is required"),
sensitive: false,
});
});
it("walks union branches for nested config keys", async () => {
const baseline = await getSharedBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
expect(byPath.get("bindings.*")).toMatchObject({
hasChildren: true,
});
expect(byPath.get("bindings.*.type")).toBeDefined();
expect(byPath.get("bindings.*.match.channel")).toBeDefined();
expect(byPath.get("bindings.*.match.peer.id")).toBeDefined();
expect(normalizeConfigDocBaselineHelpPath("session.sendPolicy.rules[0].match.keyPrefix")).toBe(
"session.sendPolicy.rules.*.match.keyPrefix",
);
expect(normalizeConfigDocBaselineHelpPath(".env.*.")).toBe("env.*");
});
it("merges tuple item metadata instead of dropping earlier entries", () => {
@ -132,48 +42,4 @@ describe("config doc baseline", () => {
expect(tupleEntry?.enumValues).toEqual(expect.arrayContaining([42, "alpha"]));
expect(tupleEntry?.enumValues).toHaveLength(2);
});
it("supports check mode for stale generated artifacts", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-doc-baseline-"));
tempRoots.push(tempRoot);
const rendered = getSharedRendered();
const initial = await writeConfigDocBaselineStatefile({
repoRoot: tempRoot,
jsonPath: "docs/.generated/config-baseline.json",
statefilePath: "docs/.generated/config-baseline.jsonl",
rendered,
});
expect(initial.wrote).toBe(true);
const current = await writeConfigDocBaselineStatefile({
repoRoot: tempRoot,
jsonPath: "docs/.generated/config-baseline.json",
statefilePath: "docs/.generated/config-baseline.jsonl",
check: true,
rendered,
});
expect(current.changed).toBe(false);
await fs.writeFile(
path.join(tempRoot, "docs/.generated/config-baseline.json"),
'{"generatedBy":"broken","entries":[]}\n',
"utf8",
);
await fs.writeFile(
path.join(tempRoot, "docs/.generated/config-baseline.jsonl"),
'{"recordType":"meta","generatedBy":"broken","totalPaths":0}\n',
"utf8",
);
const stale = await writeConfigDocBaselineStatefile({
repoRoot: tempRoot,
jsonPath: "docs/.generated/config-baseline.json",
statefilePath: "docs/.generated/config-baseline.jsonl",
check: true,
rendered,
});
expect(stale.changed).toBe(true);
expect(stale.wrote).toBe(false);
});
});

View File

@ -1,13 +1,10 @@
import { spawnSync } from "node:child_process";
import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { ChannelPlugin } from "../channels/plugins/index.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import { FIELD_HELP } from "./schema.help.js";
import { buildConfigSchema, type ConfigSchemaResponse } from "./schema.js";
import type { ConfigSchemaResponse } from "./schema.js";
import { findWildcardHintMatch, schemaHasChildren } from "./schema.shared.js";
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
@ -28,12 +25,6 @@ type JsonSchemaObject = JsonSchemaNode & {
oneOf?: JsonSchemaObject[];
};
type PackageChannelMetadata = {
id: string;
label: string;
blurb?: string;
};
type ChannelSurfaceMetadata = {
id: string;
label: string;
@ -277,191 +268,11 @@ function resolveFirstExistingPath(candidates: string[]): string | null {
return null;
}
function loadPackageChannelMetadata(rootDir: string): PackageChannelMetadata | null {
try {
const packageJson = JSON.parse(
fsSync.readFileSync(path.join(rootDir, "package.json"), "utf8"),
) as {
openclaw?: {
channel?: {
id?: unknown;
label?: unknown;
blurb?: unknown;
};
};
};
const channel = packageJson.openclaw?.channel;
if (!channel) {
return null;
}
const id = typeof channel.id === "string" ? channel.id.trim() : "";
const label = typeof channel.label === "string" ? channel.label.trim() : "";
const blurb = typeof channel.blurb === "string" ? channel.blurb.trim() : "";
if (!id || !label) {
return null;
}
return {
id,
label,
...(blurb ? { blurb } : {}),
};
} catch {
return null;
}
}
function isChannelPlugin(value: unknown): value is ChannelPlugin {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as { id?: unknown; meta?: unknown; capabilities?: unknown };
return typeof candidate.id === "string" && typeof candidate.meta === "object";
}
function resolveSetupChannelPlugin(value: unknown): ChannelPlugin | null {
if (!value || typeof value !== "object") {
return null;
}
const candidate = value as { plugin?: unknown };
return isChannelPlugin(candidate.plugin) ? candidate.plugin : null;
}
async function importChannelPluginModule(rootDir: string): Promise<ChannelPlugin> {
logConfigDocBaselineDebug(`resolve channel module ${rootDir}`);
const modulePath = resolveFirstExistingPath([
path.join(rootDir, "setup-entry.ts"),
path.join(rootDir, "setup-entry.js"),
path.join(rootDir, "setup-entry.mts"),
path.join(rootDir, "setup-entry.mjs"),
path.join(rootDir, "src", "channel.ts"),
path.join(rootDir, "src", "channel.js"),
path.join(rootDir, "src", "plugin.ts"),
path.join(rootDir, "src", "plugin.js"),
path.join(rootDir, "src", "index.ts"),
path.join(rootDir, "src", "index.js"),
path.join(rootDir, "src", "channel.mts"),
path.join(rootDir, "src", "channel.mjs"),
path.join(rootDir, "src", "plugin.mts"),
path.join(rootDir, "src", "plugin.mjs"),
]);
if (!modulePath) {
throw new Error(`channel source not found under ${rootDir}`);
}
logConfigDocBaselineDebug(`import channel module ${modulePath}`);
const imported = (await import(modulePath)) as Record<string, unknown>;
logConfigDocBaselineDebug(`imported channel module ${modulePath}`);
for (const value of Object.values(imported)) {
if (isChannelPlugin(value)) {
logConfigDocBaselineDebug(`resolved channel export ${modulePath}`);
return value;
}
const setupPlugin = resolveSetupChannelPlugin(value);
if (setupPlugin) {
logConfigDocBaselineDebug(`resolved setup channel export ${modulePath}`);
return setupPlugin;
}
if (typeof value === "function" && value.length === 0) {
const resolved = value();
if (isChannelPlugin(resolved)) {
logConfigDocBaselineDebug(`resolved channel factory ${modulePath}`);
return resolved;
}
}
}
throw new Error(`channel plugin export not found in ${modulePath}`);
}
async function importChannelSurfaceMetadata(
rootDir: string,
repoRoot: string,
env: NodeJS.ProcessEnv,
): Promise<ChannelSurfaceMetadata | null> {
logConfigDocBaselineDebug(`resolve channel config surface ${rootDir}`);
const packageMetadata = loadPackageChannelMetadata(rootDir);
if (!packageMetadata) {
logConfigDocBaselineDebug(`missing package channel metadata ${rootDir}`);
return null;
}
const modulePath = resolveFirstExistingPath([
path.join(rootDir, "src", "config-schema.ts"),
path.join(rootDir, "src", "config-schema.js"),
path.join(rootDir, "src", "config-schema.mts"),
path.join(rootDir, "src", "config-schema.mjs"),
]);
if (!modulePath) {
logConfigDocBaselineDebug(`missing channel config schema module ${rootDir}`);
return null;
}
logConfigDocBaselineDebug(`import channel config schema ${modulePath}`);
try {
logConfigDocBaselineDebug(`spawn channel config schema subprocess ${modulePath}`);
const result = spawnSync(
process.execPath,
[
"--import",
"tsx",
path.join(repoRoot, "scripts", "load-channel-config-surface.ts"),
modulePath,
],
{
cwd: repoRoot,
encoding: "utf8",
env,
timeout: 15_000,
maxBuffer: 10 * 1024 * 1024,
},
);
if (result.status !== 0 || result.error) {
throw result.error ?? new Error(result.stderr || `child exited with status ${result.status}`);
}
logConfigDocBaselineDebug(`completed channel config schema subprocess ${modulePath}`);
const configSchema = JSON.parse(result.stdout) as {
schema: Record<string, unknown>;
uiHints?: ConfigSchemaResponse["uiHints"];
};
return {
id: packageMetadata.id,
label: packageMetadata.label,
description: packageMetadata.blurb,
configSchema: configSchema.schema,
configUiHints: configSchema.uiHints,
};
} catch (error) {
logConfigDocBaselineDebug(
`channel config schema subprocess failed for ${modulePath}: ${String(error)}`,
);
return null;
}
}
async function loadChannelSurfaceMetadata(
rootDir: string,
repoRoot: string,
env: NodeJS.ProcessEnv,
): Promise<ChannelSurfaceMetadata> {
logConfigDocBaselineDebug(`load channel surface ${rootDir}`);
const configSurface = await importChannelSurfaceMetadata(rootDir, repoRoot, env);
if (configSurface) {
logConfigDocBaselineDebug(`resolved channel config surface ${rootDir}`);
return configSurface;
}
logConfigDocBaselineDebug(`fallback to channel plugin import ${rootDir}`);
const plugin = await importChannelPluginModule(rootDir);
return {
id: plugin.id,
label: plugin.meta.label,
description: plugin.meta.blurb,
configSchema: plugin.configSchema?.schema,
configUiHints: plugin.configSchema?.uiHints,
};
}
async function loadBundledConfigSchemaResponse(): Promise<ConfigSchemaResponse> {
const [{ loadPluginManifestRegistry }, { buildConfigSchema }] = await Promise.all([
import("../plugins/manifest-registry.js"),
import("./schema.js"),
]);
const repoRoot = resolveRepoRoot();
const env = {
...process.env,
@ -479,22 +290,49 @@ async function loadBundledConfigSchemaResponse(): Promise<ConfigSchemaResponse>
const bundledChannelPlugins = manifestRegistry.plugins.filter(
(plugin) => plugin.origin === "bundled" && plugin.channels.length > 0,
);
const loadChannelsSequentiallyForDebug = process.env.OPENCLAW_CONFIG_DOC_BASELINE_DEBUG === "1";
const channelPlugins = loadChannelsSequentiallyForDebug
? await bundledChannelPlugins.reduce<Promise<ChannelSurfaceMetadata[]>>(
async (promise, plugin) => {
const loaded = await promise;
loaded.push(await loadChannelSurfaceMetadata(plugin.rootDir, repoRoot, env));
return loaded;
},
Promise.resolve([]),
)
: await Promise.all(
bundledChannelPlugins.map(
async (plugin) => await loadChannelSurfaceMetadata(plugin.rootDir, repoRoot, env),
),
);
logConfigDocBaselineDebug(`imported ${channelPlugins.length} bundled channel plugins`);
const channelPlugins =
process.env.OPENCLAW_CONFIG_DOC_BASELINE_DEBUG === "1"
? await bundledChannelPlugins.reduce<Promise<ChannelSurfaceMetadata[]>>(
async (promise, plugin) => {
const loaded = await promise;
loaded.push(
(await loadChannelSurfaceMetadata(
plugin.rootDir,
plugin.id,
plugin.name ?? plugin.id,
repoRoot,
)) ?? {
id: plugin.id,
label: plugin.name ?? plugin.id,
description: plugin.description,
configSchema: plugin.configSchema,
configUiHints: plugin.configUiHints,
},
);
return loaded;
},
Promise.resolve([]),
)
: await Promise.all(
bundledChannelPlugins.map(
async (plugin) =>
(await loadChannelSurfaceMetadata(
plugin.rootDir,
plugin.id,
plugin.name ?? plugin.id,
repoRoot,
)) ?? {
id: plugin.id,
label: plugin.name ?? plugin.id,
description: plugin.description,
configSchema: plugin.configSchema,
configUiHints: plugin.configUiHints,
},
),
);
logConfigDocBaselineDebug(
`loaded ${channelPlugins.length} bundled channel entries from channel surfaces`,
);
return buildConfigSchema({
cache: false,
@ -517,6 +355,48 @@ async function loadBundledConfigSchemaResponse(): Promise<ConfigSchemaResponse>
});
}
async function loadChannelSurfaceMetadata(
rootDir: string,
id: string,
label: string,
repoRoot: string,
): Promise<ChannelSurfaceMetadata | null> {
logConfigDocBaselineDebug(`resolve channel config surface ${rootDir}`);
const modulePath = resolveFirstExistingPath([
path.join(rootDir, "src", "config-schema.ts"),
path.join(rootDir, "src", "config-schema.js"),
path.join(rootDir, "src", "config-schema.mts"),
path.join(rootDir, "src", "config-schema.mjs"),
]);
if (!modulePath) {
logConfigDocBaselineDebug(`missing channel config schema module ${rootDir}`);
return null;
}
logConfigDocBaselineDebug(`import channel config schema ${modulePath}`);
try {
const { loadChannelConfigSurfaceModule } =
await import("../../scripts/load-channel-config-surface.ts");
const configSurface = await loadChannelConfigSurfaceModule(modulePath, { repoRoot });
if (!configSurface) {
logConfigDocBaselineDebug(`channel config schema export missing ${modulePath}`);
return null;
}
logConfigDocBaselineDebug(`completed channel config schema import ${modulePath}`);
return {
id,
label,
configSchema: configSurface.schema,
configUiHints: configSurface.uiHints as ConfigSchemaResponse["uiHints"] | undefined,
};
} catch (error) {
logConfigDocBaselineDebug(
`channel config schema import failed for ${modulePath}: ${String(error)}`,
);
return null;
}
}
export function collectConfigDocBaselineEntries(
schema: JsonSchemaObject,
uiHints: ConfigSchemaResponse["uiHints"],

View File

@ -1,4 +1,5 @@
import type { OpenClawConfig } from "./config.js";
import { mergeMissing } from "./legacy.shared.js";
type JsonRecord = Record<string, unknown>;
@ -57,19 +58,60 @@ function copyLegacyProviderConfig(
return isRecord(current) ? cloneRecord(current) : undefined;
}
function setPluginWebSearchConfig(
target: JsonRecord,
pluginId: string,
webSearchConfig: JsonRecord,
): void {
const plugins = ensureRecord(target, "plugins");
function hasOwnKey(target: JsonRecord, key: string): boolean {
return Object.prototype.hasOwnProperty.call(target, key);
}
function hasMappedLegacyWebSearchConfig(raw: unknown): boolean {
const search = resolveLegacySearchConfig(raw);
if (!search) {
return false;
}
if (hasOwnKey(search, "apiKey")) {
return true;
}
return (Object.keys(LEGACY_PROVIDER_MAP) as LegacyProviderId[]).some((providerId) =>
isRecord(search[providerId]),
);
}
function migratePluginWebSearchConfig(params: {
root: JsonRecord;
legacyPath: string;
targetPath: string;
pluginId: string;
payload: JsonRecord;
changes: string[];
}) {
const plugins = ensureRecord(params.root, "plugins");
const entries = ensureRecord(plugins, "entries");
const entry = ensureRecord(entries, pluginId);
if (entry.enabled === undefined) {
const entry = ensureRecord(entries, params.pluginId);
const config = ensureRecord(entry, "config");
const hadEnabled = entry.enabled !== undefined;
const existing = isRecord(config.webSearch) ? cloneRecord(config.webSearch) : undefined;
if (!hadEnabled) {
entry.enabled = true;
}
const config = ensureRecord(entry, "config");
config.webSearch = webSearchConfig;
if (!existing) {
config.webSearch = cloneRecord(params.payload);
params.changes.push(`Moved ${params.legacyPath}${params.targetPath}.`);
return;
}
const merged = cloneRecord(existing);
mergeMissing(merged, params.payload);
const changed = JSON.stringify(merged) !== JSON.stringify(existing) || !hadEnabled;
config.webSearch = merged;
if (changed) {
params.changes.push(
`Merged ${params.legacyPath}${params.targetPath} (filled missing fields from legacy; kept explicit plugin config values).`,
);
return;
}
params.changes.push(`Removed ${params.legacyPath} (${params.targetPath} already set).`);
}
export function listLegacyWebSearchConfigPaths(raw: unknown): string[] {
@ -103,24 +145,73 @@ export function normalizeLegacyWebSearchConfig<T>(raw: T): T {
return raw;
}
return normalizeLegacyWebSearchConfigRecord(raw).config;
}
export function migrateLegacyWebSearchConfig<T>(raw: T): { config: T; changes: string[] } {
if (!isRecord(raw)) {
return { config: raw, changes: [] };
}
if (!hasMappedLegacyWebSearchConfig(raw)) {
return { config: raw, changes: [] };
}
return normalizeLegacyWebSearchConfigRecord(raw);
}
function normalizeLegacyWebSearchConfigRecord<T extends JsonRecord>(
raw: T,
): {
config: T;
changes: string[];
} {
const nextRoot = cloneRecord(raw);
const tools = ensureRecord(nextRoot, "tools");
const web = ensureRecord(tools, "web");
const search = resolveLegacySearchConfig(nextRoot);
if (!search) {
return { config: raw, changes: [] };
}
const nextSearch: JsonRecord = {};
const changes: string[] = [];
for (const [key, value] of Object.entries(search)) {
if (GENERIC_WEB_SEARCH_KEYS.has(key)) {
if (key === "apiKey") {
continue;
}
if (
(Object.keys(LEGACY_PROVIDER_MAP) as LegacyProviderId[]).includes(key as LegacyProviderId)
) {
if (isRecord(value)) {
continue;
}
}
if (GENERIC_WEB_SEARCH_KEYS.has(key) || !isRecord(value)) {
nextSearch[key] = value;
}
}
web.search = nextSearch;
const braveConfig = copyLegacyProviderConfig(search, "brave") ?? {};
if ("apiKey" in search) {
const legacyBraveConfig = copyLegacyProviderConfig(search, "brave");
const braveConfig = legacyBraveConfig ?? {};
if (hasOwnKey(search, "apiKey")) {
braveConfig.apiKey = search.apiKey;
}
if (Object.keys(braveConfig).length > 0) {
setPluginWebSearchConfig(nextRoot, LEGACY_PROVIDER_MAP.brave, braveConfig);
migratePluginWebSearchConfig({
root: nextRoot,
legacyPath: hasOwnKey(search, "apiKey")
? "tools.web.search.apiKey"
: "tools.web.search.brave",
targetPath:
hasOwnKey(search, "apiKey") && !legacyBraveConfig
? "plugins.entries.brave.config.webSearch.apiKey"
: "plugins.entries.brave.config.webSearch",
pluginId: LEGACY_PROVIDER_MAP.brave,
payload: braveConfig,
changes,
});
}
for (const providerId of [
@ -135,10 +226,17 @@ export function normalizeLegacyWebSearchConfig<T>(raw: T): T {
if (!scoped || Object.keys(scoped).length === 0) {
continue;
}
setPluginWebSearchConfig(nextRoot, LEGACY_PROVIDER_MAP[providerId], scoped);
migratePluginWebSearchConfig({
root: nextRoot,
legacyPath: `tools.web.search.${providerId}`,
targetPath: `plugins.entries.${LEGACY_PROVIDER_MAP[providerId]}.config.webSearch`,
pluginId: LEGACY_PROVIDER_MAP[providerId],
payload: scoped,
changes,
});
}
return nextRoot as T;
return { config: nextRoot, changes };
}
export function resolvePluginWebSearchConfig(

View File

@ -9,6 +9,7 @@ export { readNumberParam, readStringArrayParam, readStringParam } from "../agent
export { resolveCitationRedirectUrl } from "../agents/tools/web-search-citation-redirect.js";
export {
buildSearchCacheKey,
buildUnsupportedSearchFilterResponse,
DEFAULT_SEARCH_COUNT,
FRESHNESS_TO_RECENCY,
isoToPerplexityDate,
@ -29,6 +30,7 @@ export {
export {
getScopedCredentialValue,
getTopLevelCredentialValue,
mergeScopedSearchConfig,
resolveProviderWebSearchPluginConfig,
setScopedCredentialValue,
setProviderWebSearchPluginConfigValue,

View File

@ -0,0 +1,98 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { __testing } from "./loader.js";
type CreateJiti = typeof import("jiti").createJiti;
let createJitiPromise: Promise<CreateJiti> | undefined;
async function getCreateJiti() {
createJitiPromise ??= import("jiti").then(({ createJiti }) => createJiti);
return createJitiPromise;
}
const tempRoots: string[] = [];
function makeTempDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-loader-"));
tempRoots.push(dir);
return dir;
}
function mkdirSafe(dir: string) {
fs.mkdirSync(dir, { recursive: true });
}
afterEach(() => {
for (const dir of tempRoots.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("plugin loader git path regression", () => {
it("loads git-style package extension entries when they import plugin-sdk channel-runtime (#49806)", async () => {
const copiedExtensionRoot = path.join(makeTempDir(), "extensions", "imessage");
const copiedSourceDir = path.join(copiedExtensionRoot, "src");
const copiedPluginSdkDir = path.join(copiedExtensionRoot, "plugin-sdk");
mkdirSafe(copiedSourceDir);
mkdirSafe(copiedPluginSdkDir);
const jitiBaseFile = path.join(copiedSourceDir, "__jiti-base__.mjs");
fs.writeFileSync(jitiBaseFile, "export {};\n", "utf-8");
fs.writeFileSync(
path.join(copiedSourceDir, "channel.runtime.ts"),
`import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
import { PAIRING_APPROVED_MESSAGE } from "../runtime-api.js";
export const copiedRuntimeMarker = {
resolveOutboundSendDep,
PAIRING_APPROVED_MESSAGE,
};
`,
"utf-8",
);
fs.writeFileSync(
path.join(copiedExtensionRoot, "runtime-api.ts"),
`export const PAIRING_APPROVED_MESSAGE = "paired";
`,
"utf-8",
);
const copiedChannelRuntimeShim = path.join(copiedPluginSdkDir, "channel-runtime.ts");
fs.writeFileSync(
copiedChannelRuntimeShim,
`export function resolveOutboundSendDep() {
return "shimmed";
}
`,
"utf-8",
);
const copiedChannelRuntime = path.join(copiedExtensionRoot, "src", "channel.runtime.ts");
const jitiBaseUrl = pathToFileURL(jitiBaseFile).href;
const createJiti = await getCreateJiti();
const withoutAlias = createJiti(jitiBaseUrl, {
...__testing.buildPluginLoaderJitiOptions({}),
tryNative: false,
});
// Jiti's pre-alias failure text varies across Node versions and platforms.
// The contract is simply that the source import rejects until the scoped
// plugin-sdk alias is applied.
await expect(withoutAlias.import(copiedChannelRuntime)).rejects.toThrow();
const withAlias = createJiti(jitiBaseUrl, {
...__testing.buildPluginLoaderJitiOptions({
"openclaw/plugin-sdk/channel-runtime": copiedChannelRuntimeShim,
}),
tryNative: false,
});
await expect(withAlias.import(copiedChannelRuntime)).resolves.toMatchObject({
copiedRuntimeMarker: {
PAIRING_APPROVED_MESSAGE: "paired",
resolveOutboundSendDep: expect.any(Function),
},
});
});
});

View File

@ -1,11 +1,18 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { createJiti } from "jiti";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import { withEnv } from "../test-utils/env.js";
type CreateJiti = typeof import("jiti").createJiti;
let createJitiPromise: Promise<CreateJiti> | undefined;
async function getCreateJiti() {
createJitiPromise ??= import("jiti").then(({ createJiti }) => createJiti);
return createJitiPromise;
}
async function importFreshPluginTestModules() {
vi.resetModules();
vi.doUnmock("node:fs");
@ -3244,42 +3251,24 @@ module.exports = {
body: `module.exports = {
id: "legacy-root-import",
configSchema: (require("openclaw/plugin-sdk").emptyPluginConfigSchema)(),
register() {},
};`,
register() {},
};`,
});
const loaderModuleUrl = pathToFileURL(
path.join(process.cwd(), "src", "plugins", "loader.ts"),
).href;
const script = `
import { loadOpenClawPlugins } from ${JSON.stringify(loaderModuleUrl)};
const registry = loadOpenClawPlugins({
const registry = withEnv({ OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins" }, () =>
loadOpenClawPlugins({
cache: false,
workspaceDir: ${JSON.stringify(plugin.dir)},
workspaceDir: plugin.dir,
config: {
plugins: {
load: { paths: [${JSON.stringify(plugin.file)}] },
load: { paths: [plugin.file] },
allow: ["legacy-root-import"],
},
},
});
const record = registry.plugins.find((entry) => entry.id === "legacy-root-import");
if (!record || record.status !== "loaded") {
console.error(record?.error ?? "legacy-root-import missing");
process.exit(1);
}
`;
execFileSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], {
cwd: process.cwd(),
env: {
...process.env,
OPENCLAW_HOME: undefined,
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
},
encoding: "utf-8",
stdio: "pipe",
});
}),
);
const record = registry.plugins.find((entry) => entry.id === "legacy-root-import");
expect(record?.status).toBe("loaded");
});
it.each([
@ -3572,25 +3561,7 @@ module.exports = {
});
it("loads source runtime shims through the non-native Jiti boundary", async () => {
const jiti = createJiti(import.meta.url, {
...__testing.buildPluginLoaderJitiOptions(__testing.resolvePluginSdkScopedAliasMap()),
tryNative: false,
});
const discordChannelRuntime = path.join(
process.cwd(),
"extensions",
"discord",
"src",
"channel.runtime.ts",
);
await expect(jiti.import(discordChannelRuntime)).resolves.toMatchObject({
discordSetupWizard: expect.any(Object),
});
}, 240_000);
it("loads copied imessage runtime sources from git-style paths with plugin-sdk aliases (#49806)", async () => {
const copiedExtensionRoot = path.join(makeTempDir(), "extensions", "imessage");
const copiedExtensionRoot = path.join(makeTempDir(), "extensions", "discord");
const copiedSourceDir = path.join(copiedExtensionRoot, "src");
const copiedPluginSdkDir = path.join(copiedExtensionRoot, "plugin-sdk");
mkdirSafe(copiedSourceDir);
@ -3600,18 +3571,10 @@ module.exports = {
fs.writeFileSync(
path.join(copiedSourceDir, "channel.runtime.ts"),
`import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
import { PAIRING_APPROVED_MESSAGE } from "../runtime-api.js";
export const copiedRuntimeMarker = {
export const syntheticRuntimeMarker = {
resolveOutboundSendDep,
PAIRING_APPROVED_MESSAGE,
};
`,
"utf-8",
);
fs.writeFileSync(
path.join(copiedExtensionRoot, "runtime-api.ts"),
`export const PAIRING_APPROVED_MESSAGE = "paired";
`,
"utf-8",
);
@ -3627,13 +3590,15 @@ export const copiedRuntimeMarker = {
const copiedChannelRuntime = path.join(copiedExtensionRoot, "src", "channel.runtime.ts");
const jitiBaseUrl = pathToFileURL(jitiBaseFile).href;
const createJiti = await getCreateJiti();
const withoutAlias = createJiti(jitiBaseUrl, {
...__testing.buildPluginLoaderJitiOptions({}),
tryNative: false,
});
await expect(withoutAlias.import(copiedChannelRuntime)).rejects.toThrow(
/plugin-sdk\/channel-runtime/,
);
// Jiti's pre-alias failure text varies across Node versions and platforms.
// This boundary only needs to prove the source import rejects until the
// plugin-sdk alias is present.
await expect(withoutAlias.import(copiedChannelRuntime)).rejects.toThrow();
const withAlias = createJiti(jitiBaseUrl, {
...__testing.buildPluginLoaderJitiOptions({
@ -3642,94 +3607,11 @@ export const copiedRuntimeMarker = {
tryNative: false,
});
await expect(withAlias.import(copiedChannelRuntime)).resolves.toMatchObject({
copiedRuntimeMarker: {
PAIRING_APPROVED_MESSAGE: "paired",
syntheticRuntimeMarker: {
resolveOutboundSendDep: expect.any(Function),
},
});
});
it("loads git-style package extension entries through the plugin loader when they import plugin-sdk channel-runtime (#49806)", () => {
useNoBundledPlugins();
const pluginId = "imessage-loader-regression";
const gitExtensionRoot = path.join(
makeTempDir(),
"git-source-checkout",
"extensions",
pluginId,
);
const gitSourceDir = path.join(gitExtensionRoot, "src");
mkdirSafe(gitSourceDir);
fs.writeFileSync(
path.join(gitExtensionRoot, "package.json"),
JSON.stringify(
{
name: `@openclaw/${pluginId}`,
version: "0.0.1",
type: "module",
openclaw: {
extensions: ["./src/index.ts"],
},
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(gitExtensionRoot, "openclaw.plugin.json"),
JSON.stringify(
{
id: pluginId,
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(gitSourceDir, "channel.runtime.ts"),
`import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
export function runtimeProbeType() {
return typeof resolveOutboundSendDep;
}
`,
"utf-8",
);
fs.writeFileSync(
path.join(gitSourceDir, "index.ts"),
`import { runtimeProbeType } from "./channel.runtime.ts";
export default {
id: ${JSON.stringify(pluginId)},
register() {
if (runtimeProbeType() !== "function") {
throw new Error("channel-runtime import did not resolve");
}
},
};
`,
"utf-8",
);
const registry = withEnv({ NODE_ENV: "production", VITEST: undefined }, () =>
loadOpenClawPlugins({
cache: false,
workspaceDir: gitExtensionRoot,
config: {
plugins: {
load: { paths: [gitExtensionRoot] },
allow: [pluginId],
},
},
}),
);
const record = registry.plugins.find((entry) => entry.id === pluginId);
expect(record?.status).toBe("loaded");
});
}, 240_000);
it("loads source TypeScript plugins that route through local runtime shims", () => {
const plugin = writePlugin({

View File

@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "./registry.js";
import { setActivePluginRegistry } from "./runtime.js";
import {
resolvePluginWebSearchProviders,
resolveRuntimeWebSearchProviders,
} from "./web-search-providers.runtime.js";
const BUNDLED_WEB_SEARCH_PROVIDERS = [
{ pluginId: "brave", id: "brave", order: 10 },
{ pluginId: "google", id: "gemini", order: 20 },
{ pluginId: "xai", id: "grok", order: 30 },
{ pluginId: "moonshot", id: "kimi", order: 40 },
{ pluginId: "perplexity", id: "perplexity", order: 50 },
{ pluginId: "firecrawl", id: "firecrawl", order: 60 },
{ pluginId: "tavily", id: "tavily", order: 70 },
] as const;
const { loadOpenClawPluginsMock } = vi.hoisted(() => ({
loadOpenClawPluginsMock: vi.fn((params?: { config?: { plugins?: Record<string, unknown> } }) => {
const plugins = params?.config?.plugins as
| {
enabled?: boolean;
allow?: string[];
entries?: Record<string, { enabled?: boolean }>;
}
| undefined;
if (plugins?.enabled === false) {
return { webSearchProviders: [] };
}
const allow = Array.isArray(plugins?.allow) && plugins.allow.length > 0 ? plugins.allow : null;
const entries = plugins?.entries ?? {};
const webSearchProviders = BUNDLED_WEB_SEARCH_PROVIDERS.filter((provider) => {
if (allow && !allow.includes(provider.pluginId)) {
return false;
}
if (entries[provider.pluginId]?.enabled === false) {
return false;
}
return true;
}).map((provider) => ({
pluginId: provider.pluginId,
pluginName: provider.pluginId,
source: "test" as const,
provider: {
id: provider.id,
label: provider.id,
hint: `${provider.id} provider`,
envVars: [`${provider.id.toUpperCase()}_API_KEY`],
placeholder: `${provider.id}-...`,
signupUrl: `https://example.com/${provider.id}`,
autoDetectOrder: provider.order,
credentialPath: `plugins.entries.${provider.pluginId}.config.webSearch.apiKey`,
getCredentialValue: () => "configured",
setCredentialValue: () => {},
createTool: () => ({
description: provider.id,
parameters: {},
execute: async () => ({}),
}),
},
}));
return { webSearchProviders };
}),
}));
vi.mock("./loader.js", () => ({
loadOpenClawPlugins: loadOpenClawPluginsMock,
}));
describe("resolvePluginWebSearchProviders", () => {
beforeEach(() => {
loadOpenClawPluginsMock.mockClear();
setActivePluginRegistry(createEmptyPluginRegistry());
});
afterEach(() => {
setActivePluginRegistry(createEmptyPluginRegistry());
});
it("loads bundled providers through the plugin loader in auto-detect order", () => {
const providers = resolvePluginWebSearchProviders({});
expect(providers.map((provider) => `${provider.pluginId}:${provider.id}`)).toEqual([
"brave:brave",
"google:gemini",
"xai:grok",
"moonshot:kimi",
"perplexity:perplexity",
"firecrawl:firecrawl",
"tavily:tavily",
]);
expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(1);
});
it("prefers the active plugin registry for runtime resolution", () => {
const registry = createEmptyPluginRegistry();
registry.webSearchProviders.push({
pluginId: "custom-search",
pluginName: "Custom Search",
provider: {
id: "custom",
label: "Custom Search",
hint: "Custom runtime provider",
envVars: ["CUSTOM_SEARCH_API_KEY"],
placeholder: "custom-...",
signupUrl: "https://example.com/signup",
autoDetectOrder: 1,
credentialPath: "tools.web.search.custom.apiKey",
getCredentialValue: () => "configured",
setCredentialValue: () => {},
createTool: () => ({
description: "custom",
parameters: {},
execute: async () => ({}),
}),
},
source: "test",
});
setActivePluginRegistry(registry);
const providers = resolveRuntimeWebSearchProviders({});
expect(providers.map((provider) => `${provider.pluginId}:${provider.id}`)).toEqual([
"custom-search:custom",
]);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,56 @@
import { createSubsystemLogger } from "../logging/subsystem.js";
import { loadOpenClawPlugins } from "./loader.js";
import type { PluginLoadOptions } from "./loader.js";
import { createPluginLoaderLogger } from "./logger.js";
import { getActivePluginRegistry } from "./runtime.js";
import type { PluginWebSearchProviderEntry } from "./types.js";
import {
resolveBundledWebSearchResolutionConfig,
sortWebSearchProviders,
} from "./web-search-providers.shared.js";
const log = createSubsystemLogger("plugins");
export function resolvePluginWebSearchProviders(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
bundledAllowlistCompat?: boolean;
activate?: boolean;
cache?: boolean;
}): PluginWebSearchProviderEntry[] {
const { config } = resolveBundledWebSearchResolutionConfig(params);
const registry = loadOpenClawPlugins({
config,
workspaceDir: params.workspaceDir,
env: params.env,
cache: params.cache ?? false,
activate: params.activate ?? false,
logger: createPluginLoaderLogger(log),
});
return sortWebSearchProviders(
registry.webSearchProviders.map((entry) => ({
...entry.provider,
pluginId: entry.pluginId,
})),
);
}
export function resolveRuntimeWebSearchProviders(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
bundledAllowlistCompat?: boolean;
}): PluginWebSearchProviderEntry[] {
const runtimeProviders = getActivePluginRegistry()?.webSearchProviders ?? [];
if (runtimeProviders.length > 0) {
return sortWebSearchProviders(
runtimeProviders.map((entry) => ({
...entry.provider,
pluginId: entry.pluginId,
})),
);
}
return resolvePluginWebSearchProviders(params);
}

View File

@ -0,0 +1,120 @@
import {
withBundledPluginAllowlistCompat,
withBundledPluginEnablementCompat,
} from "./bundled-compat.js";
import { resolveBundledWebSearchPluginIds } from "./bundled-web-search.js";
import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js";
import type { PluginLoadOptions } from "./loader.js";
import type { PluginWebSearchProviderEntry } from "./types.js";
export function hasExplicitPluginConfig(config: PluginLoadOptions["config"]): boolean {
const plugins = config?.plugins;
if (!plugins) {
return false;
}
if (typeof plugins.enabled === "boolean") {
return true;
}
if (Array.isArray(plugins.allow) && plugins.allow.length > 0) {
return true;
}
if (Array.isArray(plugins.deny) && plugins.deny.length > 0) {
return true;
}
if (Array.isArray(plugins.load?.paths) && plugins.load.paths.length > 0) {
return true;
}
if (plugins.entries && Object.keys(plugins.entries).length > 0) {
return true;
}
if (plugins.slots && Object.keys(plugins.slots).length > 0) {
return true;
}
return false;
}
function resolveBundledWebSearchCompatPluginIds(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): string[] {
return resolveBundledWebSearchPluginIds({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
}
function withBundledWebSearchVitestCompat(params: {
config: PluginLoadOptions["config"];
pluginIds: readonly string[];
env?: PluginLoadOptions["env"];
}): PluginLoadOptions["config"] {
const env = params.env ?? process.env;
const isVitest = Boolean(env.VITEST || process.env.VITEST);
if (!isVitest || hasExplicitPluginConfig(params.config) || params.pluginIds.length === 0) {
return params.config;
}
return {
...params.config,
plugins: {
...params.config?.plugins,
enabled: true,
allow: [...params.pluginIds],
slots: {
...params.config?.plugins?.slots,
memory: "none",
},
},
};
}
export function sortWebSearchProviders(
providers: PluginWebSearchProviderEntry[],
): PluginWebSearchProviderEntry[] {
return providers.toSorted((a, b) => {
const aOrder = a.autoDetectOrder ?? Number.MAX_SAFE_INTEGER;
const bOrder = b.autoDetectOrder ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
return a.id.localeCompare(b.id);
});
}
export function resolveBundledWebSearchResolutionConfig(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
bundledAllowlistCompat?: boolean;
}): {
config: PluginLoadOptions["config"];
normalized: NormalizedPluginsConfig;
} {
const bundledCompatPluginIds = resolveBundledWebSearchCompatPluginIds({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const allowlistCompat = params.bundledAllowlistCompat
? withBundledPluginAllowlistCompat({
config: params.config,
pluginIds: bundledCompatPluginIds,
})
: params.config;
const enablementCompat = withBundledPluginEnablementCompat({
config: allowlistCompat,
pluginIds: bundledCompatPluginIds,
});
const config = withBundledWebSearchVitestCompat({
config: enablementCompat,
pluginIds: bundledCompatPluginIds,
env: params.env,
});
return {
config,
normalized: normalizePluginsConfig(config?.plugins),
};
}

View File

@ -1,95 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { createEmptyPluginRegistry } from "./registry.js";
import { setActivePluginRegistry } from "./runtime.js";
import {
resolveBundledPluginWebSearchProviders,
resolvePluginWebSearchProviders,
resolveRuntimeWebSearchProviders,
} from "./web-search-providers.js";
const BUNDLED_WEB_SEARCH_PROVIDERS = [
{ pluginId: "baidu", id: "baidu", order: 5 },
{ pluginId: "brave", id: "brave", order: 10 },
{ pluginId: "google", id: "gemini", order: 20 },
{ pluginId: "xai", id: "grok", order: 30 },
{ pluginId: "moonshot", id: "kimi", order: 40 },
{ pluginId: "perplexity", id: "perplexity", order: 50 },
{ pluginId: "firecrawl", id: "firecrawl", order: 60 },
{ pluginId: "tavily", id: "tavily", order: 70 },
] as const;
const { loadOpenClawPluginsMock } = vi.hoisted(() => ({
loadOpenClawPluginsMock: vi.fn((params?: { config?: { plugins?: Record<string, unknown> } }) => {
const plugins = params?.config?.plugins as
| {
enabled?: boolean;
allow?: string[];
entries?: Record<string, { enabled?: boolean }>;
}
| undefined;
if (plugins?.enabled === false) {
return { webSearchProviders: [] };
}
const allow = Array.isArray(plugins?.allow) && plugins.allow.length > 0 ? plugins.allow : null;
const entries = plugins?.entries ?? {};
const webSearchProviders = BUNDLED_WEB_SEARCH_PROVIDERS.filter((provider) => {
if (allow && !allow.includes(provider.pluginId)) {
return false;
}
if (entries[provider.pluginId]?.enabled === false) {
return false;
}
return true;
}).map((provider) => ({
pluginId: provider.pluginId,
pluginName: provider.pluginId,
source: "test" as const,
provider: {
id: provider.id,
label: provider.id,
hint: `${provider.id} provider`,
envVars: [`${provider.id.toUpperCase()}_API_KEY`],
placeholder: `${provider.id}-...`,
signupUrl: `https://example.com/${provider.id}`,
autoDetectOrder: provider.order,
credentialPath: `plugins.entries.${provider.pluginId}.config.webSearch.apiKey`,
getCredentialValue: () => "configured",
setCredentialValue: () => {},
applySelectionConfig:
provider.id === "firecrawl" ? (config: OpenClawConfig) => config : undefined,
resolveRuntimeMetadata:
provider.id === "perplexity"
? () => ({
perplexityTransport: "search_api" as const,
})
: undefined,
createTool: () => ({
description: provider.id,
parameters: {},
execute: async () => ({}),
}),
},
}));
return { webSearchProviders };
}),
}));
vi.mock("./loader.js", () => ({
loadOpenClawPlugins: loadOpenClawPluginsMock,
}));
describe("resolvePluginWebSearchProviders", () => {
beforeEach(() => {
loadOpenClawPluginsMock.mockClear();
});
afterEach(() => {
setActivePluginRegistry(createEmptyPluginRegistry());
});
import { describe, expect, it } from "vitest";
import { resolveBundledPluginWebSearchProviders } from "./web-search-providers.js";
describe("resolveBundledPluginWebSearchProviders", () => {
it("returns bundled providers in auto-detect order", () => {
const providers = resolvePluginWebSearchProviders({});
const providers = resolveBundledPluginWebSearchProviders({});
expect(providers.map((provider) => `${provider.pluginId}:${provider.id}`)).toEqual([
"baidu:baidu",
@ -120,7 +34,7 @@ describe("resolvePluginWebSearchProviders", () => {
});
it("can augment restrictive allowlists for bundled compatibility", () => {
const providers = resolvePluginWebSearchProviders({
const providers = resolveBundledPluginWebSearchProviders({
config: {
plugins: {
allow: ["openrouter"],
@ -142,7 +56,7 @@ describe("resolvePluginWebSearchProviders", () => {
});
it("does not return bundled providers excluded by a restrictive allowlist without compat", () => {
const providers = resolvePluginWebSearchProviders({
const providers = resolveBundledPluginWebSearchProviders({
config: {
plugins: {
allow: ["openrouter"],
@ -154,7 +68,7 @@ describe("resolvePluginWebSearchProviders", () => {
});
it("preserves explicit bundled provider entry state", () => {
const providers = resolvePluginWebSearchProviders({
const providers = resolveBundledPluginWebSearchProviders({
config: {
plugins: {
entries: {
@ -168,7 +82,7 @@ describe("resolvePluginWebSearchProviders", () => {
});
it("returns no providers when plugins are globally disabled", () => {
const providers = resolvePluginWebSearchProviders({
const providers = resolveBundledPluginWebSearchProviders({
config: {
plugins: {
enabled: false,
@ -194,7 +108,6 @@ describe("resolvePluginWebSearchProviders", () => {
"firecrawl:firecrawl",
"tavily:tavily",
]);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("can scope bundled resolution to one plugin id", () => {
@ -215,39 +128,5 @@ describe("resolvePluginWebSearchProviders", () => {
expect(providers.map((provider) => `${provider.pluginId}:${provider.id}`)).toEqual([
"google:gemini",
]);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("prefers the active plugin registry for runtime resolution", () => {
const registry = createEmptyPluginRegistry();
registry.webSearchProviders.push({
pluginId: "custom-search",
pluginName: "Custom Search",
provider: {
id: "custom",
label: "Custom Search",
hint: "Custom runtime provider",
envVars: ["CUSTOM_SEARCH_API_KEY"],
placeholder: "custom-...",
signupUrl: "https://example.com/signup",
autoDetectOrder: 1,
credentialPath: "tools.web.search.custom.apiKey",
getCredentialValue: () => "configured",
setCredentialValue: () => {},
createTool: () => ({
description: "custom",
parameters: {},
execute: async () => ({}),
}),
},
source: "test",
});
setActivePluginRegistry(registry);
const providers = resolveRuntimeWebSearchProviders({});
expect(providers.map((provider) => `${provider.pluginId}:${provider.id}`)).toEqual([
"custom-search:custom",
]);
});
});

View File

@ -1,135 +1,11 @@
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
withBundledPluginAllowlistCompat,
withBundledPluginEnablementCompat,
} from "./bundled-compat.js";
import {
listBundledWebSearchProviders as listBundledWebSearchProviderEntries,
resolveBundledWebSearchPluginIds,
} from "./bundled-web-search.js";
import {
normalizePluginsConfig,
resolveEffectiveEnableState,
type NormalizedPluginsConfig,
} from "./config-state.js";
import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js";
import { createPluginLoaderLogger } from "./logger.js";
import { getActivePluginRegistry } from "./runtime.js";
import { listBundledWebSearchProviders as listBundledWebSearchProviderEntries } from "./bundled-web-search.js";
import { resolveEffectiveEnableState } from "./config-state.js";
import type { PluginLoadOptions } from "./loader.js";
import type { PluginWebSearchProviderEntry } from "./types.js";
const log = createSubsystemLogger("plugins");
function hasExplicitPluginConfig(config: PluginLoadOptions["config"]): boolean {
const plugins = config?.plugins;
if (!plugins) {
return false;
}
if (typeof plugins.enabled === "boolean") {
return true;
}
if (Array.isArray(plugins.allow) && plugins.allow.length > 0) {
return true;
}
if (Array.isArray(plugins.deny) && plugins.deny.length > 0) {
return true;
}
if (Array.isArray(plugins.load?.paths) && plugins.load.paths.length > 0) {
return true;
}
if (plugins.entries && Object.keys(plugins.entries).length > 0) {
return true;
}
if (plugins.slots && Object.keys(plugins.slots).length > 0) {
return true;
}
return false;
}
function resolveBundledWebSearchCompatPluginIds(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): string[] {
return resolveBundledWebSearchPluginIds({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
}
function withBundledWebSearchVitestCompat(params: {
config: PluginLoadOptions["config"];
pluginIds: readonly string[];
env?: PluginLoadOptions["env"];
}): PluginLoadOptions["config"] {
const env = params.env ?? process.env;
const isVitest = Boolean(env.VITEST || process.env.VITEST);
if (!isVitest || hasExplicitPluginConfig(params.config) || params.pluginIds.length === 0) {
return params.config;
}
return {
...params.config,
plugins: {
...params.config?.plugins,
enabled: true,
allow: [...params.pluginIds],
slots: {
...params.config?.plugins?.slots,
memory: "none",
},
},
};
}
function sortWebSearchProviders(
providers: PluginWebSearchProviderEntry[],
): PluginWebSearchProviderEntry[] {
return providers.toSorted((a, b) => {
const aOrder = a.autoDetectOrder ?? Number.MAX_SAFE_INTEGER;
const bOrder = b.autoDetectOrder ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
return a.id.localeCompare(b.id);
});
}
function resolveBundledWebSearchResolutionConfig(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
bundledAllowlistCompat?: boolean;
}): {
config: PluginLoadOptions["config"];
normalized: NormalizedPluginsConfig;
} {
const bundledCompatPluginIds = resolveBundledWebSearchCompatPluginIds({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const allowlistCompat = params.bundledAllowlistCompat
? withBundledPluginAllowlistCompat({
config: params.config,
pluginIds: bundledCompatPluginIds,
})
: params.config;
const enablementCompat = withBundledPluginEnablementCompat({
config: allowlistCompat,
pluginIds: bundledCompatPluginIds,
});
const config = withBundledWebSearchVitestCompat({
config: enablementCompat,
pluginIds: bundledCompatPluginIds,
env: params.env,
});
return {
config,
normalized: normalizePluginsConfig(config?.plugins),
};
}
import {
resolveBundledWebSearchResolutionConfig,
sortWebSearchProviders,
} from "./web-search-providers.shared.js";
function listBundledWebSearchProviders(): PluginWebSearchProviderEntry[] {
return sortWebSearchProviders(listBundledWebSearchProviderEntries());
@ -158,47 +34,3 @@ export function resolveBundledPluginWebSearchProviders(params: {
}).enabled;
});
}
export function resolvePluginWebSearchProviders(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
bundledAllowlistCompat?: boolean;
activate?: boolean;
cache?: boolean;
}): PluginWebSearchProviderEntry[] {
const { config } = resolveBundledWebSearchResolutionConfig(params);
const registry = loadOpenClawPlugins({
config,
workspaceDir: params.workspaceDir,
env: params.env,
cache: params.cache ?? false,
activate: params.activate ?? false,
logger: createPluginLoaderLogger(log),
});
return sortWebSearchProviders(
registry.webSearchProviders.map((entry) => ({
...entry.provider,
pluginId: entry.pluginId,
})),
);
}
export function resolveRuntimeWebSearchProviders(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
bundledAllowlistCompat?: boolean;
}): PluginWebSearchProviderEntry[] {
const runtimeProviders = getActivePluginRegistry()?.webSearchProviders ?? [];
if (runtimeProviders.length > 0) {
return sortWebSearchProviders(
runtimeProviders.map((entry) => ({
...entry.provider,
pluginId: entry.pluginId,
})),
);
}
return resolvePluginWebSearchProviders(params);
}

View File

@ -124,6 +124,16 @@ describe("detectChangedScope", () => {
});
});
it("runs Python skill tests when shared Python config changes", () => {
expect(detectChangedScope(["pyproject.toml"])).toEqual({
runNode: true,
runMacos: false,
runAndroid: false,
runWindows: false,
runSkillsPython: true,
});
});
it("runs platform lanes when the CI workflow changes", () => {
expect(detectChangedScope([".github/workflows/ci.yml"])).toEqual({
runNode: true,

View File

@ -1,7 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { PluginWebSearchProviderEntry } from "../plugins/types.js";
import * as webSearchProviders from "../plugins/web-search-providers.js";
import * as bundledWebSearchProviders from "../plugins/web-search-providers.js";
import * as runtimeWebSearchProviders from "../plugins/web-search-providers.runtime.js";
import * as secretResolve from "./resolve.js";
import { createResolverContext } from "./runtime-shared.js";
import { resolveRuntimeWebTools } from "./runtime-web-tools.js";
@ -18,6 +19,9 @@ const { resolveBundledPluginWebSearchProvidersMock } = vi.hoisted(() => ({
vi.mock("../plugins/web-search-providers.js", () => ({
resolveBundledPluginWebSearchProviders: resolveBundledPluginWebSearchProvidersMock,
}));
vi.mock("../plugins/web-search-providers.runtime.js", () => ({
resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock,
}));
@ -181,8 +185,8 @@ function expectInactiveFirecrawlSecretRef(params: {
describe("runtime web tools resolution", () => {
beforeEach(() => {
vi.mocked(webSearchProviders.resolvePluginWebSearchProviders).mockClear();
vi.mocked(webSearchProviders.resolveBundledPluginWebSearchProviders).mockClear();
vi.mocked(bundledWebSearchProviders.resolveBundledPluginWebSearchProviders).mockClear();
vi.mocked(runtimeWebSearchProviders.resolvePluginWebSearchProviders).mockClear();
});
afterEach(() => {
@ -190,7 +194,7 @@ describe("runtime web tools resolution", () => {
});
it("skips loading web search providers when search config is absent", async () => {
const providerSpy = vi.mocked(webSearchProviders.resolvePluginWebSearchProviders);
const providerSpy = vi.mocked(runtimeWebSearchProviders.resolvePluginWebSearchProviders);
const { metadata } = await runRuntimeWebTools({
config: asConfig({
@ -538,8 +542,8 @@ describe("runtime web tools resolution", () => {
});
it("uses bundled provider resolution for configured bundled providers", async () => {
const bundledSpy = vi.mocked(webSearchProviders.resolveBundledPluginWebSearchProviders);
const genericSpy = vi.mocked(webSearchProviders.resolvePluginWebSearchProviders);
const bundledSpy = vi.mocked(bundledWebSearchProviders.resolveBundledPluginWebSearchProviders);
const genericSpy = vi.mocked(runtimeWebSearchProviders.resolvePluginWebSearchProviders);
const { metadata } = await runRuntimeWebTools({
config: asConfig({

View File

@ -8,10 +8,8 @@ import type {
PluginWebSearchProviderEntry,
WebSearchCredentialResolutionSource,
} from "../plugins/types.js";
import {
resolveBundledPluginWebSearchProviders,
resolvePluginWebSearchProviders,
} from "../plugins/web-search-providers.js";
import { resolveBundledPluginWebSearchProviders } from "../plugins/web-search-providers.js";
import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.runtime.js";
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
import { secretRefKey } from "./ref-contract.js";
import { resolveSecretRefValues } from "./resolve.js";

View File

@ -16,11 +16,14 @@ const { resolveBundledPluginWebSearchProvidersMock, resolvePluginWebSearchProvid
vi.mock("../plugins/web-search-providers.js", () => ({
resolveBundledPluginWebSearchProviders: resolveBundledPluginWebSearchProvidersMock,
}));
vi.mock("../plugins/web-search-providers.runtime.js", () => ({
resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock,
}));
function createTestProvider(params: {
id: "brave" | "gemini" | "grok" | "kimi" | "perplexity" | "firecrawl";
id: "brave" | "gemini" | "grok" | "kimi" | "perplexity" | "firecrawl" | "tavily";
pluginId: string;
order: number;
}): PluginWebSearchProviderEntry {
@ -80,6 +83,7 @@ function buildTestWebSearchProviders(): PluginWebSearchProviderEntry[] {
createTestProvider({ id: "kimi", pluginId: "moonshot", order: 40 }),
createTestProvider({ id: "perplexity", pluginId: "perplexity", order: 50 }),
createTestProvider({ id: "firecrawl", pluginId: "firecrawl", order: 60 }),
createTestProvider({ id: "tavily", pluginId: "tavily", order: 70 }),
];
}
@ -194,6 +198,9 @@ function buildConfigForOpenClawTarget(entry: SecretRegistryEntry, envId: string)
if (entry.id === "plugins.entries.firecrawl.config.webSearch.apiKey") {
setPathCreateStrict(config, ["tools", "web", "search", "provider"], "firecrawl");
}
if (entry.id === "plugins.entries.tavily.config.webSearch.apiKey") {
setPathCreateStrict(config, ["tools", "web", "search", "provider"], "tavily");
}
return config;
}

View File

@ -22,6 +22,9 @@ const { resolveBundledPluginWebSearchProvidersMock, resolvePluginWebSearchProvid
vi.mock("../plugins/web-search-providers.js", () => ({
resolveBundledPluginWebSearchProviders: resolveBundledPluginWebSearchProvidersMock,
}));
vi.mock("../plugins/web-search-providers.runtime.js", () => ({
resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock,
}));

View File

@ -0,0 +1,188 @@
const ERROR_PAYLOAD_PREFIX_RE =
/^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|apierror|openai\s*error|anthropic\s*error|gateway\s*error)(?:\s+\d{3})?[:\s-]+/i;
const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i;
const HTTP_STATUS_CODE_PREFIX_RE = /^(?:http\s*)?(\d{3})(?:\s+([\s\S]+))?$/i;
const HTML_ERROR_PREFIX_RE = /^\s*(?:<!doctype\s+html\b|<html\b)/i;
const CLOUDFLARE_HTML_ERROR_CODES = new Set([521, 522, 523, 524, 525, 526, 530]);
type ErrorPayload = Record<string, unknown>;
export type ApiErrorInfo = {
httpCode?: string;
type?: string;
message?: string;
requestId?: string;
};
function isErrorPayloadObject(payload: unknown): payload is ErrorPayload {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return false;
}
const record = payload as ErrorPayload;
if (record.type === "error") {
return true;
}
if (typeof record.request_id === "string" || typeof record.requestId === "string") {
return true;
}
if ("error" in record) {
const err = record.error;
if (err && typeof err === "object" && !Array.isArray(err)) {
const errRecord = err as ErrorPayload;
if (
typeof errRecord.message === "string" ||
typeof errRecord.type === "string" ||
typeof errRecord.code === "string"
) {
return true;
}
}
}
return false;
}
function parseApiErrorPayload(raw: string): ErrorPayload | null {
if (!raw) {
return null;
}
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
const candidates = [trimmed];
if (ERROR_PAYLOAD_PREFIX_RE.test(trimmed)) {
candidates.push(trimmed.replace(ERROR_PAYLOAD_PREFIX_RE, "").trim());
}
for (const candidate of candidates) {
if (!candidate.startsWith("{") || !candidate.endsWith("}")) {
continue;
}
try {
const parsed = JSON.parse(candidate) as unknown;
if (isErrorPayloadObject(parsed)) {
return parsed;
}
} catch {
// ignore parse errors
}
}
return null;
}
export function extractLeadingHttpStatus(raw: string): { code: number; rest: string } | null {
const match = raw.match(HTTP_STATUS_CODE_PREFIX_RE);
if (!match) {
return null;
}
const code = Number(match[1]);
if (!Number.isFinite(code)) {
return null;
}
return { code, rest: (match[2] ?? "").trim() };
}
export function isCloudflareOrHtmlErrorPage(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
const status = extractLeadingHttpStatus(trimmed);
if (!status || status.code < 500) {
return false;
}
if (CLOUDFLARE_HTML_ERROR_CODES.has(status.code)) {
return true;
}
return (
status.code < 600 && HTML_ERROR_PREFIX_RE.test(status.rest) && /<\/html>/i.test(status.rest)
);
}
export function parseApiErrorInfo(raw?: string): ApiErrorInfo | null {
if (!raw) {
return null;
}
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
let httpCode: string | undefined;
let candidate = trimmed;
const httpPrefixMatch = candidate.match(/^(\d{3})\s+(.+)$/s);
if (httpPrefixMatch) {
httpCode = httpPrefixMatch[1];
candidate = httpPrefixMatch[2].trim();
}
const payload = parseApiErrorPayload(candidate);
if (!payload) {
return null;
}
const requestId =
typeof payload.request_id === "string"
? payload.request_id
: typeof payload.requestId === "string"
? payload.requestId
: undefined;
const topType = typeof payload.type === "string" ? payload.type : undefined;
const topMessage = typeof payload.message === "string" ? payload.message : undefined;
let errType: string | undefined;
let errMessage: string | undefined;
if (payload.error && typeof payload.error === "object" && !Array.isArray(payload.error)) {
const err = payload.error as Record<string, unknown>;
if (typeof err.type === "string") {
errType = err.type;
}
if (typeof err.code === "string" && !errType) {
errType = err.code;
}
if (typeof err.message === "string") {
errMessage = err.message;
}
}
return {
httpCode,
type: errType ?? topType,
message: errMessage ?? topMessage,
requestId,
};
}
export function formatRawAssistantErrorForUi(raw?: string): string {
const trimmed = (raw ?? "").trim();
if (!trimmed) {
return "LLM request failed with an unknown error.";
}
const leadingStatus = extractLeadingHttpStatus(trimmed);
if (leadingStatus && isCloudflareOrHtmlErrorPage(trimmed)) {
return `The AI service is temporarily unavailable (HTTP ${leadingStatus.code}). Please try again in a moment.`;
}
const httpMatch = trimmed.match(HTTP_STATUS_PREFIX_RE);
if (httpMatch) {
const rest = httpMatch[2].trim();
if (!rest.startsWith("{")) {
return `HTTP ${httpMatch[1]}: ${rest}`;
}
}
const info = parseApiErrorInfo(trimmed);
if (info?.message) {
const prefix = info.httpCode ? `HTTP ${info.httpCode}` : "LLM error";
const type = info.type ? ` ${info.type}` : "";
const requestId = info.requestId ? ` (request_id: ${info.requestId})` : "";
return `${prefix}${type}: ${info.message}${requestId}`;
}
return trimmed.length > 600 ? `${trimmed.slice(0, 600)}` : trimmed;
}

View File

@ -1,5 +1,5 @@
import { formatRawAssistantErrorForUi } from "../agents/pi-embedded-helpers.js";
import { stripLeadingInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js";
import { formatRawAssistantErrorForUi } from "../shared/assistant-error-format.js";
import { stripAnsi } from "../terminal/ansi.js";
import { formatTokenCount } from "../utils/usage-format.js";

Some files were not shown because too many files have changed in this diff Show More