Compare commits

..

23 Commits

Author SHA1 Message Date
Vincent Koc
d93091dad7 Exec: restore approval-first host defaults 2026-04-25 11:28:35 -07:00
Peter Steinberger
316d10637b refactor: canonicalize legacy x search secret target coverage 2026-04-02 15:30:05 +01:00
Peter Steinberger
65c1716ad4 refactor(infra): clarify jsonl socket contract 2026-04-02 15:20:37 +01:00
Peter Steinberger
ef86edacf7 fix: harden plugin auto-enable empty config handling 2026-04-02 15:19:53 +01:00
wangchunyue
b40ef364b7 fix: pin admin-only subagent gateway scopes (#59555) (thanks @openperf)
* fix(agents): pin subagent gateway calls to admin scope to prevent scope-upgrade pairing failures

callSubagentGateway forwards params to callGateway without explicit scopes,
so callGatewayLeastPrivilege negotiates the minimum scope per method
independently.  The first connection pairs the device at a lower tier and
every subsequent higher-tier call triggers a scope-upgrade handshake that
headless gateway-client connections cannot complete interactively
(close 1008 "pairing required").

Pin callSubagentGateway to operator.admin so the device is paired at the
ceiling scope on the very first (silent, local-loopback) handshake, avoiding
any subsequent scope-upgrade negotiation entirely.

Fixes #59428

* fix: pin admin-only subagent gateway scopes (#59555) (thanks @openperf)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-02 19:40:03 +05:30
Vincent Koc
4f692190b4 fix(config): tolerate missing facade boundary config 2026-04-02 23:04:53 +09:00
skernelx
e0d20966ae Refine JSONL socket EOF regression test 2026-04-02 23:03:36 +09:00
skernelx
0e3cc12900 Fix macOS exec-host JSONL socket deadlock 2026-04-02 23:03:36 +09:00
Peter Steinberger
c4fb15e492 test: make web fetch runtime env handling hermetic 2026-04-02 15:02:40 +01:00
jacky
ecf72319ed fix: use JSON5 parser for plugin manifest loading (#57734) [AI-assisted] (#59084)
Merged via squash.

Prepared head SHA: 58a4d537fc
Co-authored-by: singleGanghood <179357632+singleGanghood@users.noreply.github.com>
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Reviewed-by: @hxy91819
2026-04-02 22:02:04 +08:00
Vincent Koc
bb3f17fc02 refactor(plugins): drop generic status report alias (#59700) 2026-04-02 22:59:25 +09:00
Vincent Koc
b0f94a227b refactor(providers): normalize transport policy wiring (#59682)
* refactor(providers): normalize transport policy wiring

* fix(providers): address transport policy review

* fix(providers): harden transport overrides

* fix(providers): keep env proxy tls separate

* fix(changelog): note provider transport policy hardening
2026-04-02 22:54:34 +09:00
Peter Steinberger
4269f40811 docs(security): clarify exec yolo default 2026-04-02 14:52:51 +01:00
Peter Steinberger
c678ae7e7a feat(exec): default host exec to yolo 2026-04-02 14:52:51 +01:00
Vincent Koc
0500b410c5 docs: update config paths for Firecrawl web_fetch and xAI x_search migrations, add Android assistant section, backfill PR numbers 2026-04-02 22:52:00 +09:00
Vincent Koc
def5b954a8 feat(plugins): surface imported runtime state in status tooling (#59659)
* feat(plugins): surface imported runtime state

* fix(plugins): keep status imports snapshot-only

* fix(plugins): keep status snapshots manifest-only

* fix(plugins): restore doctor load checks

* refactor(plugins): split snapshot and diagnostics reports

* fix(plugins): track imported erroring modules

* fix(plugins): keep hot metadata where required

* fix(plugins): keep hot doctor and write targeting

* fix(plugins): track throwing module imports
2026-04-02 22:50:17 +09:00
Peter Steinberger
1ecd92af89 chore: refresh deps and backfill changelog 2026-04-02 14:49:47 +01:00
Ayaan Zaidi
a1f95e5278 fix: land Android assistant entrypoints (#59596) 2026-04-02 19:16:34 +05:30
Ayaan Zaidi
41b81ca7f8 fix: address Android assistant review feedback 2026-04-02 19:16:34 +05:30
Ayaan Zaidi
59eccef768 feat: add Google Assistant App Actions entrypoint 2026-04-02 19:16:34 +05:30
Ayaan Zaidi
e45b29b247 feat: add Android assistant role entrypoint 2026-04-02 19:16:34 +05:30
Ayaan Zaidi
fcf708665c feat: route Android assistant launches into chat 2026-04-02 19:16:34 +05:30
Agustin Rivera
290e5bf219 fix(dotenv): block helper interpreter workspace overrides (#58473)
* fix(dotenv): block helper interpreter workspace overrides

* fix(dotenv): cover trusted helper interpreter envs

* fix(changelog): note dotenv helper override hardening

* fix(changelog): remove dotenv entry from pr

* changelog: note dotenv helper override hardening

---------

Co-authored-by: Jacob Tomlinson <jtomlinson@nvidia.com>
2026-04-02 06:45:13 -07:00
95 changed files with 2736 additions and 532 deletions

View File

@@ -6,11 +6,16 @@ Docs: https://docs.openclaw.ai
### Breaking
- Plugins/web fetch: move Firecrawl `web_fetch` config from the legacy core `tools.web.fetch.firecrawl.*` path to the plugin-owned `plugins.entries.firecrawl.config.webFetch.*` path, route `web_fetch` fallback through the new fetch-provider boundary instead of a Firecrawl-only core branch, and migrate legacy config with `openclaw doctor --fix`. Thanks @vincentkoc.
- Plugins/xAI: move `x_search` settings from the legacy core `tools.web.x_search.*` path to the plugin-owned `plugins.entries.xai.config.xSearch.*` path, standardize `x_search` auth on `plugins.entries.xai.config.webSearch.apiKey` / `XAI_API_KEY`, and migrate legacy config with `openclaw doctor --fix`. Thanks @vincentkoc.
- Plugins/web fetch: move Firecrawl `web_fetch` config from the legacy core `tools.web.fetch.firecrawl.*` path to the plugin-owned `plugins.entries.firecrawl.config.webFetch.*` path, route `web_fetch` fallback through the new fetch-provider boundary instead of a Firecrawl-only core branch, and migrate legacy config with `openclaw doctor --fix`. (#59465) Thanks @vincentkoc.
- Plugins/xAI: move `x_search` settings from the legacy core `tools.web.x_search.*` path to the plugin-owned `plugins.entries.xai.config.xSearch.*` path, standardize `x_search` auth on `plugins.entries.xai.config.webSearch.apiKey` / `XAI_API_KEY`, and migrate legacy config with `openclaw doctor --fix`. (#59674) Thanks @vincentkoc.
### Changes
- Exec defaults: make gateway/node host exec default to YOLO mode by requesting `security=full` with `ask=off`, and align host approval-file fallbacks plus docs/doctor reporting with that no-prompt default.
- Agents/compaction: add `agents.defaults.compaction.notifyUser` so the `🧹 Compacting context...` start notice is opt-in instead of always being shown. (#54251) Thanks @oguricap0327.
- Plugins/hooks: add `before_agent_reply` so plugins can short-circuit the LLM with synthetic replies after inline actions. (#20067) Thanks @JoshuaLelon
- Providers/runtime: add provider-owned replay hook surfaces for transcript policy, replay cleanup, and reasoning-mode dispatch. (#59143) Thanks @jalehman.
- Diffs: add plugin-owned `viewerBaseUrl` so viewer links can use a stable proxy/public origin without passing `baseUrl` on every tool call. (#59341) Related #59227. Thanks @gumadeiras.
- Matrix/plugin: emit spec-compliant `m.mentions` metadata across text sends, media captions, edits, poll fallback text, and action-driven edits so Matrix mentions notify reliably in clients like Element. (#59323) Thanks @gumadeiras.
- Feishu/comments: add a dedicated Drive comment-event flow with comment-thread context resolution, in-thread replies, and `feishu_drive` comment actions for document collaboration workflows. (#58497) thanks @wittam-01.
- WhatsApp/reactions: add `reactionLevel` guidance for agent reactions. Thanks @mcaxtr.
@@ -23,6 +28,7 @@ Docs: https://docs.openclaw.ai
- Tasks/TaskFlow: restore the core TaskFlow substrate with managed-vs-mirrored sync modes, durable flow state/revision tracking, and `openclaw flows` inspection/recovery primitives so background orchestration can persist and be operated separately from plugin authoring layers. (#58930) Thanks @mbelinky.
- Tasks/TaskFlow: add managed child task spawning plus sticky cancel intent, so external orchestrators can stop scheduling immediately and let parent TaskFlows settle to `cancelled` once active child tasks finish. (#59610) Thanks @mbelinky.
- Plugins/TaskFlow: add a bound `api.runtime.taskFlow` seam so plugins and trusted authoring layers can create and drive managed TaskFlows from host-resolved OpenClaw context without passing owner identifiers on each call. (#59622) Thanks @mbelinky.
- Android/assistant: add assistant-role entrypoints plus Google Assistant App Actions metadata so Android can launch OpenClaw from the assistant trigger and hand prompts into the chat composer. (#59596) Thanks @obviyus.
### Fixes
@@ -37,6 +43,10 @@ Docs: https://docs.openclaw.ai
- Mattermost/probes: route status probes through the SSRF guard and honor `allowPrivateNetwork` so connectivity checks stay safe for self-hosted Mattermost deployments. (#58529) Thanks @mappel-nv.
- Zalo/webhook replay: scope replay dedupe key by chat and sender so reused message IDs across different chats or senders no longer collide, and harden metadata reads for partially missing payloads. (#58444)
- QQBot/structured payloads: restrict local file paths to QQ Bot-owned media storage, block traversal outside that root, reduce path leakage in logs, and keep inline image data URLs working. (#58453) Thanks @jacobtomlinson.
- Providers/streaming headers: centralize default and attribution header merging across OpenAI websocket, embedded-runner, and proxy stream paths so provider-specific headers stay consistent and caller overrides only win where intended. (#59542) Thanks @vincentkoc.
- Providers/Anthropic routing: centralize native-vs-proxy endpoint classification for direct Anthropic `service_tier` handling so spoofed or proxied hosts do not inherit native Anthropic defaults. (#59608) Thanks @vincentkoc.
- Providers/transport policy: centralize request auth, proxy, TLS, and header shaping across shared HTTP, stream, and websocket paths, block insecure TLS/runtime transport overrides, and keep proxy-hop TLS separate from target mTLS settings. (#59682) Thanks @vincentkoc.
- Browser/host inspection: keep static Chrome inspection helpers out of the activated browser runtime so `openclaw doctor browser` and related checks do not eagerly load the bundled browser plugin. (#59471) Thanks @vincentkoc.
- Gateway/exec loopback: restore legacy-role fallback for empty paired-device token maps and allow silent local role upgrades so local exec and node clients stop failing with pairing-required errors after `2026.3.31`. (#59092) Thanks @openperf.
- Agents/output sanitization: strip namespaced `antml:thinking` blocks from user-visible text so Anthropic-style internal monologue tags do not leak into replies. (#59550) Thanks @obviyus.
- Kimi Coding/tools: normalize Anthropic tool payloads into the OpenAI-compatible function shape Kimi Coding expects so tool calls stop losing required arguments. (#59440) Thanks @obviyus.
@@ -49,16 +59,22 @@ Docs: https://docs.openclaw.ai
- Providers/media HTTP: centralize base URL normalization, default auth/header injection, and explicit header override handling across shared OpenAI-compatible audio, Deepgram audio, Gemini media/image, and Moonshot video request paths. (#59469) Thanks @vincentkoc.
- Providers/streaming headers: centralize default and attribution header merging across OpenAI websocket, embedded-runner, and proxy stream paths so provider-specific headers stay consistent and caller overrides only win where intended. (#59542) Thanks @vincentkoc.
- Providers/Anthropic routing: centralize native-vs-proxy endpoint classification for direct Anthropic `service_tier` handling so spoofed or proxied hosts do not inherit native Anthropic defaults. (#59608) Thanks @vincentkoc.
- Providers/Copilot: classify native GitHub Copilot API hosts in the shared provider endpoint resolver and harden token-derived proxy endpoint parsing so Copilot base URL routing stays centralized and fails closed on malformed hints. Thanks @vincentkoc.
- Providers/Copilot: classify native GitHub Copilot API hosts in the shared provider endpoint resolver and harden token-derived proxy endpoint parsing so Copilot base URL routing stays centralized and fails closed on malformed hints. (#59644) Thanks @vincentkoc.
- ACP/gateway reconnects: keep ACP prompts alive across transient websocket drops while still failing boundedly when reconnect recovery does not complete. (#59473) Thanks @obviyus.
- ACP/gateway reconnects: reject stale pre-ack ACP prompts after reconnect grace expiry so callers fail cleanly instead of hanging indefinitely when the gateway never confirms the run.
- Exec approvals/doctor: report host policy sources from the real approvals file path and ignore malformed host override values when attributing effective policy conflicts. (#59367) Thanks @gumadeiras.
- Agents/subagents: pin admin-only subagent gateway calls to `operator.admin` while keeping `agent` at least privilege, so `sessions_spawn` no longer dies on loopback scope-upgrade pairing with `close(1008) "pairing required"`. (#59555) Thanks @openperf.
- Exec approvals/config: strip invalid `security`, `ask`, and `askFallback` values from `~/.openclaw/exec-approvals.json` during normalization so malformed policy enums fall back cleanly to the documented defaults instead of corrupting runtime policy resolution. (#59112) Thanks @openperf.
- Gateway/session kill: enforce HTTP operator scopes on session kill requests and gate authorization before session lookup so unauthenticated callers cannot probe session existence. (#59128) Thanks @jacobtomlinson.
- MS Teams/logging: format non-`Error` failures with the shared unknown-error helper so logs stop collapsing caught SDK or Axios objects into `[object Object]`. (#59321) Thanks @bradgroux.
- Gateway: prune empty `node-pending-work` state entries after explicit acknowledgments and natural expiry so the per-node state map no longer grows indefinitely. (#58179) Thanks @gavyngong.
- Webhooks/secret comparison: replace ad-hoc timing-safe secret comparisons across BlueBubbles, Feishu, Mattermost, Telegram, Twilio, and Zalo webhook handlers with the shared `safeEqualSecret` helper and reject empty auth tokens in BlueBubbles. Thanks @eleqtrizit.
- Webhooks/secret comparison: replace ad-hoc timing-safe secret comparisons across BlueBubbles, Feishu, Mattermost, Telegram, Twilio, and Zalo webhook handlers with the shared `safeEqualSecret` helper and reject empty auth tokens in BlueBubbles. (#58432) Thanks @eleqtrizit.
- OpenShell/mirror: constrain `remoteWorkspaceDir` and `remoteAgentWorkspaceDir` to the managed `/sandbox` and `/agent` roots so mirror sync cannot escape the intended remote workspace paths. (#58515) Thanks @eleqtrizit.
- Plugins/activation: preserve explicit, auto-enabled, and default activation provenance plus reason metadata across CLI, gateway bootstrap, and status surfaces so plugin enablement state stays accurate after auto-enable resolution. (#59641) Thanks @vincentkoc.
- Exec/env: block additional host environment override pivots for package roots, language runtimes, compiler include paths, and credential/config locations so request-scoped exec cannot redirect trusted toolchains or config lookups. (#59233) Thanks @drobison00.
- OpenShell/mirror sync: constrain mirror sync to managed roots only so user-added shell roots are no longer overwritten or removed during config synchronization. (#58515) Thanks @eleqtrizit.
- Dotenv/workspace overrides: block workspace `.env` files from overriding `OPENCLAW_PINNED_PYTHON` and `OPENCLAW_PINNED_WRITE_PYTHON` so trusted helper interpreters cannot be redirected by repo-local env injection. (#58473) Thanks @eleqtrizit.
- Plugins/install: accept JSON5 syntax in `openclaw.plugin.json` and bundle `plugin.json` manifests during install/validation, so third-party plugins with trailing commas, comments, or unquoted keys no longer fail to install. (#59084) Thanks @singleGanghood.
## 2026.4.1-beta.1

View File

@@ -76,10 +76,17 @@
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|density|keyboard|keyboardHidden|navigation">
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.ASSIST" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,40 @@
package ai.openclaw.app
import android.content.Intent
const val actionAskOpenClaw = "ai.openclaw.app.action.ASK_OPENCLAW"
const val extraAssistantPrompt = "prompt"
enum class HomeDestination {
Connect,
Chat,
Voice,
Screen,
Settings,
}
data class AssistantLaunchRequest(
val source: String,
val prompt: String?,
)
fun parseAssistantLaunchIntent(intent: Intent?): AssistantLaunchRequest? {
val action = intent?.action ?: return null
return when (action) {
Intent.ACTION_ASSIST ->
AssistantLaunchRequest(
source = "assist",
prompt = null,
)
actionAskOpenClaw -> {
val prompt = intent.getStringExtra(extraAssistantPrompt)?.trim()?.ifEmpty { null }
AssistantLaunchRequest(
source = "app_action",
prompt = prompt,
)
}
else -> null
}
}

View File

@@ -23,6 +23,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleAssistantIntent(intent)
WindowCompat.setDecorFitsSystemWindows(window, false)
permissionRequester = PermissionRequester(this)
@@ -70,4 +71,15 @@ class MainActivity : ComponentActivity() {
viewModel.setForeground(false)
super.onStop()
}
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleAssistantIntent(intent)
}
private fun handleAssistantIntent(intent: android.content.Intent?) {
val request = parseAssistantLaunchIntent(intent) ?: return
viewModel.handleAssistantLaunch(request)
}
}

View File

@@ -27,6 +27,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
private val prefs = nodeApp.prefs
private val runtimeRef = MutableStateFlow<NodeRuntime?>(null)
private var foreground = true
private val _requestedHomeDestination = MutableStateFlow<HomeDestination?>(null)
val requestedHomeDestination: StateFlow<HomeDestination?> = _requestedHomeDestination
private val _chatDraft = MutableStateFlow<String?>(null)
val chatDraft: StateFlow<String?> = _chatDraft
private fun ensureRuntime(): NodeRuntime {
runtimeRef.value?.let { return it }
@@ -246,6 +250,19 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
ensureRuntime().setVoiceScreenActive(active)
}
fun handleAssistantLaunch(request: AssistantLaunchRequest) {
_requestedHomeDestination.value = HomeDestination.Chat
_chatDraft.value = request.prompt
}
fun clearRequestedHomeDestination() {
_requestedHomeDestination.value = null
}
fun clearChatDraft() {
_chatDraft.value = null
}
fun setMicEnabled(enabled: Boolean) {
ensureRuntime().setMicEnabled(enabled)
}

View File

@@ -46,6 +46,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import ai.openclaw.app.HomeDestination
import ai.openclaw.app.MainViewModel
private enum class HomeTab(
@@ -72,6 +73,20 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier)
var activeTab by rememberSaveable { mutableStateOf(HomeTab.Connect) }
var chatTabStarted by rememberSaveable { mutableStateOf(false) }
var screenTabStarted by rememberSaveable { mutableStateOf(false) }
val requestedHomeDestination by viewModel.requestedHomeDestination.collectAsState()
LaunchedEffect(requestedHomeDestination) {
val destination = requestedHomeDestination ?: return@LaunchedEffect
activeTab =
when (destination) {
HomeDestination.Connect -> HomeTab.Connect
HomeDestination.Chat -> HomeTab.Chat
HomeDestination.Voice -> HomeTab.Voice
HomeDestination.Screen -> HomeTab.Screen
HomeDestination.Settings -> HomeTab.Settings
}
viewModel.clearRequestedHomeDestination()
}
// Stop TTS when user navigates away from voice tab, and lazily keep the Chat/Screen tabs
// alive after the first visit so repeated tab switches do not rebuild their UI trees.

View File

@@ -9,6 +9,7 @@ import android.hardware.SensorManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.app.role.RoleManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
@@ -150,6 +151,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
versionName
}
}
var assistantRoleAvailable by remember(context) { mutableStateOf(isAssistantRoleAvailable(context)) }
var assistantRoleHeld by remember(context) { mutableStateOf(isAssistantRoleHeld(context)) }
val listItemColors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
@@ -326,6 +329,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
viewModel.refreshGatewayConnection()
}
val assistantRoleLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
assistantRoleAvailable = isAssistantRoleAvailable(context)
assistantRoleHeld = isAssistantRoleHeld(context)
}
DisposableEffect(lifecycleOwner, context) {
val observer =
LifecycleEventObserver { _, event ->
@@ -362,6 +371,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
PackageManager.PERMISSION_GRANTED
assistantRoleAvailable = isAssistantRoleAvailable(context)
assistantRoleHeld = isAssistantRoleHeld(context)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
@@ -478,6 +489,42 @@ fun SettingsSheet(viewModel: MainViewModel) {
color = mobileTextTertiary,
)
}
if (assistantRoleAvailable) {
HorizontalDivider(color = mobileBorder)
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Default Assistant", style = mobileHeadline) },
supportingContent = {
Text(
if (assistantRoleHeld) {
"OpenClaw is registered as the device assistant."
} else {
"Let Android launch OpenClaw from the assistant gesture. Google Assistant App Actions still work separately."
},
style = mobileCallout,
)
},
trailingContent = {
Button(
onClick = {
assistantRoleLauncher.launch(
context
.getSystemService(RoleManager::class.java)
.createRequestRoleIntent(RoleManager.ROLE_ASSISTANT),
)
},
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text(
if (assistantRoleHeld) "Manage" else "Enable",
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
)
}
},
)
}
}
}
@@ -1294,3 +1341,11 @@ private fun hasMotionCapabilities(context: Context): Boolean {
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
}
private fun isAssistantRoleAvailable(context: Context): Boolean {
return context.getSystemService(RoleManager::class.java).isRoleAvailable(RoleManager.ROLE_ASSISTANT)
}
private fun isAssistantRoleHeld(context: Context): Boolean {
return context.getSystemService(RoleManager::class.java).isRoleHeld(RoleManager.ROLE_ASSISTANT)
}

View File

@@ -33,6 +33,7 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -59,12 +60,45 @@ import ai.openclaw.app.ui.mobileText
import ai.openclaw.app.ui.mobileTextSecondary
import ai.openclaw.app.ui.mobileTextTertiary
internal data class DraftApplication(
val input: String,
val lastAppliedDraft: String?,
val consumed: Boolean,
)
internal fun applyDraftText(
draftText: String?,
currentInput: String,
lastAppliedDraft: String?,
): DraftApplication {
val draft =
draftText?.trim()?.ifEmpty { null } ?: return DraftApplication(
input = currentInput,
lastAppliedDraft = null,
consumed = false,
)
if (draft == lastAppliedDraft) {
return DraftApplication(
input = currentInput,
lastAppliedDraft = lastAppliedDraft,
consumed = false,
)
}
return DraftApplication(
input = draft,
lastAppliedDraft = draft,
consumed = true,
)
}
@Composable
fun ChatComposer(
draftText: String?,
healthOk: Boolean,
thinkingLevel: String,
pendingRunCount: Int,
attachments: List<PendingImageAttachment>,
onDraftApplied: () -> Unit,
onPickImages: () -> Unit,
onRemoveAttachment: (id: String) -> Unit,
onSetThinkingLevel: (level: String) -> Unit,
@@ -73,8 +107,18 @@ fun ChatComposer(
onSend: (text: String) -> Unit,
) {
var input by rememberSaveable { mutableStateOf("") }
var lastAppliedDraft by rememberSaveable { mutableStateOf<String?>(null) }
var showThinkingMenu by remember { mutableStateOf(false) }
LaunchedEffect(draftText) {
val next = applyDraftText(draftText = draftText, currentInput = input, lastAppliedDraft = lastAppliedDraft)
input = next.input
lastAppliedDraft = next.lastAppliedDraft
if (next.consumed) {
onDraftApplied()
}
}
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
val sendBusy = pendingRunCount > 0

View File

@@ -60,6 +60,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val sessions by viewModel.chatSessions.collectAsState()
val chatDraft by viewModel.chatDraft.collectAsState()
LaunchedEffect(Unit) {
viewModel.loadChat(mainSessionKey)
@@ -118,10 +119,12 @@ fun ChatSheetContent(viewModel: MainViewModel) {
Row(modifier = Modifier.fillMaxWidth().imePadding()) {
ChatComposer(
draftText = chatDraft,
healthOk = healthOk,
thinkingLevel = thinkingLevel,
pendingRunCount = pendingRunCount,
attachments = attachments,
onDraftApplied = viewModel::clearChatDraft,
onPickImages = { pickImages.launch("image/*") },
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },

View File

@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>ask OpenClaw $prompt</item>
<item>tell OpenClaw to $prompt</item>
<item>open OpenClaw and ask $prompt</item>
</string-array>
</resources>

View File

@@ -0,0 +1,17 @@
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<capability
android:name="custom.actions.intent.ASK_OPENCLAW"
app:queryPatterns="@array/ask_openclaw_query_patterns">
<intent
android:action="ai.openclaw.app.action.ASK_OPENCLAW"
android:targetPackage="ai.openclaw.app"
android:targetClass="ai.openclaw.app.MainActivity">
<parameter
android:name="prompt"
android:key="prompt"
android:mimeType="text/*"
android:required="true" />
</intent>
</capability>
</shortcuts>

View File

@@ -0,0 +1,39 @@
package ai.openclaw.app
import android.content.Intent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class AssistantLaunchTest {
@Test
fun parsesAssistGestureIntent() {
val parsed = parseAssistantLaunchIntent(Intent(Intent.ACTION_ASSIST))
requireNotNull(parsed)
assertEquals("assist", parsed.source)
assertNull(parsed.prompt)
}
@Test
fun parsesAppActionPrompt() {
val parsed =
parseAssistantLaunchIntent(
Intent(actionAskOpenClaw).putExtra(extraAssistantPrompt, " summarize my unread texts "),
)
requireNotNull(parsed)
assertEquals("app_action", parsed.source)
assertEquals("summarize my unread texts", parsed.prompt)
}
@Test
fun ignoresUnrelatedIntents() {
assertNull(parseAssistantLaunchIntent(Intent(Intent.ACTION_VIEW)))
}
}

View File

@@ -0,0 +1,44 @@
package ai.openclaw.app.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatComposerDraftTest {
@Test
fun clearsLastAppliedDraftWhenViewModelDraftResets() {
val consumed =
applyDraftText(
draftText = "repeat this",
currentInput = "",
lastAppliedDraft = null,
)
assertTrue(consumed.consumed)
assertEquals("repeat this", consumed.input)
assertEquals("repeat this", consumed.lastAppliedDraft)
val cleared =
applyDraftText(
draftText = null,
currentInput = consumed.input,
lastAppliedDraft = consumed.lastAppliedDraft,
)
assertFalse(cleared.consumed)
assertEquals("repeat this", cleared.input)
assertEquals(null, cleared.lastAppliedDraft)
val repeated =
applyDraftText(
draftText = "repeat this",
currentInput = cleared.input,
lastAppliedDraft = cleared.lastAppliedDraft,
)
assertTrue(repeated.consumed)
assertEquals("repeat this", repeated.input)
assertEquals("repeat this", repeated.lastAppliedDraft)
}
}

View File

@@ -45,6 +45,48 @@ openclaw approvals set --node <id|name|ip> --file ./exec-approvals.json
openclaw approvals set --gateway --file ./exec-approvals.json
```
## "Never prompt" / YOLO example
For a host that should never stop on exec approvals, set the host approvals defaults to `full` + `off`:
```bash
openclaw approvals set --stdin <<'EOF'
{
version: 1,
defaults: {
security: "full",
ask: "off",
askFallback: "full"
}
}
EOF
```
Node variant:
```bash
openclaw approvals set --node <id|name|ip> --stdin <<'EOF'
{
version: 1,
defaults: {
security: "full",
ask: "off",
askFallback: "full"
}
}
EOF
```
This changes the **host approvals file** only. To keep the requested OpenClaw policy aligned, also set:
```bash
openclaw config set tools.exec.host gateway
openclaw config set tools.exec.security full
openclaw config set tools.exec.ask off
```
This matches the current host-default YOLO behavior. Tighten it if you want approvals.
## Allowlist helpers
```bash

View File

@@ -90,6 +90,7 @@ Treat Gateway and node as one operator trust domain, with different roles:
- A caller authenticated to the Gateway is trusted at Gateway scope. After pairing, node actions are trusted operator actions on that node.
- `sessionKey` is routing/context selection, not per-user auth.
- Exec approvals (allowlist + ask) are guardrails for operator intent, not hostile multi-tenant isolation.
- OpenClaw's product default for trusted single-operator setups is that host exec on `gateway`/`node` is allowed without approval prompts (`security="full"`, `ask="off"` unless you tighten it). That default is intentional UX, not a vulnerability by itself.
- Exec approvals bind exact request context and best-effort direct local file operands; they do not semantically model every runtime/interpreter loader path. Use sandboxing and host isolation for strong boundaries.
If you need hostile-user isolation, split trust boundaries by OS user/host and run separate gateways.
@@ -173,6 +174,7 @@ If more than one person can DM your bot:
- **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot?
- **Tool blast radius** (elevated tools + open rooms): could prompt injection turn into shell/file/network actions?
- **Exec approval drift** (`security=full`, `autoAllowSkills`, interpreter allowlists without `strictInlineEval`): are host-exec guardrails still doing what you think they are?
- `security="full"` is a broad posture warning, not proof of a bug. It is the chosen default for trusted personal-assistant setups; tighten it only when your threat model needs approval or allowlist guardrails.
- **Network exposure** (Gateway bind/auth, Tailscale Serve/Funnel, weak/short auth tokens).
- **Browser control exposure** (remote nodes, relay ports, remote CDP endpoints).
- **Local disk hygiene** (permissions, symlinks, config includes, “synced folder” paths).
@@ -375,6 +377,7 @@ If a macOS node is paired, the Gateway can invoke `system.run` on that node. Thi
- The Gateway applies a coarse global node command policy via `gateway.nodes.allowCommands` / `denyCommands`.
- Controlled on the Mac via **Settings → Exec approvals** (security + ask + allowlist).
- The per-node `system.run` policy is the node's own exec approvals file (`exec.approvals.node.*`), which can be stricter or looser than the gateway's global command-ID policy.
- A node running with `security="full"` and `ask="off"` is following the default trusted-operator model. Treat that as expected behavior unless your deployment explicitly requires a tighter approval or allowlist stance.
- Approval mode binds exact request context and, when possible, one concrete local script/file operand. If OpenClaw cannot identify exactly one direct local file for an interpreter/runtime command, approval-backed execution is denied rather than promising full semantic coverage.
- If you dont want remote execution, set security to **deny** and remove node pairing for that Mac.

View File

@@ -278,11 +278,11 @@ flowchart TD
- If `tools.exec.host` is unset, the default is `auto`.
- `host=auto` resolves to `sandbox` when a sandbox runtime is active, `gateway` otherwise.
- On `gateway` and `node`, unset `tools.exec.security` defaults to `allowlist`.
- Unset `tools.exec.ask` defaults to `on-miss`.
- Result: ordinary host commands can now pause with `Approval required` instead of running immediately.
- On `gateway` and `node`, unset `tools.exec.security` defaults to `full`.
- Unset `tools.exec.ask` defaults to `off`.
- Result: if you are seeing approvals, some host-local or per-session policy tightened exec away from the current defaults.
Restore the old gateway no-approval behavior:
Restore current default no-approval behavior:
```bash
openclaw config set tools.exec.host gateway
@@ -293,8 +293,8 @@ flowchart TD
Safer alternatives:
- Set only `tools.exec.host=gateway` if you just want stable host routing and still want approvals.
- Keep `security=allowlist` with `ask=on-miss` if you want host exec but still want review on allowlist misses.
- Set only `tools.exec.host=gateway` if you just want stable host routing.
- Use `security=allowlist` with `ask=on-miss` if you want host exec but still want review on allowlist misses.
- Enable sandbox mode if you want `host=auto` to resolve back to `sandbox`.
Common log signatures:

View File

@@ -167,6 +167,21 @@ See [Camera node](/nodes/camera) for parameters and CLI helpers.
- `sms.search`
- `motion.activity`, `motion.pedometer`
## Assistant entrypoints
Android supports launching OpenClaw from the system assistant trigger (Google
Assistant). When configured, holding the home button or saying "Hey Google, ask
OpenClaw..." opens the app and hands the prompt into the chat composer.
This uses Android **App Actions** metadata declared in the app manifest. No
extra configuration is needed on the gateway side -- the assistant intent is
handled entirely by the Android app and forwarded as a normal chat message.
<Note>
App Actions availability depends on the device, Google Play Services version,
and whether the user has set OpenClaw as the default assistant app.
</Note>
## Notification forwarding
Android can forward device notifications to the gateway as events. Several controls let you scope which notifications are forwarded and when.

View File

@@ -91,6 +91,68 @@ Example schema:
}
```
## No-approval "YOLO" mode
If you want host exec to run without approval prompts, you must open **both** policy layers:
- requested exec policy in OpenClaw config (`tools.exec.*`)
- host-local approvals policy in `~/.openclaw/exec-approvals.json`
This is now the default host behavior unless you tighten it explicitly:
- `tools.exec.security`: `full` on `gateway`/`node`
- `tools.exec.ask`: `off`
- host `askFallback`: `full`
If you want a more conservative setup, tighten either layer back to `allowlist` / `on-miss`
or `deny`.
Persistent gateway-host "never prompt" setup:
```bash
openclaw config set tools.exec.host gateway
openclaw config set tools.exec.security full
openclaw config set tools.exec.ask off
openclaw gateway restart
```
Then set the host approvals file to match:
```bash
openclaw approvals set --stdin <<'EOF'
{
version: 1,
defaults: {
security: "full",
ask: "off",
askFallback: "full"
}
}
EOF
```
For a node host, apply the same approvals file on that node instead:
```bash
openclaw approvals set --node <id|name|ip> --stdin <<'EOF'
{
version: 1,
defaults: {
security: "full",
ask: "off",
askFallback: "full"
}
}
EOF
```
Session-only shortcut:
- `/exec security=full ask=off` changes only the current session.
- `/elevated full` is a break-glass shortcut that also skips exec approvals for that session.
If the host approvals file stays stricter than config, the stricter host policy still wins.
## Policy knobs
### Security (`exec.security`)

View File

@@ -54,8 +54,9 @@ Notes:
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
- `tools.exec.approvalRunningNoticeMs` (default: 10000): emit a single “running” notice when an approval-gated exec runs longer than this (0 disables).
- `tools.exec.host` (default: `auto`; resolves to `sandbox` when sandbox runtime is active, `gateway` otherwise)
- `tools.exec.security` (default: `deny` for sandbox, `allowlist` for gateway + node when unset)
- `tools.exec.ask` (default: `on-miss`)
- `tools.exec.security` (default: `deny` for sandbox, `full` for gateway + node when unset)
- `tools.exec.ask` (default: `off`)
- No-approval host exec is the default for gateway + node. If you want approvals/allowlist behavior, tighten both `tools.exec.*` and the host `~/.openclaw/exec-approvals.json`; see [Exec approvals](/tools/exec-approvals#no-approval-yolo-mode).
- `tools.exec.node` (default: unset)
- `tools.exec.strictInlineEval` (default: false): when true, inline interpreter eval forms such as `python -c`, `node -e`, `ruby -e`, `perl -e`, `php -r`, `lua -e`, and `osascript -e` always require explicit approval. `allow-always` can still persist benign interpreter/script invocations, but inline-eval forms still prompt each time.
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs (gateway + sandbox only).

View File

@@ -65,18 +65,14 @@ Notes:
entries: {
firecrawl: {
enabled: true,
},
},
},
tools: {
web: {
fetch: {
firecrawl: {
apiKey: "FIRECRAWL_API_KEY_HERE",
baseUrl: "https://api.firecrawl.dev",
onlyMainContent: true,
maxAgeMs: 172800000,
timeoutSeconds: 60,
config: {
webFetch: {
apiKey: "FIRECRAWL_API_KEY_HERE",
baseUrl: "https://api.firecrawl.dev",
onlyMainContent: true,
maxAgeMs: 172800000,
timeoutSeconds: 60,
},
},
},
},
@@ -87,10 +83,11 @@ Notes:
Notes:
- `firecrawl.enabled` defaults to `true` unless explicitly set to `false`.
- Firecrawl fallback attempts run only when an API key is available (`tools.web.fetch.firecrawl.apiKey` or `FIRECRAWL_API_KEY`).
- Firecrawl fallback attempts run only when an API key is available (`plugins.entries.firecrawl.config.webFetch.apiKey` or `FIRECRAWL_API_KEY`).
- `maxAgeMs` controls how old cached results can be (ms). Default is 2 days.
- Legacy `tools.web.fetch.firecrawl.*` config is auto-migrated by `openclaw doctor --fix`.
`firecrawl_scrape` reuses the same `tools.web.fetch.firecrawl.*` settings and env vars.
`firecrawl_scrape` reuses the same `plugins.entries.firecrawl.config.webFetch.*` settings and env vars.
## Firecrawl plugin tools

View File

@@ -82,16 +82,18 @@ If Readability extraction fails, `web_fetch` can fall back to
```json5
{
tools: {
web: {
fetch: {
firecrawl: {
enabled: true,
apiKey: "fc-...", // optional if FIRECRAWL_API_KEY is set
baseUrl: "https://api.firecrawl.dev",
onlyMainContent: true,
maxAgeMs: 86400000, // cache duration (1 day)
timeoutSeconds: 60,
plugins: {
entries: {
firecrawl: {
enabled: true,
config: {
webFetch: {
apiKey: "fc-...", // optional if FIRECRAWL_API_KEY is set
baseUrl: "https://api.firecrawl.dev",
onlyMainContent: true,
maxAgeMs: 86400000, // cache duration (1 day)
timeoutSeconds: 60,
},
},
},
},
@@ -99,7 +101,8 @@ If Readability extraction fails, `web_fetch` can fall back to
}
```
`tools.web.fetch.firecrawl.apiKey` supports SecretRef objects.
`plugins.entries.firecrawl.config.webFetch.apiKey` supports SecretRef objects.
Legacy `tools.web.fetch.firecrawl.*` config is auto-migrated by `openclaw doctor --fix`.
<Note>
If Firecrawl is enabled and its SecretRef is unresolved with no

View File

@@ -193,8 +193,9 @@ Provider-specific config (API keys, base URLs, modes) lives under
`plugins.entries.<plugin>.config.webSearch.*`. See the provider pages for
examples.
For `x_search`, configure `tools.web.x_search.*` directly. It uses the same
`XAI_API_KEY` fallback as Grok web search.
For `x_search`, configure `plugins.entries.xai.config.xSearch.*`. It uses the
same `XAI_API_KEY` fallback as Grok web search.
Legacy `tools.web.x_search.*` config is auto-migrated by `openclaw doctor --fix`.
When you choose Grok during `openclaw onboard` or `openclaw configure --section web`,
OpenClaw can also offer optional `x_search` setup with the same key.
This is a separate follow-up step inside the Grok path, not a separate top-level
@@ -280,16 +281,22 @@ tool on the request that serves this tool call.
```json5
{
tools: {
web: {
x_search: {
enabled: true,
apiKey: "xai-...", // optional if XAI_API_KEY is set
model: "grok-4-1-fast-non-reasoning",
inlineCitations: false,
maxTurns: 2,
timeoutSeconds: 30,
cacheTtlMinutes: 15,
plugins: {
entries: {
xai: {
config: {
xSearch: {
enabled: true,
model: "grok-4-1-fast-non-reasoning",
inlineCitations: false,
maxTurns: 2,
timeoutSeconds: 30,
cacheTtlMinutes: 15,
},
webSearch: {
apiKey: "xai-...", // optional if XAI_API_KEY is set
},
},
},
},
},

View File

@@ -32,18 +32,20 @@ export async function transcribeDeepgramAudio(
): Promise<AudioTranscriptionResult> {
const fetchFn = params.fetchFn ?? fetch;
const model = resolveModel(params.model);
const { baseUrl, allowPrivateNetwork, headers } = resolveProviderHttpRequestConfig({
baseUrl: params.baseUrl,
defaultBaseUrl: DEFAULT_DEEPGRAM_AUDIO_BASE_URL,
headers: params.headers,
defaultHeaders: {
authorization: `Token ${params.apiKey}`,
"content-type": params.mime ?? "application/octet-stream",
},
provider: "deepgram",
capability: "audio",
transport: "media-understanding",
});
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: params.baseUrl,
defaultBaseUrl: DEFAULT_DEEPGRAM_AUDIO_BASE_URL,
headers: params.headers,
request: params.request,
defaultHeaders: {
authorization: `Token ${params.apiKey}`,
"content-type": params.mime ?? "application/octet-stream",
},
provider: "deepgram",
capability: "audio",
transport: "media-understanding",
});
const url = new URL(`${baseUrl}/listen`);
url.searchParams.set("model", model);
@@ -67,6 +69,7 @@ export async function transcribeDeepgramAudio(
timeoutMs: params.timeoutMs,
fetchFn,
allowPrivateNetwork,
dispatcherPolicy,
});
try {

View File

@@ -8,7 +8,7 @@
"@discordjs/voice": "^0.19.2",
"discord-api-types": "^0.38.43",
"https-proxy-agent": "^8.0.0",
"opusscript": "^0.1.1"
"opusscript": "^0.0.8"
},
"devDependencies": {
"openclaw": "workspace:*"

View File

@@ -134,16 +134,17 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
}
const model = normalizeGoogleImageModel(req.model);
const { baseUrl, allowPrivateNetwork, headers } = resolveProviderHttpRequestConfig({
baseUrl: resolveGoogleBaseUrl(req.cfg),
defaultBaseUrl: DEFAULT_GOOGLE_API_BASE_URL,
allowPrivateNetwork: Boolean(req.cfg?.models?.providers?.google?.baseUrl?.trim()),
defaultHeaders: parseGeminiAuth(auth.apiKey).headers,
provider: "google",
api: "google-generative-ai",
capability: "image",
transport: "http",
});
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: resolveGoogleBaseUrl(req.cfg),
defaultBaseUrl: DEFAULT_GOOGLE_API_BASE_URL,
allowPrivateNetwork: Boolean(req.cfg?.models?.providers?.google?.baseUrl?.trim()),
defaultHeaders: parseGeminiAuth(auth.apiKey).headers,
provider: "google",
api: "google-generative-ai",
capability: "image",
transport: "http",
});
const imageConfig = mapSizeToImageConfig(req.size);
const inputParts = (req.inputImages ?? []).map((image) => ({
inlineData: {
@@ -177,6 +178,7 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
timeoutMs: 60_000,
fetchFn: fetch,
allowPrivateNetwork,
dispatcherPolicy,
});
try {

View File

@@ -11,6 +11,7 @@ import {
assertOkOrThrowHttpError,
postJsonRequest,
resolveProviderHttpRequestConfig,
type ProviderRequestTransportOverrides,
} from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_GOOGLE_API_BASE_URL,
@@ -32,6 +33,7 @@ async function generateGeminiInlineDataText(params: {
apiKey: string;
baseUrl?: string;
headers?: Record<string, string>;
request?: ProviderRequestTransportOverrides;
model?: string;
prompt?: string;
timeoutMs: number;
@@ -51,17 +53,19 @@ async function generateGeminiInlineDataText(params: {
}
return normalizeGoogleModelId(trimmed);
})();
const { baseUrl, allowPrivateNetwork, headers } = resolveProviderHttpRequestConfig({
baseUrl: normalizeGoogleApiBaseUrl(params.baseUrl ?? params.defaultBaseUrl),
defaultBaseUrl: DEFAULT_GOOGLE_API_BASE_URL,
allowPrivateNetwork: Boolean(params.baseUrl?.trim()),
headers: params.headers,
defaultHeaders: parseGeminiAuth(params.apiKey).headers,
provider: "google",
api: "google-generative-ai",
capability: params.defaultMime.startsWith("audio/") ? "audio" : "video",
transport: "media-understanding",
});
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: normalizeGoogleApiBaseUrl(params.baseUrl ?? params.defaultBaseUrl),
defaultBaseUrl: DEFAULT_GOOGLE_API_BASE_URL,
allowPrivateNetwork: Boolean(params.baseUrl?.trim()),
headers: params.headers,
request: params.request,
defaultHeaders: parseGeminiAuth(params.apiKey).headers,
provider: "google",
api: "google-generative-ai",
capability: params.defaultMime.startsWith("audio/") ? "audio" : "video",
transport: "media-understanding",
});
const url = `${baseUrl}/models/${model}:generateContent`;
const prompt = (() => {
@@ -93,6 +97,7 @@ async function generateGeminiInlineDataText(params: {
timeoutMs: params.timeoutMs,
fetchFn,
allowPrivateNetwork,
dispatcherPolicy,
});
try {

View File

@@ -65,19 +65,21 @@ export async function describeMoonshotVideo(
const model = resolveModel(params.model);
const mime = params.mime ?? "video/mp4";
const prompt = resolvePrompt(params.prompt);
const { baseUrl, allowPrivateNetwork, headers } = resolveProviderHttpRequestConfig({
baseUrl: params.baseUrl,
defaultBaseUrl: DEFAULT_MOONSHOT_VIDEO_BASE_URL,
headers: params.headers,
defaultHeaders: {
"content-type": "application/json",
authorization: `Bearer ${params.apiKey}`,
},
provider: "moonshot",
api: "openai-completions",
capability: "video",
transport: "media-understanding",
});
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: params.baseUrl,
defaultBaseUrl: DEFAULT_MOONSHOT_VIDEO_BASE_URL,
headers: params.headers,
request: params.request,
defaultHeaders: {
"content-type": "application/json",
authorization: `Bearer ${params.apiKey}`,
},
provider: "moonshot",
api: "openai-completions",
capability: "video",
transport: "media-understanding",
});
const url = `${baseUrl}/chat/completions`;
const body = {
@@ -105,6 +107,7 @@ export async function describeMoonshotVideo(
timeoutMs: params.timeoutMs,
fetchFn,
allowPrivateNetwork,
dispatcherPolicy,
});
try {

View File

@@ -1273,7 +1273,7 @@
"@types/node": "^25.5.0",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260331.1",
"@typescript/native-preview": "7.0.0-dev.20260401.1",
"@vitest/coverage-v8": "^4.1.2",
"jscpd": "4.0.8",
"jsdom": "^29.0.1",

106
pnpm-lock.yaml generated
View File

@@ -201,8 +201,8 @@ importers:
specifier: ^8.18.1
version: 8.18.1
'@typescript/native-preview':
specifier: 7.0.0-dev.20260331.1
version: 7.0.0-dev.20260331.1
specifier: 7.0.0-dev.20260401.1
version: 7.0.0-dev.20260401.1
'@vitest/coverage-v8':
specifier: ^4.1.2
version: 4.1.2(@vitest/browser@4.1.2(vite@8.0.3(@emnapi/core@1.8.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2))(vitest@4.1.2)
@@ -232,7 +232,7 @@ importers:
version: 0.21.1(signal-polyfill@0.2.2)
tsdown:
specifier: 0.21.7
version: 0.21.7(@emnapi/core@1.8.1)(@emnapi/runtime@1.9.1)(@typescript/native-preview@7.0.0-dev.20260331.1)(typescript@6.0.2)
version: 0.21.7(@emnapi/core@1.8.1)(@emnapi/runtime@1.9.1)(@typescript/native-preview@7.0.0-dev.20260401.1)(typescript@6.0.2)
tsx:
specifier: ^4.21.0
version: 4.21.0
@@ -343,10 +343,10 @@ importers:
dependencies:
'@buape/carbon':
specifier: 0.0.0-beta-20260327000044
version: 0.0.0-beta-20260327000044(@discordjs/opus@0.10.0)(hono@4.12.9)(opusscript@0.1.1)
version: 0.0.0-beta-20260327000044(@discordjs/opus@0.10.0)(hono@4.12.9)(opusscript@0.0.8)
'@discordjs/voice':
specifier: ^0.19.2
version: 0.19.2(@discordjs/opus@0.10.0)(@emnapi/core@1.8.1)(@emnapi/runtime@1.9.1)(opusscript@0.1.1)
version: 0.19.2(@discordjs/opus@0.10.0)(@emnapi/core@1.8.1)(@emnapi/runtime@1.9.1)(opusscript@0.0.8)
discord-api-types:
specifier: ^0.38.43
version: 0.38.43
@@ -354,8 +354,8 @@ importers:
specifier: ^8.0.0
version: 8.0.0
opusscript:
specifier: ^0.1.1
version: 0.1.1
specifier: ^0.0.8
version: 0.0.8
devDependencies:
openclaw:
specifier: workspace:*
@@ -3459,43 +3459,43 @@ packages:
'@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260331.1':
resolution: {integrity: sha512-1PRnBCN2csiCzj76YaSBtP4jPLEGBUmVhXHplC+yHOKaxx9nf3HFiFCg/19raInvN/lJ8+Bp1fZ/qIsWAAHiBw==}
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260401.1':
resolution: {integrity: sha512-9PCc1D4/zLic30g1upOw6ZmUl98fnrXYRv5wIZ6fxm1zZAObieRKUX3Jbr8M9N8iQQFxPIZPniIScsxAbmbJvw==}
cpu: [arm64]
os: [darwin]
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260331.1':
resolution: {integrity: sha512-llXnfLGjl+gXsANLD7UI/gSb3lj7aZW13Rf8sVXQnHJ3/dkJRAm/MgLqdjuuyvYq3pFaleiep+zoLd96rLRqUw==}
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260401.1':
resolution: {integrity: sha512-wwzca1KrjSVC6ApXfITsg/wF4GGbhVYebc7zChpuyi+phrHfw6ThTPB5XFUH4nA32vqw0Hn/6KACipMgzg8GPA==}
cpu: [x64]
os: [darwin]
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260331.1':
resolution: {integrity: sha512-sH5gALi89jl5ZjAL/UsLDPsjT/nCLRfHl/pw86ablRX10tYsJhJ/RD6J/cl3g39kJ18tIISSbsuIBn+ncanfSA==}
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260401.1':
resolution: {integrity: sha512-1hgKibGi4QZF1J0hKltgY4nj4yKDmI4Ang5ar80I+YeUdGxV/fP2kU3bJang7QtHuSso6W+a52SF62zgqbzdow==}
cpu: [arm64]
os: [linux]
'@typescript/native-preview-linux-arm@7.0.0-dev.20260331.1':
resolution: {integrity: sha512-+8AZzA0BRjMkLDvdQKZOMuheRxNGpSWn7sOtoKqo70R915D0TyEynEXX6B7/aw3+Jfn1H5hLRiBjxoVsmdKENw==}
'@typescript/native-preview-linux-arm@7.0.0-dev.20260401.1':
resolution: {integrity: sha512-bbIkRZYjtyoyCJ3wFES7qn3EwYO5Go1hxArL5X5oWiBmUHq5gMIxTZDv5mpWxopVf9Eyh4ErHefXjf1s4J+6Ag==}
cpu: [arm]
os: [linux]
'@typescript/native-preview-linux-x64@7.0.0-dev.20260331.1':
resolution: {integrity: sha512-Yic6MYfX7Uit5jLLENzWFIi6tjp4LTLF37KBiVaHZSvEFyX1kqVwu4j9WNeaz81O6fcB/1dZ1MrILgfcqalNBg==}
'@typescript/native-preview-linux-x64@7.0.0-dev.20260401.1':
resolution: {integrity: sha512-1ysZ4c/Wa3RYIlrwVceYlhb1m1hxQ4P2x92valZXH0bNWEPb+oiQ4Yf35O/vi5h8zDdX/ZQ59vivYl27cF1VVA==}
cpu: [x64]
os: [linux]
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260331.1':
resolution: {integrity: sha512-vGxK6gtGF97zSx9wOpiVME3h9v0tbZbrHHdKA+fLFNvDV0Df8ud89DEePL7l2yKnVVmf0OnjJy6sYoVyj+LIPA==}
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260401.1':
resolution: {integrity: sha512-fZYLCRe36y1BuzRFFpU2/RQ212l6Y1dccRMh8oTB8HlAVAAwtbkb6cjEn0Ablj4Dy16+Ih8R9uHsxPLNhtKw1Q==}
cpu: [arm64]
os: [win32]
'@typescript/native-preview-win32-x64@7.0.0-dev.20260331.1':
resolution: {integrity: sha512-oJnNiU9UTDPJp6dOmOUW+/Wzt3MQZXIHsDaU4qM0RiAjFE6S+PIX8s5z/ID0orr4MMroUMiLdolL4OVZolNDSw==}
'@typescript/native-preview-win32-x64@7.0.0-dev.20260401.1':
resolution: {integrity: sha512-I6ses4SjWvpbvSpm1BPFRrDeqrzu7JTchJG/a26iwwmTLv4fAGLc5/o6Kv9Naygozop1W3KOcVM5i3A9oxiIjQ==}
cpu: [x64]
os: [win32]
'@typescript/native-preview@7.0.0-dev.20260331.1':
resolution: {integrity: sha512-Gfy2J/LhydkOHOw+ZWRw0M8Xl3O2bzQXLXIYITdMz2N4GpMm8misAvvCzhqMacOGvazKr1FsL9LIIW2kxk6kzw==}
'@typescript/native-preview@7.0.0-dev.20260401.1':
resolution: {integrity: sha512-xJcN9WlY/P6xKjCMH4+DTzZSj/EKR6H9avuqUKs4eKyPEvaQ4bX+9Ys3Vl2qhlUaD7IRWY7HN7db0LHAGlWRSA==}
hasBin: true
'@ungap/structured-clone@1.3.0':
@@ -5399,8 +5399,8 @@ packages:
opus-decoder@0.7.11:
resolution: {integrity: sha512-+e+Jz3vGQLxRTBHs8YJQPRPc1Tr+/aC6coV/DlZylriA29BdHQAYXhvNRKtjftof17OFng0+P4wsFIqQu3a48A==}
opusscript@0.1.1:
resolution: {integrity: sha512-mL0fZZOUnXdZ78woRXp18lApwpp0lF5tozJOD1Wut0dgrA9WuQTgSels/CSmFleaAZrJi/nci5KOVtbuxeWoQA==}
opusscript@0.0.8:
resolution: {integrity: sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==}
ora@9.3.0:
resolution: {integrity: sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==}
@@ -7349,13 +7349,13 @@ snapshots:
dependencies:
css-tree: 3.2.1
'@buape/carbon@0.0.0-beta-20260327000044(@discordjs/opus@0.10.0)(hono@4.12.9)(opusscript@0.1.1)':
'@buape/carbon@0.0.0-beta-20260327000044(@discordjs/opus@0.10.0)(hono@4.12.9)(opusscript@0.0.8)':
dependencies:
'@types/node': 25.5.0
discord-api-types: 0.38.37
optionalDependencies:
'@cloudflare/workers-types': 4.20260120.0
'@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1)
'@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.0.8)
'@hono/node-server': 1.19.10(hono@4.12.9)
'@types/bun': 1.3.9
'@types/ws': 8.18.1
@@ -7507,11 +7507,11 @@ snapshots:
- supports-color
optional: true
'@discordjs/voice@0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1)':
'@discordjs/voice@0.19.0(@discordjs/opus@0.10.0)(opusscript@0.0.8)':
dependencies:
'@types/ws': 8.18.1
discord-api-types: 0.38.43
prism-media: 1.3.5(@discordjs/opus@0.10.0)(opusscript@0.1.1)
prism-media: 1.3.5(@discordjs/opus@0.10.0)(opusscript@0.0.8)
tslib: 2.8.1
ws: 8.20.0
transitivePeerDependencies:
@@ -7523,12 +7523,12 @@ snapshots:
- utf-8-validate
optional: true
'@discordjs/voice@0.19.2(@discordjs/opus@0.10.0)(@emnapi/core@1.8.1)(@emnapi/runtime@1.9.1)(opusscript@0.1.1)':
'@discordjs/voice@0.19.2(@discordjs/opus@0.10.0)(@emnapi/core@1.8.1)(@emnapi/runtime@1.9.1)(opusscript@0.0.8)':
dependencies:
'@snazzah/davey': 0.1.11(@emnapi/core@1.8.1)(@emnapi/runtime@1.9.1)
'@types/ws': 8.18.1
discord-api-types: 0.38.43
prism-media: 1.3.5(@discordjs/opus@0.10.0)(opusscript@0.1.1)
prism-media: 1.3.5(@discordjs/opus@0.10.0)(opusscript@0.0.8)
tslib: 2.8.1
ws: 8.20.0
transitivePeerDependencies:
@@ -9814,36 +9814,36 @@ snapshots:
'@types/node': 25.5.0
optional: true
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260331.1':
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260401.1':
optional: true
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260331.1':
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260401.1':
optional: true
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260331.1':
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260401.1':
optional: true
'@typescript/native-preview-linux-arm@7.0.0-dev.20260331.1':
'@typescript/native-preview-linux-arm@7.0.0-dev.20260401.1':
optional: true
'@typescript/native-preview-linux-x64@7.0.0-dev.20260331.1':
'@typescript/native-preview-linux-x64@7.0.0-dev.20260401.1':
optional: true
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260331.1':
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260401.1':
optional: true
'@typescript/native-preview-win32-x64@7.0.0-dev.20260331.1':
'@typescript/native-preview-win32-x64@7.0.0-dev.20260401.1':
optional: true
'@typescript/native-preview@7.0.0-dev.20260331.1':
'@typescript/native-preview@7.0.0-dev.20260401.1':
optionalDependencies:
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260331.1
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20260331.1
'@typescript/native-preview-linux-arm': 7.0.0-dev.20260331.1
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20260331.1
'@typescript/native-preview-linux-x64': 7.0.0-dev.20260331.1
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20260331.1
'@typescript/native-preview-win32-x64': 7.0.0-dev.20260331.1
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260401.1
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20260401.1
'@typescript/native-preview-linux-arm': 7.0.0-dev.20260401.1
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20260401.1
'@typescript/native-preview-linux-x64': 7.0.0-dev.20260401.1
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20260401.1
'@typescript/native-preview-win32-x64': 7.0.0-dev.20260401.1
'@ungap/structured-clone@1.3.0': {}
@@ -11947,7 +11947,7 @@ snapshots:
'@wasm-audio-decoders/common': 9.0.7
optional: true
opusscript@0.1.1: {}
opusscript@0.0.8: {}
ora@9.3.0:
dependencies:
@@ -12184,10 +12184,10 @@ snapshots:
dependencies:
parse-ms: 4.0.0
prism-media@1.3.5(@discordjs/opus@0.10.0)(opusscript@0.1.1):
prism-media@1.3.5(@discordjs/opus@0.10.0)(opusscript@0.0.8):
optionalDependencies:
'@discordjs/opus': 0.10.0
opusscript: 0.1.1
opusscript: 0.0.8
process-nextick-args@2.0.1: {}
@@ -12456,7 +12456,7 @@ snapshots:
glob: 7.2.3
optional: true
rolldown-plugin-dts@0.23.2(@typescript/native-preview@7.0.0-dev.20260331.1)(rolldown@1.0.0-rc.12(@emnapi/core@1.8.1)(@emnapi/runtime@1.9.1))(typescript@6.0.2):
rolldown-plugin-dts@0.23.2(@typescript/native-preview@7.0.0-dev.20260401.1)(rolldown@1.0.0-rc.12(@emnapi/core@1.8.1)(@emnapi/runtime@1.9.1))(typescript@6.0.2):
dependencies:
'@babel/generator': 8.0.0-rc.3
'@babel/helper-validator-identifier': 8.0.0-rc.3
@@ -12470,7 +12470,7 @@ snapshots:
picomatch: 4.0.4
rolldown: 1.0.0-rc.12(@emnapi/core@1.8.1)(@emnapi/runtime@1.9.1)
optionalDependencies:
'@typescript/native-preview': 7.0.0-dev.20260331.1
'@typescript/native-preview': 7.0.0-dev.20260401.1
typescript: 6.0.2
transitivePeerDependencies:
- oxc-resolver
@@ -12963,7 +12963,7 @@ snapshots:
ts-algebra@2.0.0: {}
tsdown@0.21.7(@emnapi/core@1.8.1)(@emnapi/runtime@1.9.1)(@typescript/native-preview@7.0.0-dev.20260331.1)(typescript@6.0.2):
tsdown@0.21.7(@emnapi/core@1.8.1)(@emnapi/runtime@1.9.1)(@typescript/native-preview@7.0.0-dev.20260401.1)(typescript@6.0.2):
dependencies:
ansis: 4.2.0
cac: 7.0.0
@@ -12974,7 +12974,7 @@ snapshots:
obug: 2.1.1
picomatch: 4.0.4
rolldown: 1.0.0-rc.12(@emnapi/core@1.8.1)(@emnapi/runtime@1.9.1)
rolldown-plugin-dts: 0.23.2(@typescript/native-preview@7.0.0-dev.20260331.1)(rolldown@1.0.0-rc.12(@emnapi/core@1.8.1)(@emnapi/runtime@1.9.1))(typescript@6.0.2)
rolldown-plugin-dts: 0.23.2(@typescript/native-preview@7.0.0-dev.20260401.1)(rolldown@1.0.0-rc.12(@emnapi/core@1.8.1)(@emnapi/runtime@1.9.1))(typescript@6.0.2)
semver: 7.7.4
tinyexec: 1.0.4
tinyglobby: 0.2.15

View File

@@ -281,35 +281,23 @@ function Invoke-CaptureLogged {
}
try {
function Write-Phase {
param([Parameter(Mandatory = $true)][string]$Message)
('==> ' + $Message) | Tee-Object -FilePath $LogPath -Append | Out-Null
}
$env:PATH = "$env:LOCALAPPDATA\OpenClaw\deps\portable-git\cmd;$env:LOCALAPPDATA\OpenClaw\deps\portable-git\mingw64\bin;$env:LOCALAPPDATA\OpenClaw\deps\portable-git\usr\bin;$env:PATH"
$tgz = Join-Path $env:TEMP 'openclaw-main-update.tgz'
Remove-Item $tgz, $LogPath, $DonePath -Force -ErrorAction SilentlyContinue
Set-Item -Path ('Env:' + $ProviderKeyEnv) -Value $ProviderKey
Write-Phase 'windows update.download-current-tgz'
Invoke-Logged 'download current tgz' { curl.exe -fsSL $TgzUrl -o $tgz }
Write-Phase 'windows update.install-current-tgz'
Invoke-Logged 'npm install current tgz' { npm.cmd install -g $tgz --no-fund --no-audit }
$openclaw = Join-Path $env:APPDATA 'npm\openclaw.cmd'
Write-Phase 'windows update.verify-version'
$version = Invoke-CaptureLogged 'openclaw --version' { & $openclaw --version }
if ($version -notmatch [regex]::Escape($HeadShort)) {
throw "version mismatch: expected substring $HeadShort"
}
Write-Phase 'windows update.set-model'
Invoke-Logged 'openclaw models set' { & $openclaw models set $ModelId }
# Windows can keep the old hashed dist modules alive across in-place global npm upgrades.
# Restart the gateway/service before verifying status or the next agent turn.
Write-Phase 'windows update.gateway-restart'
Invoke-Logged 'openclaw gateway restart' { & $openclaw gateway restart }
Start-Sleep -Seconds 5
Write-Phase 'windows update.gateway-status'
Invoke-Logged 'openclaw gateway status' { & $openclaw gateway status --deep --require-rpc }
Write-Phase 'windows update.agent-turn'
Invoke-CaptureLogged 'openclaw agent' { & $openclaw agent --agent main --session-id $SessionId --message 'Reply with exact ASCII text OK only.' --json } | Out-Null
$exitCode = $LASTEXITCODE
if ($null -eq $exitCode) {
@@ -514,67 +502,6 @@ PY
host_timeout_exec "$timeout_s" prlctl exec "$WINDOWS_VM" --current-user powershell.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand "$encoded"
}
launch_guest_helper() {
local label="$1"
local script="$2"
local attempt rc
for attempt in 1 2; do
say "$label launch attempt $attempt/2"
set +e
guest_powershell "$script"
rc=$?
set -e
if [[ $rc -eq 0 ]]; then
return 0
fi
warn "$label launch failed (rc=$rc)"
sleep 2
done
return 1
}
drain_guest_log_incremental() {
local log_name="$1"
local count_var="$2"
local current_count="${!count_var:-0}"
local count_output count_line count_rc new_output new_rc
set +e
count_output="$(
guest_powershell_poll 20 "\$log = Join-Path \$env:TEMP '$log_name'; if (Test-Path \$log) { (Get-Content \$log).Count } else { 0 }"
)"
count_rc=$?
set -e
if [[ $count_rc -ne 0 ]]; then
return "$count_rc"
fi
count_output="${count_output//$'\r'/}"
count_line="${count_output##*$'\n'}"
[[ "$count_line" =~ ^[0-9]+$ ]] || return 0
if (( count_line <= current_count )); then
return 0
fi
set +e
new_output="$(
guest_powershell_poll 20 "\$log = Join-Path \$env:TEMP '$log_name'; if (Test-Path \$log) { Get-Content \$log | Select-Object -Skip $current_count }"
)"
new_rc=$?
set -e
if [[ $new_rc -ne 0 ]]; then
return "$new_rc"
fi
new_output="${new_output//$'\r'/}"
if [[ -n "$new_output" ]]; then
printf '%s\n' "$new_output"
fi
printf -v "$count_var" '%s' "$count_line"
}
run_windows_script_via_log() {
local script_url="$1"
local tgz_url="$2"
@@ -584,16 +511,15 @@ run_windows_script_via_log() {
local provider_key_env="$6"
local provider_key="$7"
local runner_name log_name done_name done_status launcher_state
local start_seconds poll_deadline startup_checked poll_rc state_rc log_rc log_line_count
local start_seconds poll_deadline startup_checked poll_rc state_rc log_rc
runner_name="openclaw-update-$RANDOM-$RANDOM.ps1"
log_name="openclaw-update-$RANDOM-$RANDOM.log"
done_name="openclaw-update-$RANDOM-$RANDOM.done"
start_seconds="$SECONDS"
poll_deadline=$((SECONDS + 900))
startup_checked=0
log_line_count=0
launch_guest_helper "windows update helper" "$(cat <<EOF
guest_powershell "$(cat <<EOF
\$runner = Join-Path \$env:TEMP '$runner_name'
\$log = Join-Path \$env:TEMP '$log_name'
\$done = Join-Path \$env:TEMP '$done_name'
@@ -613,17 +539,9 @@ Start-Process powershell.exe -ArgumentList @(
'-DonePath', \$done
) -WindowStyle Hidden | Out-Null
EOF
)" || return 1
)"
while :; do
set +e
drain_guest_log_incremental "$log_name" log_line_count
log_rc=$?
set -e
if [[ $log_rc -ne 0 ]]; then
warn "windows update helper live log poll failed; retrying"
fi
set +e
done_status="$(
guest_powershell_poll 20 "\$done = Join-Path \$env:TEMP '$done_name'; if (Test-Path \$done) { (Get-Content \$done -Raw).Trim() }"
@@ -642,7 +560,7 @@ EOF
fi
if [[ -n "$done_status" ]]; then
set +e
drain_guest_log_incremental "$log_name" log_line_count
guest_powershell_poll 20 "\$log = Join-Path \$env:TEMP '$log_name'; if (Test-Path \$log) { Get-Content \$log }"
log_rc=$?
set -e
if [[ $log_rc -ne 0 ]]; then
@@ -667,7 +585,7 @@ EOF
fi
if (( SECONDS >= poll_deadline )); then
set +e
drain_guest_log_incremental "$log_name" log_line_count
guest_powershell_poll 20 "\$log = Join-Path \$env:TEMP '$log_name'; if (Test-Path \$log) { Get-Content \$log }"
log_rc=$?
set -e
if [[ $log_rc -ne 0 ]]; then

View File

@@ -418,68 +418,6 @@ PY
host_timeout_exec "$timeout_s" prlctl exec "$VM_NAME" --current-user powershell.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand "$encoded"
}
launch_guest_helper() {
local label="$1"
local script="$2"
local attempt rc
for (( attempt = 1; attempt <= 2; attempt++ )); do
say "$label launch attempt $attempt/2"
set +e
guest_powershell "$script"
rc=$?
set -e
if [[ $rc -eq 0 ]]; then
return 0
fi
warn "$label launch failed (rc=$rc)"
wait_for_guest_ready >/dev/null 2>&1 || true
sleep 2
done
return 1
}
drain_guest_log_incremental() {
local log_name="$1"
local count_var="$2"
local current_count="${!count_var:-0}"
local count_output count_line count_rc new_output new_rc
set +e
count_output="$(
guest_powershell_poll 20 "\$log = Join-Path \$env:TEMP '$log_name'; if (Test-Path \$log) { (Get-Content \$log).Count } else { 0 }"
)"
count_rc=$?
set -e
if [[ $count_rc -ne 0 ]]; then
return "$count_rc"
fi
count_output="${count_output//$'\r'/}"
count_line="${count_output##*$'\n'}"
[[ "$count_line" =~ ^[0-9]+$ ]] || return 0
if (( count_line <= current_count )); then
return 0
fi
set +e
new_output="$(
guest_powershell_poll 20 "\$log = Join-Path \$env:TEMP '$log_name'; if (Test-Path \$log) { Get-Content \$log | Select-Object -Skip $current_count }"
)"
new_rc=$?
set -e
if [[ $new_rc -ne 0 ]]; then
return "$new_rc"
fi
new_output="${new_output//$'\r'/}"
if [[ -n "$new_output" ]]; then
printf '%s\n' "$new_output"
fi
printf -v "$count_var" '%s' "$count_line"
}
guest_run_openclaw() {
local env_name="${1:-}"
local env_value="${2:-}"
@@ -944,14 +882,8 @@ param(
\$PSNativeCommandUseErrorActionPreference = \$false
try {
function Write-Phase {
param([Parameter(Mandatory = \$true)][string]\$Message)
('==> ' + \$Message) | Tee-Object -FilePath \$LogPath -Append | Out-Null
}
\$openclaw = Join-Path \$env:APPDATA 'npm\openclaw.cmd'
Write-Phase 'windows onboard.start'
\$cmdLine = ('"{0}" onboard --non-interactive --mode local --auth-choice ${AUTH_CHOICE} --secret-input-mode ref --gateway-port 18789 --gateway-bind loopback --install-daemon --skip-skills --accept-risk --json >> "{1}" 2>&1' -f \$openclaw, \$LogPath)
\$cmdLine = ('"{0}" onboard --non-interactive --mode local --auth-choice ${AUTH_CHOICE} --secret-input-mode ref --gateway-port 18789 --gateway-bind loopback --install-daemon --skip-skills --accept-risk --json > "{1}" 2>&1' -f \$openclaw, \$LogPath)
& cmd.exe /d /s /c \$cmdLine
Set-Content -Path \$DonePath -Value ([string]\$LASTEXITCODE)
} catch {
@@ -968,7 +900,7 @@ EOF
run_ref_onboard() {
local api_key_env_q api_key_value_q script_url
local runner_name log_name done_name done_status launcher_state
local poll_rc state_rc log_rc start_seconds poll_deadline startup_checked log_line_count
local poll_rc state_rc log_rc start_seconds poll_deadline startup_checked
api_key_env_q="$(ps_single_quote "$API_KEY_ENV")"
api_key_value_q="$(ps_single_quote "$API_KEY_VALUE")"
write_onboard_runner_script
@@ -979,9 +911,8 @@ run_ref_onboard() {
start_seconds="$SECONDS"
poll_deadline=$((SECONDS + TIMEOUT_ONBOARD_S + 60))
startup_checked=0
log_line_count=0
launch_guest_helper "windows onboard helper" "$(cat <<EOF
guest_powershell "$(cat <<EOF
\$runner = Join-Path \$env:TEMP '$runner_name'
\$log = Join-Path \$env:TEMP '$log_name'
\$done = Join-Path \$env:TEMP '$done_name'
@@ -990,17 +921,9 @@ Set-Item -Path ('Env:' + '${api_key_env_q}') -Value '${api_key_value_q}'
curl.exe -fsSL '$script_url' -o \$runner
Start-Process powershell.exe -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', \$runner, '-LogPath', \$log, '-DonePath', \$done) -WindowStyle Hidden | Out-Null
EOF
)" || return 1
)"
while :; do
set +e
drain_guest_log_incremental "$log_name" log_line_count
log_rc=$?
set -e
if [[ $log_rc -ne 0 ]]; then
warn "windows onboard helper live log poll failed; retrying"
fi
set +e
done_status="$(
guest_powershell_poll 20 "\$done = Join-Path \$env:TEMP '$done_name'; if (Test-Path \$done) { (Get-Content \$done -Raw).Trim() }"
@@ -1019,7 +942,7 @@ EOF
fi
if [[ -n "$done_status" ]]; then
set +e
drain_guest_log_incremental "$log_name" log_line_count
guest_powershell_poll 20 "\$log = Join-Path \$env:TEMP '$log_name'; if (Test-Path \$log) { Get-Content \$log }"
log_rc=$?
set -e
if [[ $log_rc -ne 0 ]]; then
@@ -1044,7 +967,7 @@ EOF
fi
if (( SECONDS >= poll_deadline )); then
set +e
drain_guest_log_incremental "$log_name" log_line_count
guest_powershell_poll 20 "\$log = Join-Path \$env:TEMP '$log_name'; if (Test-Path \$log) { Get-Content \$log }"
log_rc=$?
set -e
if [[ $log_rc -ne 0 ]]; then
@@ -1064,16 +987,15 @@ verify_gateway() {
run_gateway_daemon_action() {
local action="$1"
local runner_name log_name done_name done_status launcher_state
local poll_rc state_rc log_rc start_seconds poll_deadline startup_checked log_line_count
local poll_rc state_rc log_rc start_seconds poll_deadline startup_checked
runner_name="openclaw-gateway-$action-$RANDOM-$RANDOM.ps1"
log_name="openclaw-gateway-$action-$RANDOM-$RANDOM.log"
done_name="openclaw-gateway-$action-$RANDOM-$RANDOM.done"
start_seconds="$SECONDS"
poll_deadline=$((SECONDS + TIMEOUT_GATEWAY_S + 60))
startup_checked=0
log_line_count=0
launch_guest_helper "windows gateway $action helper" "$(cat <<EOF
guest_powershell "$(cat <<EOF
\$runner = Join-Path \$env:TEMP '$runner_name'
\$log = Join-Path \$env:TEMP '$log_name'
\$done = Join-Path \$env:TEMP '$done_name'
@@ -1083,13 +1005,8 @@ Remove-Item \$runner, \$log, \$done -Force -ErrorAction SilentlyContinue
\$PSNativeCommandUseErrorActionPreference = \$false
\$log = Join-Path \$env:TEMP '$log_name'
\$done = Join-Path \$env:TEMP '$done_name'
function Write-Phase {
param([Parameter(Mandatory = \$true)][string]\$Message)
('==> ' + \$Message) | Tee-Object -FilePath \$log -Append | Out-Null
}
try {
\$openclaw = Join-Path \$env:APPDATA 'npm\openclaw.cmd'
Write-Phase 'windows gateway $action'
& \$openclaw gateway $action *>&1 | Tee-Object -FilePath \$log -Append | Out-Null
Set-Content -Path \$done -Value ([string]\$LASTEXITCODE)
} catch {
@@ -1103,17 +1020,9 @@ try {
'@ | Set-Content -Path \$runner
Start-Process powershell.exe -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', \$runner) -WindowStyle Hidden | Out-Null
EOF
)" || return 1
)"
while :; do
set +e
drain_guest_log_incremental "$log_name" log_line_count
log_rc=$?
set -e
if [[ $log_rc -ne 0 ]]; then
warn "windows gateway $action helper live log poll failed; retrying"
fi
set +e
done_status="$(
guest_powershell_poll 20 "\$done = Join-Path \$env:TEMP '$done_name'; if (Test-Path \$done) { (Get-Content \$done -Raw).Trim() }"
@@ -1132,7 +1041,7 @@ EOF
fi
if [[ -n "$done_status" ]]; then
set +e
drain_guest_log_incremental "$log_name" log_line_count
guest_powershell_poll 20 "\$log = Join-Path \$env:TEMP '$log_name'; if (Test-Path \$log) { Get-Content \$log }"
log_rc=$?
set -e
if [[ $log_rc -ne 0 ]]; then
@@ -1157,7 +1066,7 @@ EOF
fi
if (( SECONDS >= poll_deadline )); then
set +e
drain_guest_log_incremental "$log_name" log_line_count
guest_powershell_poll 20 "\$log = Join-Path \$env:TEMP '$log_name'; if (Test-Path \$log) { Get-Content \$log }"
log_rc=$?
set -e
if [[ $log_rc -ne 0 ]]; then

View File

@@ -168,7 +168,7 @@ export async function executeNodeHostCommand(
const resolved = resolveExecApprovalsFromFile({
file: approvalsFile as ExecApprovalsFile,
agentId: params.agentId,
overrides: { security: "allowlist" },
overrides: { security: "full" },
});
// Allowlist-only precheck; safe bins are node-local and may diverge.
const allowlistEval = evaluateShellAllowlist({

View File

@@ -115,7 +115,7 @@ function expectPendingApprovalText(
expect(pendingText).toContain(
(options.allowedDecisions ?? "").includes("allow-always")
? "Background mode requires pre-approved policy"
: "Background mode requires host policy that allows pre-approval",
: "Background mode requires an effective policy that allows pre-approval",
);
}
return details;
@@ -339,6 +339,7 @@ describe("exec approvals", () => {
const tool = createExecTool({
host: "node",
security: "allowlist",
ask: "on-miss",
approvalRunningNoticeMs: 0,
});
@@ -1131,7 +1132,7 @@ describe("exec approvals", () => {
const tool = createExecTool({
host: "gateway",
ask: "always",
security: "full",
security: "allowlist",
trigger: "cron",
approvalRunningNoticeMs: 0,
});
@@ -1211,6 +1212,11 @@ describe("exec approvals", () => {
});
it("explains cron no-route denials with a host-policy fix hint", async () => {
await writeExecApprovalsConfig({
version: 1,
defaults: { security: "full", ask: "always", askFallback: "deny" },
agents: {},
});
mockNoApprovalRouteRegistration();
const tool = createExecTool({

View File

@@ -273,6 +273,19 @@ describe("OpenAIWebSocketManager", () => {
await connectPromise;
});
it("rejects insecure websocket TLS overrides", async () => {
const manager = buildManager({
request: {
tls: {
insecureSkipVerify: true,
},
},
});
await expect(manager.connect("sk-test-key")).rejects.toThrow(/insecureskipverify/i);
expect(MockWebSocket.lastInstance).toBeNull();
});
it("resolves when the connection opens", async () => {
const manager = buildManager();
const connectPromise = manager.connect("sk-test");

View File

@@ -15,7 +15,11 @@
import { EventEmitter } from "node:events";
import WebSocket, { type ClientOptions } from "ws";
import { resolveProviderRequestPolicyConfig } from "./provider-request-config.js";
import {
buildProviderRequestTlsClientOptions,
resolveProviderRequestPolicyConfig,
type ProviderRequestTransportOverrides,
} from "./provider-request-config.js";
// ─────────────────────────────────────────────────────────────────────────────
// WebSocket Event Types (Server → Client)
@@ -268,6 +272,8 @@ export interface OpenAIWebSocketManagerOptions {
backoffDelaysMs?: readonly number[];
/** Custom socket factory for tests. */
socketFactory?: (url: string, options: ClientOptions) => WebSocket;
/** Optional transport overrides for provider-owned auth or TLS wiring. */
request?: ProviderRequestTransportOverrides;
}
type InternalEvents = {
@@ -308,6 +314,7 @@ export class OpenAIWebSocketManager extends EventEmitter<InternalEvents> {
private readonly maxRetries: number;
private readonly backoffDelaysMs: readonly number[];
private readonly socketFactory: (url: string, options: ClientOptions) => WebSocket;
private readonly request?: ProviderRequestTransportOverrides;
constructor(options: OpenAIWebSocketManagerOptions = {}) {
super();
@@ -316,6 +323,7 @@ export class OpenAIWebSocketManager extends EventEmitter<InternalEvents> {
this.backoffDelaysMs = options.backoffDelaysMs ?? BACKOFF_DELAYS_MS;
this.socketFactory =
options.socketFactory ?? ((url, socketOptions) => new WebSocket(url, socketOptions));
this.request = options.request;
}
// ─── Public API ────────────────────────────────────────────────────────────
@@ -402,19 +410,22 @@ export class OpenAIWebSocketManager extends EventEmitter<InternalEvents> {
return;
}
const requestConfig = resolveProviderRequestPolicyConfig({
provider: "openai",
api: "openai-responses",
baseUrl: this.wsUrl,
capability: "llm",
transport: "websocket",
providerHeaders: {
Authorization: `Bearer ${this.apiKey}`,
"OpenAI-Beta": "responses-websocket=v1",
},
precedence: "defaults-win",
request: this.request,
});
const socket = this.socketFactory(this.wsUrl, {
headers: resolveProviderRequestPolicyConfig({
provider: "openai",
api: "openai-responses",
baseUrl: this.wsUrl,
capability: "llm",
transport: "websocket",
providerHeaders: {
Authorization: `Bearer ${this.apiKey}`,
"OpenAI-Beta": "responses-websocket=v1",
},
precedence: "defaults-win",
}).headers,
headers: requestConfig.headers,
...buildProviderRequestTlsClientOptions(requestConfig),
});
this.ws = socket;

View File

@@ -61,6 +61,10 @@ import {
} from "../pi-hooks/compaction-safeguard-runtime.js";
import { createPreparedEmbeddedPiSettingsManager } from "../pi-project-settings.js";
import { createOpenClawCodingTools } from "../pi-tools.js";
import {
resolveProviderRequestConfig,
sanitizeRuntimeProviderRequestOverrides,
} from "../provider-request-config.js";
import { registerProviderStreamForModel } from "../provider-stream.js";
import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js";
import { resolveSandboxContext } from "../sandbox.js";
@@ -360,8 +364,24 @@ export async function compactEmbeddedPiSessionDirect(
profileId: apiKeyInfo.profileId,
},
});
if (preparedAuth?.baseUrl) {
runtimeModel = { ...runtimeModel, baseUrl: preparedAuth.baseUrl };
if (preparedAuth?.baseUrl || preparedAuth?.request) {
const runtimeRequestConfig = resolveProviderRequestConfig({
provider: runtimeModel.provider,
api: runtimeModel.api,
baseUrl: preparedAuth?.baseUrl ?? runtimeModel.baseUrl,
providerHeaders:
runtimeModel.headers && typeof runtimeModel.headers === "object"
? runtimeModel.headers
: undefined,
request: sanitizeRuntimeProviderRequestOverrides(preparedAuth?.request),
capability: "llm",
transport: "stream",
});
runtimeModel = {
...runtimeModel,
...(preparedAuth?.baseUrl ? { baseUrl: preparedAuth.baseUrl } : {}),
...(runtimeRequestConfig.headers ? { headers: runtimeRequestConfig.headers } : {}),
};
}
const runtimeApiKey = preparedAuth?.apiKey ?? apiKeyInfo.apiKey;
hasRuntimeAuthExchange = Boolean(preparedAuth?.apiKey);

View File

@@ -0,0 +1,208 @@
import type { Api, Model } from "@mariozechner/pi-ai";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AuthProfileStore } from "../../auth-profiles.js";
const mocks = vi.hoisted(() => ({
prepareProviderRuntimeAuth: vi.fn(),
getApiKeyForModel: vi.fn(),
}));
vi.mock("../../../plugins/provider-runtime.js", () => ({
prepareProviderRuntimeAuth: mocks.prepareProviderRuntimeAuth,
}));
vi.mock("../../model-auth.js", async () => {
const actual = await vi.importActual<typeof import("../../model-auth.js")>("../../model-auth.js");
return {
...actual,
getApiKeyForModel: mocks.getApiKeyForModel,
};
});
import { createEmbeddedRunAuthController } from "./auth-controller.js";
function createTestModel(): Model<Api> {
return {
id: "test-model",
name: "test-model",
provider: "custom-openai",
api: "openai-responses",
baseUrl: "https://old.example.com/v1",
headers: {
Authorization: "Bearer stale-token",
},
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8_000,
maxTokens: 4_000,
} as Model<Api>;
}
describe("createEmbeddedRunAuthController", () => {
beforeEach(() => {
mocks.prepareProviderRuntimeAuth.mockReset();
mocks.getApiKeyForModel.mockReset();
});
it("applies runtime request overrides on the first auth exchange", async () => {
let runtimeModel = createTestModel();
let effectiveModel = createTestModel();
let runtimeAuthState: {
sourceApiKey: string;
authMode: string;
profileId?: string;
expiresAt?: number;
} | null = null;
let apiKeyInfo: unknown = null;
let lastProfileId: string | undefined;
const setRuntimeApiKey = vi.fn();
mocks.getApiKeyForModel.mockResolvedValue({
apiKey: "source-api-key",
mode: "api-key",
profileId: "default",
source: "env",
});
mocks.prepareProviderRuntimeAuth.mockResolvedValue({
apiKey: "runtime-api-key",
baseUrl: "https://runtime.example.com/v1",
request: {
auth: {
mode: "header",
headerName: "api-key",
value: "runtime-header-token",
},
},
});
const controller = createEmbeddedRunAuthController({
config: undefined,
agentDir: "/tmp/agent",
workspaceDir: "/tmp/workspace",
authStore: {
version: 1,
profiles: {},
} as AuthProfileStore,
authStorage: { setRuntimeApiKey },
profileCandidates: ["default"],
initialThinkLevel: "medium",
attemptedThinking: new Set(),
fallbackConfigured: false,
allowTransientCooldownProbe: false,
getProvider: () => "custom-openai",
getModelId: () => "test-model",
getRuntimeModel: () => runtimeModel,
setRuntimeModel: (next) => {
runtimeModel = next;
},
getEffectiveModel: () => effectiveModel,
setEffectiveModel: (next) => {
effectiveModel = next;
},
getApiKeyInfo: () => apiKeyInfo as never,
setApiKeyInfo: (next) => {
apiKeyInfo = next;
},
getLastProfileId: () => lastProfileId,
setLastProfileId: (next) => {
lastProfileId = next;
},
getRuntimeAuthState: () => runtimeAuthState as never,
setRuntimeAuthState: (next) => {
runtimeAuthState = next;
},
getRuntimeAuthRefreshCancelled: () => false,
setRuntimeAuthRefreshCancelled: () => undefined,
getProfileIndex: () => 0,
setProfileIndex: () => undefined,
setThinkLevel: () => undefined,
log: {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
},
});
await controller.initializeAuthProfile();
expect(runtimeModel.baseUrl).toBe("https://runtime.example.com/v1");
expect(runtimeModel.headers).toEqual({
"api-key": "runtime-header-token",
});
expect(effectiveModel.baseUrl).toBe("https://runtime.example.com/v1");
expect(effectiveModel.headers).toEqual({
"api-key": "runtime-header-token",
});
expect(setRuntimeApiKey).toHaveBeenCalledWith("custom-openai", "runtime-api-key");
expect(runtimeAuthState).toMatchObject({
sourceApiKey: "source-api-key",
authMode: "api-key",
profileId: "default",
});
});
it("rejects privileged runtime transport overrides on the first auth exchange", async () => {
let runtimeModel = createTestModel();
mocks.getApiKeyForModel.mockResolvedValue({
apiKey: "source-api-key",
mode: "api-key",
profileId: "default",
source: "env",
});
mocks.prepareProviderRuntimeAuth.mockResolvedValue({
apiKey: "runtime-api-key",
request: {
proxy: {
mode: "explicit-proxy",
url: "http://proxy.internal:8443",
},
},
});
const controller = createEmbeddedRunAuthController({
config: undefined,
agentDir: "/tmp/agent",
workspaceDir: "/tmp/workspace",
authStore: {
version: 1,
profiles: {},
} as AuthProfileStore,
authStorage: { setRuntimeApiKey: vi.fn() },
profileCandidates: ["default"],
initialThinkLevel: "medium",
attemptedThinking: new Set(),
fallbackConfigured: false,
allowTransientCooldownProbe: false,
getProvider: () => "custom-openai",
getModelId: () => "test-model",
getRuntimeModel: () => runtimeModel,
setRuntimeModel: (next) => {
runtimeModel = next;
},
getEffectiveModel: () => runtimeModel,
setEffectiveModel: () => undefined,
getApiKeyInfo: () => null as never,
setApiKeyInfo: () => undefined,
getLastProfileId: () => undefined,
setLastProfileId: () => undefined,
getRuntimeAuthState: () => null,
setRuntimeAuthState: () => undefined,
getRuntimeAuthRefreshCancelled: () => false,
setRuntimeAuthRefreshCancelled: () => undefined,
getProfileIndex: () => 0,
setProfileIndex: () => undefined,
setThinkLevel: () => undefined,
log: {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
},
});
await expect(controller.initializeAuthProfile()).rejects.toThrow(
/runtime auth request overrides do not allow proxy or tls/i,
);
});
});

View File

@@ -14,6 +14,10 @@ import {
isFailoverErrorMessage,
type FailoverReason,
} from "../../pi-embedded-helpers.js";
import {
resolveProviderRequestConfig,
sanitizeRuntimeProviderRequestOverrides,
} from "../../provider-request-config.js";
import { clampRuntimeAuthRefreshDelayMs } from "../../runtime-auth-refresh.js";
import { describeUnknownError } from "../utils.js";
import {
@@ -67,6 +71,45 @@ export function createEmbeddedRunAuthController(params: {
setThinkLevel(next: ThinkLevel): void;
log: LogLike;
}) {
const applyPreparedRuntimeRequestOverrides = (paramsForApply: {
runtimeModel: Model<Api>;
preparedAuth: {
baseUrl?: string;
request?: Parameters<typeof resolveProviderRequestConfig>[0]["request"];
};
}): void => {
if (!paramsForApply.preparedAuth.baseUrl && !paramsForApply.preparedAuth.request) {
return;
}
const runtimeRequestConfig = resolveProviderRequestConfig({
provider: paramsForApply.runtimeModel.provider,
api: paramsForApply.runtimeModel.api,
baseUrl: paramsForApply.preparedAuth.baseUrl ?? paramsForApply.runtimeModel.baseUrl,
providerHeaders:
paramsForApply.runtimeModel.headers &&
typeof paramsForApply.runtimeModel.headers === "object"
? paramsForApply.runtimeModel.headers
: undefined,
request: sanitizeRuntimeProviderRequestOverrides(paramsForApply.preparedAuth.request),
capability: "llm",
transport: "stream",
});
params.setRuntimeModel({
...paramsForApply.runtimeModel,
...(paramsForApply.preparedAuth.baseUrl
? { baseUrl: paramsForApply.preparedAuth.baseUrl }
: {}),
...(runtimeRequestConfig.headers ? { headers: runtimeRequestConfig.headers } : {}),
});
params.setEffectiveModel({
...params.getEffectiveModel(),
...(paramsForApply.preparedAuth.baseUrl
? { baseUrl: paramsForApply.preparedAuth.baseUrl }
: {}),
...(runtimeRequestConfig.headers ? { headers: runtimeRequestConfig.headers } : {}),
});
};
const hasRefreshableRuntimeAuth = () =>
Boolean(params.getRuntimeAuthState()?.sourceApiKey.trim());
@@ -128,13 +171,7 @@ export function createEmbeddedRunAuthController(params: {
);
}
params.authStorage.setRuntimeApiKey(runtimeModel.provider, preparedAuth.apiKey);
if (preparedAuth.baseUrl) {
params.setRuntimeModel({ ...runtimeModel, baseUrl: preparedAuth.baseUrl });
params.setEffectiveModel({
...params.getEffectiveModel(),
baseUrl: preparedAuth.baseUrl,
});
}
applyPreparedRuntimeRequestOverrides({ runtimeModel, preparedAuth });
params.setRuntimeAuthState({
...params.getRuntimeAuthState(),
expiresAt: preparedAuth.expiresAt,
@@ -316,10 +353,7 @@ export function createEmbeddedRunAuthController(params: {
profileId: apiKeyInfo.profileId,
},
});
if (preparedAuth?.baseUrl) {
params.setRuntimeModel({ ...runtimeModel, baseUrl: preparedAuth.baseUrl });
params.setEffectiveModel({ ...params.getEffectiveModel(), baseUrl: preparedAuth.baseUrl });
}
applyPreparedRuntimeRequestOverrides({ runtimeModel, preparedAuth: preparedAuth ?? {} });
if (preparedAuth?.apiKey) {
params.authStorage.setRuntimeApiKey(runtimeModel.provider, preparedAuth.apiKey);
params.setRuntimeAuthState({

View File

@@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest";
import {
buildProviderRequestDispatcherPolicy,
resolveProviderRequestPolicyConfig,
resolveProviderRequestConfig,
resolveProviderRequestHeaders,
sanitizeRuntimeProviderRequestOverrides,
} from "./provider-request-config.js";
describe("provider request config", () => {
@@ -46,6 +48,7 @@ describe("provider request config", () => {
});
expect(resolved.auth).toEqual({
configured: false,
mode: "authorization-bearer",
injectAuthorizationHeader: true,
});
@@ -65,6 +68,180 @@ describe("provider request config", () => {
expect(resolved.tls).toEqual({ configured: false });
expect(resolved.policy.endpointClass).toBe("openrouter");
expect(resolved.policy.attributionProvider).toBe("openrouter");
expect(resolved.extraHeaders).toEqual({
configured: false,
headers: undefined,
});
});
it("normalizes transport overrides into auth, extra headers, proxy, and tls slots", () => {
const resolved = resolveProviderRequestConfig({
provider: "custom-openai",
api: "openai-responses",
baseUrl: "https://proxy.example.com/v1",
request: {
headers: {
"X-Tenant": "acme",
},
auth: {
mode: "header",
headerName: "api-key",
value: "secret",
},
proxy: {
mode: "explicit-proxy",
url: "http://proxy.internal:8443",
tls: {
ca: "proxy-ca",
},
},
tls: {
cert: "client-cert",
key: "client-key",
serverName: "gateway.internal",
},
},
capability: "llm",
transport: "stream",
});
expect(resolved.extraHeaders).toEqual({
configured: true,
headers: {
"X-Tenant": "acme",
"api-key": "secret",
},
});
expect(resolved.auth).toEqual({
configured: true,
mode: "header",
headerName: "api-key",
value: "secret",
injectAuthorizationHeader: false,
});
expect(resolved.proxy).toEqual({
configured: true,
mode: "explicit-proxy",
proxyUrl: "http://proxy.internal:8443",
tls: {
configured: true,
ca: "proxy-ca",
},
});
expect(resolved.tls).toEqual({
configured: true,
cert: "client-cert",
key: "client-key",
serverName: "gateway.internal",
});
});
it("drops legacy Authorization when a custom auth header override is configured", () => {
const resolved = resolveProviderRequestConfig({
provider: "custom-openai",
api: "openai-responses",
baseUrl: "https://proxy.example.com/v1",
providerHeaders: {
Authorization: "Bearer stale-token",
"X-Tenant": "acme",
},
request: {
auth: {
mode: "header",
headerName: "api-key",
value: "secret",
},
},
capability: "llm",
transport: "stream",
});
expect(resolved.headers).toEqual({
"X-Tenant": "acme",
"api-key": "secret",
});
});
it("builds explicit proxy dispatcher policy from normalized transport config", () => {
const resolved = resolveProviderRequestConfig({
provider: "custom-openai",
baseUrl: "https://proxy.example.com/v1",
request: {
proxy: {
mode: "explicit-proxy",
url: "http://proxy.internal:8443",
tls: {
ca: "proxy-ca",
},
},
tls: {
cert: "client-cert",
key: "client-key",
},
},
});
expect(buildProviderRequestDispatcherPolicy(resolved)).toEqual({
mode: "explicit-proxy",
proxyUrl: "http://proxy.internal:8443",
proxyTls: {
ca: "proxy-ca",
},
});
});
it("does not copy target TLS into env proxy TLS", () => {
const resolved = resolveProviderRequestConfig({
provider: "custom-openai",
baseUrl: "https://proxy.example.com/v1",
request: {
proxy: {
mode: "env-proxy",
},
tls: {
cert: "client-cert",
key: "client-key",
serverName: "gateway.internal",
},
},
});
expect(buildProviderRequestDispatcherPolicy(resolved)).toEqual({
mode: "env-proxy",
connect: {
cert: "client-cert",
key: "client-key",
servername: "gateway.internal",
},
});
});
it("rejects insecure TLS transport overrides", () => {
expect(() =>
resolveProviderRequestConfig({
provider: "custom-openai",
baseUrl: "https://proxy.example.com/v1",
request: {
tls: {
insecureSkipVerify: true,
},
},
}),
).toThrow(/insecureskipverify/i);
});
it("rejects proxy and tls runtime auth overrides", () => {
expect(() =>
sanitizeRuntimeProviderRequestOverrides({
headers: {
"X-Tenant": "acme",
},
proxy: {
mode: "explicit-proxy",
url: "http://proxy.internal:8443",
},
}),
).toThrow(/runtime auth request overrides do not allow proxy or tls/i);
});
it("lets defaults override caller headers when requested", () => {

View File

@@ -1,5 +1,6 @@
import type { Api } from "@mariozechner/pi-ai";
import type { ModelDefinitionConfig } from "../config/types.js";
import type { PinnedDispatcherPolicy } from "../infra/net/ssrf.js";
import type {
ProviderRequestCapabilities,
ProviderRequestCapability,
@@ -13,23 +14,110 @@ import {
type RequestApi = Api | ModelDefinitionConfig["api"];
export type ResolvedProviderRequestAuthConfig = {
mode: "provider-default" | "authorization-bearer";
injectAuthorizationHeader: boolean;
export type ProviderRequestAuthOverride =
| {
mode: "provider-default";
}
| {
mode: "authorization-bearer";
token: string;
}
| {
mode: "header";
headerName: string;
value: string;
prefix?: string;
};
export type ProviderRequestTlsOverride = {
ca?: string;
cert?: string;
key?: string;
passphrase?: string;
serverName?: string;
insecureSkipVerify?: boolean;
};
export type ResolvedProviderRequestProxyConfig = {
configured: false;
export type ProviderRequestProxyOverride =
| {
mode: "env-proxy";
tls?: ProviderRequestTlsOverride;
}
| {
mode: "explicit-proxy";
url: string;
tls?: ProviderRequestTlsOverride;
};
export type ProviderRequestTransportOverrides = {
headers?: Record<string, string>;
auth?: ProviderRequestAuthOverride;
proxy?: ProviderRequestProxyOverride;
tls?: ProviderRequestTlsOverride;
};
export type ResolvedProviderRequestTlsConfig = {
configured: false;
export type ResolvedProviderRequestAuthConfig =
| {
configured: false;
mode: "provider-default" | "authorization-bearer";
injectAuthorizationHeader: boolean;
}
| {
configured: true;
mode: "authorization-bearer";
headerName: "Authorization";
value: string;
injectAuthorizationHeader: true;
}
| {
configured: true;
mode: "header";
headerName: string;
value: string;
prefix?: string;
injectAuthorizationHeader: false;
};
export type ResolvedProviderRequestProxyConfig =
| {
configured: false;
}
| {
configured: true;
mode: "env-proxy";
tls: ResolvedProviderRequestTlsConfig;
}
| {
configured: true;
mode: "explicit-proxy";
proxyUrl: string;
tls: ResolvedProviderRequestTlsConfig;
};
export type ResolvedProviderRequestTlsConfig =
| {
configured: false;
}
| {
configured: true;
ca?: string;
cert?: string;
key?: string;
passphrase?: string;
serverName?: string;
rejectUnauthorized?: boolean;
};
export type ResolvedProviderRequestExtraHeadersConfig = {
configured: boolean;
headers?: Record<string, string>;
};
export type ResolvedProviderRequestConfig = {
api?: RequestApi;
baseUrl?: string;
headers?: Record<string, string>;
extraHeaders: ResolvedProviderRequestExtraHeadersConfig;
auth: ResolvedProviderRequestAuthConfig;
proxy: ResolvedProviderRequestProxyConfig;
tls: ResolvedProviderRequestTlsConfig;
@@ -44,6 +132,10 @@ export type ResolvedProviderRequestPolicyConfig = ResolvedProviderRequestConfig
};
const FORBIDDEN_HEADER_KEYS = new Set(["__proto__", "prototype", "constructor"]);
const FORBIDDEN_INSECURE_TLS_MESSAGE =
"Provider transport overrides do not allow insecureSkipVerify";
const FORBIDDEN_RUNTIME_TRANSPORT_OVERRIDE_MESSAGE =
"Runtime auth request overrides do not allow proxy or TLS transport settings";
type ResolveProviderRequestPolicyConfigParams = {
provider?: string;
@@ -63,6 +155,7 @@ type ResolveProviderRequestPolicyConfigParams = {
} | null;
modelId?: string | null;
allowPrivateNetwork?: boolean;
request?: ProviderRequestTransportOverrides;
};
export function normalizeBaseUrl(baseUrl: string | undefined, fallback: string): string;
@@ -109,6 +202,201 @@ export function mergeProviderRequestHeaders(
return merged && Object.keys(merged).length > 0 ? merged : undefined;
}
function resolveTlsOverride(
tls: ProviderRequestTlsOverride | undefined,
): ResolvedProviderRequestTlsConfig {
if (!tls) {
return { configured: false };
}
if (tls.insecureSkipVerify === true) {
throw new Error(FORBIDDEN_INSECURE_TLS_MESSAGE);
}
const ca = tls.ca?.trim();
const cert = tls.cert?.trim();
const key = tls.key?.trim();
const passphrase = tls.passphrase?.trim();
const serverName = tls.serverName?.trim();
const rejectUnauthorized = tls.insecureSkipVerify === false ? true : undefined;
if (!ca && !cert && !key && !passphrase && !serverName && rejectUnauthorized === undefined) {
return { configured: false };
}
return {
configured: true,
...(ca ? { ca } : {}),
...(cert ? { cert } : {}),
...(key ? { key } : {}),
...(passphrase ? { passphrase } : {}),
...(serverName ? { serverName } : {}),
...(rejectUnauthorized !== undefined ? { rejectUnauthorized } : {}),
};
}
function resolveAuthOverride(params: {
authHeader?: boolean;
request?: ProviderRequestTransportOverrides;
}): ResolvedProviderRequestAuthConfig {
const auth = params.request?.auth;
if (auth?.mode === "authorization-bearer") {
const value = auth.token.trim();
if (value) {
return {
configured: true,
mode: "authorization-bearer",
headerName: "Authorization",
value,
injectAuthorizationHeader: true,
};
}
}
if (auth?.mode === "header") {
const headerName = auth.headerName.trim();
const value = auth.value.trim();
const prefix = auth.prefix?.trim();
if (headerName && value) {
return {
configured: true,
mode: "header",
headerName,
value,
...(prefix ? { prefix } : {}),
injectAuthorizationHeader: false,
};
}
}
return {
configured: false,
mode: params.authHeader ? "authorization-bearer" : "provider-default",
injectAuthorizationHeader: params.authHeader === true,
};
}
export function sanitizeRuntimeProviderRequestOverrides(
request: ProviderRequestTransportOverrides | undefined,
): ProviderRequestTransportOverrides | undefined {
if (!request) {
return undefined;
}
if (request.proxy || request.tls) {
throw new Error(FORBIDDEN_RUNTIME_TRANSPORT_OVERRIDE_MESSAGE);
}
const headers = request.headers;
const auth = request.auth;
if (!headers && !auth) {
return undefined;
}
return {
...(headers ? { headers } : {}),
...(auth ? { auth } : {}),
};
}
function resolveProxyOverride(
request: ProviderRequestTransportOverrides | undefined,
): ResolvedProviderRequestProxyConfig {
const proxy = request?.proxy;
if (!proxy) {
return { configured: false };
}
const tls = resolveTlsOverride(proxy.tls);
if (proxy.mode === "env-proxy") {
return {
configured: true,
mode: "env-proxy",
tls,
};
}
const proxyUrl = proxy.url.trim();
if (!proxyUrl) {
return { configured: false };
}
return {
configured: true,
mode: "explicit-proxy",
proxyUrl,
tls,
};
}
function applyResolvedAuthHeader(
headers: Record<string, string> | undefined,
auth: ResolvedProviderRequestAuthConfig,
): Record<string, string> | undefined {
if (!auth.configured) {
return headers;
}
const next = mergeProviderRequestHeaders(headers) ?? Object.create(null);
const keysToDelete = new Set([auth.headerName.toLowerCase()]);
if (auth.mode === "header") {
keysToDelete.add("authorization");
}
for (const key of Object.keys(next)) {
if (keysToDelete.has(key.toLowerCase())) {
delete next[key];
}
}
next[auth.headerName] =
auth.mode === "authorization-bearer"
? `Bearer ${auth.value}`
: `${auth.prefix ?? ""}${auth.value}`;
return Object.keys(next).length > 0 ? next : undefined;
}
function toTlsConnectOptions(
tls: ResolvedProviderRequestTlsConfig,
): Record<string, unknown> | undefined {
if (!tls.configured) {
return undefined;
}
const next: Record<string, unknown> = {};
if (tls.ca) {
next.ca = tls.ca;
}
if (tls.cert) {
next.cert = tls.cert;
}
if (tls.key) {
next.key = tls.key;
}
if (tls.passphrase) {
next.passphrase = tls.passphrase;
}
if (tls.serverName) {
next.servername = tls.serverName;
}
if (tls.rejectUnauthorized !== undefined) {
next.rejectUnauthorized = tls.rejectUnauthorized;
}
return Object.keys(next).length > 0 ? next : undefined;
}
export function buildProviderRequestDispatcherPolicy(
request: Pick<ResolvedProviderRequestConfig, "proxy" | "tls">,
): PinnedDispatcherPolicy | undefined {
const targetTls = toTlsConnectOptions(request.tls);
if (!request.proxy.configured) {
return targetTls ? { mode: "direct", connect: targetTls } : undefined;
}
const proxiedTls = toTlsConnectOptions(request.proxy.tls);
if (request.proxy.mode === "env-proxy") {
return {
mode: "env-proxy",
...(targetTls ? { connect: { ...targetTls } } : {}),
...(proxiedTls ? { proxyTls: { ...proxiedTls } } : {}),
};
}
return {
mode: "explicit-proxy",
proxyUrl: request.proxy.proxyUrl,
...(proxiedTls ? { proxyTls: proxiedTls } : {}),
};
}
export function buildProviderRequestTlsClientOptions(
request: Pick<ResolvedProviderRequestConfig, "tls">,
): Record<string, unknown> | undefined {
return toTlsConnectOptions(request.tls);
}
export function resolveProviderRequestPolicyConfig(
params: ResolveProviderRequestPolicyConfigParams,
): ResolvedProviderRequestPolicyConfig {
@@ -128,10 +416,18 @@ export function resolveProviderRequestPolicyConfig(
compat: params.compat,
modelId: params.modelId,
});
const defaultHeaders = mergeProviderRequestHeaders(
params.discoveredHeaders,
params.providerHeaders,
params.modelHeaders,
const auth = resolveAuthOverride({
authHeader: params.authHeader,
request: params.request,
});
const extraHeaders = applyResolvedAuthHeader(
mergeProviderRequestHeaders(
params.discoveredHeaders,
params.providerHeaders,
params.modelHeaders,
params.request?.headers,
),
auth,
);
const protectedAttributionKeys = new Set(
Object.keys(policy.attributionHeaders ?? {}).map((key) => key.toLowerCase()),
@@ -143,7 +439,7 @@ export function resolveProviderRequestPolicyConfig(
),
)
: undefined;
const mergedDefaults = mergeProviderRequestHeaders(defaultHeaders, policy.attributionHeaders);
const mergedDefaults = mergeProviderRequestHeaders(extraHeaders, policy.attributionHeaders);
const headers =
params.precedence === "caller-wins"
? mergeProviderRequestHeaders(mergedDefaults, unprotectedCallerHeaders)
@@ -153,12 +449,13 @@ export function resolveProviderRequestPolicyConfig(
api: params.api,
baseUrl,
headers,
auth: {
mode: params.authHeader ? "authorization-bearer" : "provider-default",
injectAuthorizationHeader: params.authHeader === true,
extraHeaders: {
configured: Boolean(extraHeaders),
headers: extraHeaders,
},
proxy: { configured: false },
tls: { configured: false },
auth,
proxy: resolveProxyOverride(params.request),
tls: resolveTlsOverride(params.request?.tls),
policy,
capabilities,
allowPrivateNetwork: params.allowPrivateNetwork ?? Boolean(params.baseUrl?.trim()),
@@ -175,6 +472,7 @@ export function resolveProviderRequestConfig(params: {
providerHeaders?: Record<string, string>;
modelHeaders?: Record<string, string>;
authHeader?: boolean;
request?: ProviderRequestTransportOverrides;
}): ResolvedProviderRequestConfig {
const resolved = resolveProviderRequestPolicyConfig(params);
return {
@@ -183,11 +481,8 @@ export function resolveProviderRequestConfig(params: {
// Model resolution intentionally excludes attribution headers. Those are
// applied later at transport/request time so native-host gating stays tied
// to the final resolved route instead of the catalog/config merge step.
headers: mergeProviderRequestHeaders(
params.discoveredHeaders,
params.providerHeaders,
params.modelHeaders,
),
headers: resolved.extraHeaders.headers,
extraHeaders: resolved.extraHeaders,
auth: resolved.auth,
proxy: resolved.proxy,
tls: resolved.tls,
@@ -204,6 +499,7 @@ export function resolveProviderRequestHeaders(params: {
callerHeaders?: Record<string, string>;
defaultHeaders?: Record<string, string>;
precedence?: ProviderRequestHeaderPrecedence;
request?: ProviderRequestTransportOverrides;
}): Record<string, string> | undefined {
return resolveProviderRequestPolicyConfig({
provider: params.provider,
@@ -214,5 +510,6 @@ export function resolveProviderRequestHeaders(params: {
callerHeaders: params.callerHeaders,
providerHeaders: params.defaultHeaders,
precedence: params.precedence,
request: params.request,
}).headers;
}

View File

@@ -157,4 +157,50 @@ describe("spawnSubagentDirect seam flow", () => {
);
expect(operations.indexOf("gateway:agent")).toBeGreaterThan(operations.indexOf("store:update"));
});
it("pins admin-only methods to operator.admin and preserves least-privilege for others (#59428)", async () => {
const capturedCalls: Array<{ method?: string; scopes?: string[] }> = [];
hoisted.callGatewayMock.mockImplementation(
async (request: { method?: string; scopes?: string[] }) => {
capturedCalls.push({ method: request.method, scopes: request.scopes });
if (request.method === "agent") {
return { runId: "run-1" };
}
if (request.method?.startsWith("sessions.")) {
return { ok: true };
}
return {};
},
);
installSessionStoreCaptureMock(hoisted.updateSessionStoreMock);
const result = await spawnSubagentDirect(
{
task: "verify per-method scope routing",
model: "openai-codex/gpt-5.4",
},
{
agentSessionKey: "agent:main:main",
agentChannel: "discord",
agentAccountId: "acct-1",
agentTo: "user-1",
workspaceDir: "/tmp/requester-workspace",
},
);
expect(result.status).toBe("accepted");
expect(capturedCalls.length).toBeGreaterThan(0);
for (const call of capturedCalls) {
if (call.method === "sessions.patch" || call.method === "sessions.delete") {
// Admin-only methods must be pinned to operator.admin.
expect(call.scopes).toEqual(["operator.admin"]);
} else {
// Non-admin methods (e.g. "agent") must NOT be forced to admin scope
// so the gateway preserves least-privilege and senderIsOwner stays false.
expect(call.scopes).toBeUndefined();
}
}
});
});

View File

@@ -5,6 +5,7 @@ import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js";
import { loadConfig } from "../config/config.js";
import { mergeSessionEntry, updateSessionStore } from "../config/sessions.js";
import { callGateway } from "../gateway/call.js";
import { ADMIN_SCOPE, isAdminOnlyMethod } from "../gateway/method-scopes.js";
import {
pruneLegacyStoreKeys,
resolveGatewaySessionStoreTarget,
@@ -148,7 +149,21 @@ async function updateSubagentSessionStore(
async function callSubagentGateway(
params: Parameters<typeof callGateway>[0],
): Promise<Awaited<ReturnType<typeof callGateway>>> {
return await subagentSpawnDeps.callGateway(params);
// Subagent lifecycle requires methods spanning multiple scope tiers
// (sessions.patch / sessions.delete → admin, agent → write). When each call
// independently negotiates least-privilege scopes the first connection pairs
// at a lower tier and every subsequent higher-tier call triggers a
// scope-upgrade handshake that headless gateway-client connections cannot
// complete interactively, causing close(1008) "pairing required" (#59428).
//
// Only admin-only methods are pinned to ADMIN_SCOPE; other methods (e.g.
// "agent" → write) keep their least-privilege scope so that the gateway does
// not treat the caller as owner (senderIsOwner) and expose owner-only tools.
const scopes = params.scopes ?? (isAdminOnlyMethod(params.method) ? [ADMIN_SCOPE] : undefined);
return await subagentSpawnDeps.callGateway({
...params,
...(scopes != null ? { scopes } : {}),
});
}
function readGatewayRunId(response: Awaited<ReturnType<typeof callGateway>>): string | undefined {

View File

@@ -8,12 +8,14 @@ const {
readConfigFileSnapshotMock,
validateConfigObjectWithPluginsMock,
writeConfigFileMock,
buildPluginStatusReportMock,
buildPluginSnapshotReportMock,
buildPluginDiagnosticsReportMock,
} = vi.hoisted(() => ({
readConfigFileSnapshotMock: vi.fn(),
validateConfigObjectWithPluginsMock: vi.fn(),
writeConfigFileMock: vi.fn(),
buildPluginStatusReportMock: vi.fn(),
buildPluginSnapshotReportMock: vi.fn(),
buildPluginDiagnosticsReportMock: vi.fn(),
}));
vi.mock("../../config/config.js", async () => {
@@ -32,7 +34,8 @@ vi.mock("../../plugins/status.js", async () => {
await vi.importActual<typeof import("../../plugins/status.js")>("../../plugins/status.js");
return {
...actual,
buildPluginStatusReport: buildPluginStatusReportMock,
buildPluginSnapshotReport: buildPluginSnapshotReportMock,
buildPluginDiagnosticsReport: buildPluginDiagnosticsReportMock,
};
});
@@ -56,7 +59,8 @@ describe("handleCommands /plugins toggle", () => {
readConfigFileSnapshotMock.mockReset();
validateConfigObjectWithPluginsMock.mockReset();
writeConfigFileMock.mockReset();
buildPluginStatusReportMock.mockReset();
buildPluginSnapshotReportMock.mockReset();
buildPluginDiagnosticsReportMock.mockReset();
});
it("enables a discovered plugin", async () => {
@@ -66,7 +70,7 @@ describe("handleCommands /plugins toggle", () => {
path: "/tmp/openclaw.json",
resolved: config,
});
buildPluginStatusReportMock.mockReturnValue(
buildPluginDiagnosticsReportMock.mockReturnValue(
createPluginStatusReport({
workspaceDir: "/tmp/workspace",
plugins: [
@@ -106,7 +110,7 @@ describe("handleCommands /plugins toggle", () => {
path: "/tmp/openclaw.json",
resolved: config,
});
buildPluginStatusReportMock.mockReturnValue(
buildPluginDiagnosticsReportMock.mockReturnValue(
createPluginStatusReport({
workspaceDir: "/tmp/workspace",
plugins: [
@@ -137,4 +141,53 @@ describe("handleCommands /plugins toggle", () => {
}),
);
});
it("resolves write targets by runtime-derived plugin name", async () => {
const config = buildCfg();
readConfigFileSnapshotMock.mockResolvedValue({
valid: true,
path: "/tmp/openclaw.json",
resolved: config,
});
buildPluginSnapshotReportMock.mockReturnValue(
createPluginStatusReport({
workspaceDir: "/tmp/workspace",
plugins: [
createPluginRecord({
id: "superpowers",
name: "superpowers",
format: "bundle",
source: WORKSPACE_PLUGIN_ROOT,
enabled: false,
status: "disabled",
}),
],
}),
);
buildPluginDiagnosticsReportMock.mockReturnValue(
createPluginStatusReport({
workspaceDir: "/tmp/workspace",
plugins: [
createPluginRecord({
id: "superpowers",
name: "Super Powers",
format: "bundle",
source: WORKSPACE_PLUGIN_ROOT,
enabled: false,
status: "disabled",
}),
],
}),
);
validateConfigObjectWithPluginsMock.mockImplementation((next) => ({ ok: true, config: next }));
writeConfigFileMock.mockResolvedValue(undefined);
const params = buildCommandTestParams("/plugins enable Super Powers", buildCfg());
params.command.senderIsOwner = true;
const result = await handleCommands(params);
expect(result.reply?.text).toContain('Plugin "superpowers" enabled');
expect(buildPluginDiagnosticsReportMock).toHaveBeenCalled();
expect(buildPluginSnapshotReportMock).not.toHaveBeenCalled();
});
});

View File

@@ -22,8 +22,9 @@ import { clearPluginManifestRegistryCache } from "../../plugins/manifest-registr
import type { PluginRecord } from "../../plugins/registry.js";
import {
buildAllPluginInspectReports,
buildPluginDiagnosticsReport,
buildPluginInspectReport,
buildPluginStatusReport,
buildPluginSnapshotReport,
formatPluginCompatibilityNotice,
type PluginStatusReport,
} from "../../plugins/status.js";
@@ -272,7 +273,10 @@ async function installPluginFromPluginsCommand(params: {
return { ok: true, pluginId: result.pluginId };
}
async function loadPluginCommandState(workspaceDir: string): Promise<
async function loadPluginCommandState(
workspaceDir: string,
options?: { loadModules?: boolean },
): Promise<
| {
ok: true;
path: string;
@@ -294,7 +298,10 @@ async function loadPluginCommandState(workspaceDir: string): Promise<
ok: true,
path: snapshot.path,
config,
report: buildPluginStatusReport({ config, workspaceDir }),
report:
options?.loadModules === true
? buildPluginDiagnosticsReport({ config, workspaceDir })
: buildPluginSnapshotReport({ config, workspaceDir }),
};
}
@@ -331,7 +338,9 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm
};
}
const loaded = await loadPluginCommandState(params.workspaceDir);
const loaded = await loadPluginCommandState(params.workspaceDir, {
loadModules: pluginsCommand.action !== "list",
});
if (!loaded.ok) {
return {
shouldContinue: false,

View File

@@ -115,10 +115,10 @@ function hasEnvConfiguredChannel(env: NodeJS.ProcessEnv): boolean {
}
export function hasPotentialConfiguredChannels(
cfg: OpenClawConfig,
cfg: OpenClawConfig | null | undefined,
env: NodeJS.ProcessEnv = process.env,
): boolean {
const channels = isRecord(cfg.channels) ? cfg.channels : null;
const channels = isRecord(cfg?.channels) ? cfg.channels : null;
if (channels) {
for (const [key, value] of Object.entries(channels)) {
if (IGNORED_CHANNEL_CONFIG_KEYS.has(key)) {

View File

@@ -436,7 +436,8 @@ describe("exec approvals CLI", () => {
effective: "always",
}),
askFallback: expect.objectContaining({
source: "OpenClaw default (deny)",
effective: "full",
source: "OpenClaw default (full)",
}),
}),
]),

View File

@@ -10,7 +10,7 @@ import {
import { resolveHookEntries } from "../hooks/policy.js";
import type { HookEntry } from "../hooks/types.js";
import { loadWorkspaceHookEntries } from "../hooks/workspace.js";
import { buildPluginStatusReport } from "../plugins/status.js";
import { buildPluginDiagnosticsReport } from "../plugins/status.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { getTerminalTableWidth, renderTable } from "../terminal/table.js";
@@ -46,7 +46,7 @@ function mergeHookEntries(pluginEntries: HookEntry[], workspaceEntries: HookEntr
function buildHooksReport(config: OpenClawConfig): HookStatusReport {
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const workspaceEntries = loadWorkspaceHookEntries(workspaceDir, { config });
const pluginReport = buildPluginStatusReport({ config, workspaceDir });
const pluginReport = buildPluginDiagnosticsReport({ config, workspaceDir });
const pluginEntries = pluginReport.hooks.map((hook) => hook.entry);
const entries = mergeHookEntries(pluginEntries, workspaceEntries);
return buildWorkspaceHookStatus(workspaceDir, { config, entries });

View File

@@ -18,7 +18,9 @@ export const resolveMarketplaceInstallShortcut = vi.fn();
export const enablePluginInConfig = vi.fn();
export const recordPluginInstall = vi.fn();
export const clearPluginManifestRegistryCache = vi.fn();
export const buildPluginStatusReport = vi.fn();
export const buildPluginSnapshotReport = vi.fn();
export const buildPluginDiagnosticsReport = vi.fn();
export const buildPluginCompatibilityNotices = vi.fn();
export const applyExclusiveSlotSelection = vi.fn();
export const uninstallPlugin = vi.fn();
export const updateNpmInstalledPlugins = vi.fn();
@@ -72,7 +74,9 @@ vi.mock("../plugins/manifest-registry.js", () => ({
}));
vi.mock("../plugins/status.js", () => ({
buildPluginStatusReport: (...args: unknown[]) => buildPluginStatusReport(...args),
buildPluginSnapshotReport: (...args: unknown[]) => buildPluginSnapshotReport(...args),
buildPluginDiagnosticsReport: (...args: unknown[]) => buildPluginDiagnosticsReport(...args),
buildPluginCompatibilityNotices: (...args: unknown[]) => buildPluginCompatibilityNotices(...args),
}));
vi.mock("../plugins/slots.js", () => ({
@@ -154,7 +158,9 @@ export function resetPluginsCliTestState() {
enablePluginInConfig.mockReset();
recordPluginInstall.mockReset();
clearPluginManifestRegistryCache.mockReset();
buildPluginStatusReport.mockReset();
buildPluginSnapshotReport.mockReset();
buildPluginDiagnosticsReport.mockReset();
buildPluginCompatibilityNotices.mockReset();
applyExclusiveSlotSelection.mockReset();
uninstallPlugin.mockReset();
updateNpmInstalledPlugins.mockReset();
@@ -199,10 +205,13 @@ export function resetPluginsCliTestState() {
});
enablePluginInConfig.mockImplementation((cfg: OpenClawConfig) => ({ config: cfg }));
recordPluginInstall.mockImplementation((cfg: OpenClawConfig) => cfg);
buildPluginStatusReport.mockReturnValue({
const defaultPluginReport = {
plugins: [],
diagnostics: [],
});
};
buildPluginSnapshotReport.mockReturnValue(defaultPluginReport);
buildPluginDiagnosticsReport.mockReturnValue(defaultPluginReport);
buildPluginCompatibilityNotices.mockReturnValue([]);
applyExclusiveSlotSelection.mockImplementation(({ config }: { config: OpenClawConfig }) => ({
config,
warnings: [],

View File

@@ -3,7 +3,7 @@ import { installedPluginRoot } from "../../test/helpers/bundled-plugin-paths.js"
import type { OpenClawConfig } from "../config/config.js";
import {
applyExclusiveSlotSelection,
buildPluginStatusReport,
buildPluginDiagnosticsReport,
clearPluginManifestRegistryCache,
enablePluginInConfig,
installHooksFromNpmSpec,
@@ -179,7 +179,7 @@ describe("plugins cli install", () => {
});
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(installedCfg);
buildPluginStatusReport.mockReturnValue({
buildPluginDiagnosticsReport.mockReturnValue({
plugins: [{ id: "alpha", kind: "provider" }],
diagnostics: [],
});

View File

@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it } from "vitest";
import { createPluginRecord } from "../plugins/status.test-helpers.js";
import {
buildPluginDiagnosticsReport,
buildPluginSnapshotReport,
resetPluginsCliTestState,
runPluginsCommand,
runtimeLogs,
} from "./plugins-cli-test-helpers.js";
describe("plugins cli list", () => {
beforeEach(() => {
resetPluginsCliTestState();
});
it("includes imported state in JSON output", async () => {
buildPluginSnapshotReport.mockReturnValue({
workspaceDir: "/workspace",
plugins: [
createPluginRecord({
id: "demo",
imported: true,
activated: true,
explicitlyEnabled: true,
}),
],
diagnostics: [],
});
await runPluginsCommand(["plugins", "list", "--json"]);
expect(buildPluginSnapshotReport).toHaveBeenCalledWith();
expect(JSON.parse(runtimeLogs[0] ?? "null")).toEqual({
workspaceDir: "/workspace",
plugins: [
expect.objectContaining({
id: "demo",
imported: true,
activated: true,
explicitlyEnabled: true,
}),
],
diagnostics: [],
});
});
it("shows imported state in verbose output", async () => {
buildPluginSnapshotReport.mockReturnValue({
plugins: [
createPluginRecord({
id: "demo",
name: "Demo Plugin",
imported: false,
activated: true,
explicitlyEnabled: false,
}),
],
diagnostics: [],
});
await runPluginsCommand(["plugins", "list", "--verbose"]);
expect(buildPluginSnapshotReport).toHaveBeenCalledWith();
const output = runtimeLogs.join("\n");
expect(output).toContain("activated: yes");
expect(output).toContain("imported: no");
expect(output).toContain("explicitly enabled: no");
});
it("keeps doctor on a module-loading snapshot", async () => {
buildPluginDiagnosticsReport.mockReturnValue({
plugins: [],
diagnostics: [],
});
await runPluginsCommand(["plugins", "doctor"]);
expect(buildPluginDiagnosticsReport).toHaveBeenCalledWith();
expect(runtimeLogs).toContain("No plugin issues detected.");
});
});

View File

@@ -12,9 +12,10 @@ import type { PluginRecord } from "../plugins/registry.js";
import { formatPluginSourceForTable, resolvePluginSourceRoots } from "../plugins/source-display.js";
import {
buildAllPluginInspectReports,
buildPluginDiagnosticsReport,
buildPluginCompatibilityNotices,
buildPluginInspectReport,
buildPluginStatusReport,
buildPluginSnapshotReport,
formatPluginCompatibilityNotice,
} from "../plugins/status.js";
import {
@@ -140,6 +141,9 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string {
if (plugin.activated !== undefined) {
parts.push(` activated: ${plugin.activated ? "yes" : "no"}`);
}
if (plugin.imported !== undefined) {
parts.push(` imported: ${plugin.imported ? "yes" : "no"}`);
}
if (plugin.explicitlyEnabled !== undefined) {
parts.push(` explicitly enabled: ${plugin.explicitlyEnabled ? "yes" : "no"}`);
}
@@ -236,7 +240,7 @@ export function registerPluginsCli(program: Command) {
.option("--enabled", "Only show enabled plugins", false)
.option("--verbose", "Show detailed entries", false)
.action((opts: PluginsListOptions) => {
const report = buildPluginStatusReport();
const report = buildPluginSnapshotReport();
const list = opts.enabled
? report.plugins.filter((p) => p.status === "loaded")
: report.plugins;
@@ -338,7 +342,7 @@ export function registerPluginsCli(program: Command) {
.option("--json", "Print JSON")
.action((id: string | undefined, opts: PluginInspectOptions) => {
const cfg = loadConfig();
const report = buildPluginStatusReport({ config: cfg });
const report = buildPluginDiagnosticsReport({ config: cfg });
if (opts.all) {
if (id) {
defaultRuntime.error("Pass either a plugin id or --all, not both.");
@@ -603,7 +607,7 @@ export function registerPluginsCli(program: Command) {
.action(async (id: string, opts: PluginUninstallOptions) => {
const snapshot = await readConfigFileSnapshot();
const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
const report = buildPluginStatusReport({ config: cfg });
const report = buildPluginDiagnosticsReport({ config: cfg });
const extensionsDir = path.join(resolveStateDir(process.env, os.homedir), "extensions");
const keepFiles = Boolean(opts.keepFiles || opts.keepConfig);
@@ -790,7 +794,7 @@ export function registerPluginsCli(program: Command) {
.command("doctor")
.description("Report plugin load issues")
.action(() => {
const report = buildPluginStatusReport();
const report = buildPluginDiagnosticsReport();
const errors = report.plugins.filter((p) => p.status === "error");
const diags = report.diagnostics.filter((d) => d.level === "error");
const compatibility = buildPluginCompatibilityNotices({ report });

View File

@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest";
import { installedPluginRoot } from "../../test/helpers/bundled-plugin-paths.js";
import type { OpenClawConfig } from "../config/config.js";
import {
buildPluginStatusReport,
buildPluginDiagnosticsReport,
loadConfig,
parseClawHubPluginSpec,
promptYesNo,
@@ -39,7 +39,7 @@ describe("plugins cli uninstall", () => {
},
},
} as OpenClawConfig);
buildPluginStatusReport.mockReturnValue({
buildPluginDiagnosticsReport.mockReturnValue({
plugins: [{ id: "alpha", name: "alpha" }],
diagnostics: [],
});
@@ -74,7 +74,7 @@ describe("plugins cli uninstall", () => {
} as OpenClawConfig;
loadConfig.mockReturnValue(baseConfig);
buildPluginStatusReport.mockReturnValue({
buildPluginDiagnosticsReport.mockReturnValue({
plugins: [{ id: "alpha", name: "alpha" }],
diagnostics: [],
});
@@ -111,7 +111,7 @@ describe("plugins cli uninstall", () => {
installs: {},
},
} as OpenClawConfig);
buildPluginStatusReport.mockReturnValue({
buildPluginDiagnosticsReport.mockReturnValue({
plugins: [{ id: "alpha", name: "alpha" }],
diagnostics: [],
});
@@ -139,7 +139,7 @@ describe("plugins cli uninstall", () => {
},
},
} as OpenClawConfig);
buildPluginStatusReport.mockReturnValue({
buildPluginDiagnosticsReport.mockReturnValue({
plugins: [{ id: "linkmind-context", name: "linkmind-context" }],
diagnostics: [],
});
@@ -170,7 +170,7 @@ describe("plugins cli uninstall", () => {
},
},
} as OpenClawConfig);
buildPluginStatusReport.mockReturnValue({
buildPluginDiagnosticsReport.mockReturnValue({
plugins: [{ id: "linkmind-context", name: "linkmind-context" }],
diagnostics: [],
});

View File

@@ -4,7 +4,7 @@ import type { PluginInstallRecord } from "../config/types.plugins.js";
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub.js";
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
import { buildPluginStatusReport } from "../plugins/status.js";
import { buildPluginDiagnosticsReport } from "../plugins/status.js";
import { defaultRuntime } from "../runtime.js";
import { theme } from "../terminal/theme.js";
@@ -40,7 +40,7 @@ export function applySlotSelectionForPlugin(
config: OpenClawConfig,
pluginId: string,
): { config: OpenClawConfig; warnings: string[] } {
const report = buildPluginStatusReport({ config });
const report = buildPluginDiagnosticsReport({ config });
const plugin = report.plugins.find((entry) => entry.id === pluginId);
if (!plugin) {
return { config, warnings: [] };

View File

@@ -75,8 +75,8 @@ function execAskRank(value: ExecAsk): number {
function collectExecPolicyConflictWarnings(cfg: OpenClawConfig): string[] {
const warnings: string[] = [];
const approvals = loadExecApprovals();
const defaultRequestedSecuritySource = "OpenClaw default (allowlist)";
const defaultRequestedAskSource = "OpenClaw default (on-miss)";
const defaultRequestedSecuritySource = "OpenClaw default (full)";
const defaultRequestedAskSource = "OpenClaw default (off)";
const maybeWarn = (params: {
scopeLabel: string;

View File

@@ -11,7 +11,7 @@ const mocks = vi.hoisted(() => ({
resolveAgentWorkspaceDir: vi.fn(),
resolveDefaultAgentId: vi.fn(),
buildWorkspaceSkillStatus: vi.fn(),
buildPluginStatusReport: vi.fn(),
buildPluginDiagnosticsReport: vi.fn(),
buildPluginCompatibilityWarnings: vi.fn(),
listTaskFlowRecords: vi.fn<() => unknown[]>(() => []),
listTasksForFlowId: vi.fn<(flowId: string) => unknown[]>((_flowId: string) => []),
@@ -27,7 +27,7 @@ vi.mock("../agents/skills-status.js", () => ({
}));
vi.mock("../plugins/status.js", () => ({
buildPluginStatusReport: (...args: unknown[]) => mocks.buildPluginStatusReport(...args),
buildPluginDiagnosticsReport: (...args: unknown[]) => mocks.buildPluginDiagnosticsReport(...args),
buildPluginCompatibilityWarnings: (...args: unknown[]) =>
mocks.buildPluginCompatibilityWarnings(...args),
}));
@@ -53,7 +53,7 @@ async function runNoteWorkspaceStatusForTest(
mocks.buildWorkspaceSkillStatus.mockReturnValue({
skills: [],
});
mocks.buildPluginStatusReport.mockReturnValue({
mocks.buildPluginDiagnosticsReport.mockReturnValue({
workspaceDir: "/workspace",
...loadResult,
});
@@ -85,7 +85,7 @@ describe("noteWorkspaceStatus", () => {
}),
);
try {
expect(mocks.buildPluginStatusReport).toHaveBeenCalledWith({
expect(mocks.buildPluginDiagnosticsReport).toHaveBeenCalledWith({
config: {},
workspaceDir: "/workspace",
});
@@ -124,6 +124,30 @@ describe("noteWorkspaceStatus", () => {
}
});
it("includes imported plugin counts in the plugins note", async () => {
const noteSpy = await runNoteWorkspaceStatusForTest(
createPluginLoadResult({
plugins: [
createPluginRecord({
id: "imported-plugin",
imported: true,
}),
createPluginRecord({
id: "cold-plugin",
imported: false,
}),
],
}),
);
try {
const pluginCalls = noteSpy.mock.calls.filter(([, title]) => title === "Plugins");
expect(pluginCalls).toHaveLength(1);
expect(String(pluginCalls[0]?.[0])).toContain("Imported: 1");
} finally {
noteSpy.mockRestore();
}
});
it("omits plugin compatibility note when no legacy compatibility paths are present", async () => {
const noteSpy = await runNoteWorkspaceStatusForTest(
createPluginLoadResult({
@@ -158,6 +182,10 @@ describe("noteWorkspaceStatus", () => {
"legacy-plugin still uses legacy before_agent_start",
]);
try {
expect(mocks.buildPluginDiagnosticsReport).toHaveBeenCalledWith({
config: {},
workspaceDir: "/workspace",
});
expect(mocks.buildPluginCompatibilityWarnings).toHaveBeenCalledWith({
config: {},
workspaceDir: "/workspace",

View File

@@ -2,7 +2,10 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import { buildPluginCompatibilityWarnings, buildPluginStatusReport } from "../plugins/status.js";
import {
buildPluginCompatibilityWarnings,
buildPluginDiagnosticsReport,
} from "../plugins/status.js";
import { listTasksForFlowId } from "../tasks/runtime-internal.js";
import { listTaskFlowRecords } from "../tasks/task-flow-runtime-internal.js";
import { note } from "../terminal/note.js";
@@ -69,7 +72,7 @@ export function noteWorkspaceStatus(cfg: OpenClawConfig) {
"Skills status",
);
const pluginRegistry = buildPluginStatusReport({
const pluginRegistry = buildPluginDiagnosticsReport({
config: cfg,
workspaceDir,
});
@@ -77,9 +80,11 @@ export function noteWorkspaceStatus(cfg: OpenClawConfig) {
const loaded = pluginRegistry.plugins.filter((p) => p.status === "loaded");
const disabled = pluginRegistry.plugins.filter((p) => p.status === "disabled");
const errored = pluginRegistry.plugins.filter((p) => p.status === "error");
const imported = pluginRegistry.plugins.filter((p) => p.imported);
const lines = [
`Loaded: ${loaded.length}`,
`Imported: ${imported.length}`,
`Disabled: ${disabled.length}`,
`Errors: ${errored.length}`,
errored.length > 0

View File

@@ -121,6 +121,17 @@ afterEach(() => {
});
describe("applyPluginAutoEnable", () => {
it("treats an undefined config as empty", () => {
const result = applyPluginAutoEnable({
config: undefined,
env: {},
});
expect(result.config).toEqual({});
expect(result.changes).toEqual([]);
expect(result.autoEnabledReasons).toEqual({});
});
it("auto-enables built-in channels without appending to plugins.allow", () => {
const result = applyWithSlackConfig({ plugins: { allow: ["telegram"] } });

View File

@@ -645,7 +645,7 @@ function formatAutoEnableChange(entry: PluginEnableChange): string {
}
export function applyPluginAutoEnable(params: {
config: OpenClawConfig;
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
/** Pre-loaded manifest registry. When omitted, the registry is loaded from
* the installed plugins on disk. Pass an explicit registry in tests to
@@ -653,20 +653,21 @@ export function applyPluginAutoEnable(params: {
manifestRegistry?: PluginManifestRegistry;
}): PluginAutoEnableResult {
const env = params.env ?? process.env;
if (!configMayNeedPluginAutoEnable(params.config, env)) {
return { config: params.config, changes: [], autoEnabledReasons: {} };
const config = params.config ?? ({} as OpenClawConfig);
if (!configMayNeedPluginAutoEnable(config, env)) {
return { config, changes: [], autoEnabledReasons: {} };
}
const registry =
params.manifestRegistry ??
(configMayNeedPluginManifestRegistry(params.config)
? loadPluginManifestRegistry({ config: params.config, env })
(configMayNeedPluginManifestRegistry(config)
? loadPluginManifestRegistry({ config, env })
: EMPTY_PLUGIN_MANIFEST_REGISTRY);
const configured = resolveConfiguredPlugins(params.config, env, registry);
const configured = resolveConfiguredPlugins(config, env, registry);
if (configured.length === 0) {
return { config: params.config, changes: [], autoEnabledReasons: {} };
return { config, changes: [], autoEnabledReasons: {} };
}
let next = params.config;
let next = config;
const changes: string[] = [];
const autoEnabledReasons = new Map<string, string[]>();

View File

@@ -236,6 +236,28 @@ describe("loadDotEnv", () => {
});
});
it("blocks pinned helper interpreter vars from workspace .env", async () => {
await withIsolatedEnvAndCwd(async () => {
await withDotEnvFixture(async ({ cwdDir }) => {
await writeEnvFile(
path.join(cwdDir, ".env"),
[
"OPENCLAW_PINNED_PYTHON=./attacker-python",
"OPENCLAW_PINNED_WRITE_PYTHON=./attacker-write-python",
].join("\n"),
);
delete process.env.OPENCLAW_PINNED_PYTHON;
delete process.env.OPENCLAW_PINNED_WRITE_PYTHON;
loadWorkspaceDotEnvFile(path.join(cwdDir, ".env"), { quiet: true });
expect(process.env.OPENCLAW_PINNED_PYTHON).toBeUndefined();
expect(process.env.OPENCLAW_PINNED_WRITE_PYTHON).toBeUndefined();
});
});
});
it("blocks bundled trust-root vars from workspace .env", async () => {
await withIsolatedEnvAndCwd(async () => {
await withDotEnvFixture(async ({ cwdDir }) => {
@@ -266,16 +288,25 @@ describe("loadDotEnv", () => {
await withDotEnvFixture(async ({ cwdDir, stateDir }) => {
await writeEnvFile(
path.join(stateDir, ".env"),
"ANTHROPIC_BASE_URL=https://trusted.example.com/v1\nHTTP_PROXY=http://proxy.test:8080\n",
[
"ANTHROPIC_BASE_URL=https://trusted.example.com/v1",
"HTTP_PROXY=http://proxy.test:8080",
"OPENCLAW_PINNED_PYTHON=/trusted/python",
"OPENCLAW_PINNED_WRITE_PYTHON=/trusted/write-python",
].join("\n"),
);
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
delete process.env.ANTHROPIC_BASE_URL;
delete process.env.HTTP_PROXY;
delete process.env.OPENCLAW_PINNED_PYTHON;
delete process.env.OPENCLAW_PINNED_WRITE_PYTHON;
loadDotEnv({ quiet: true });
expect(process.env.ANTHROPIC_BASE_URL).toBe("https://trusted.example.com/v1");
expect(process.env.HTTP_PROXY).toBe("http://proxy.test:8080");
expect(process.env.OPENCLAW_PINNED_PYTHON).toBe("/trusted/python");
expect(process.env.OPENCLAW_PINNED_WRITE_PYTHON).toBe("/trusted/write-python");
});
});
});

View File

@@ -30,6 +30,8 @@ const BLOCKED_WORKSPACE_DOTENV_KEYS = new Set([
"OPENCLAW_LIVE_GEMINI_KEY",
"OPENCLAW_LIVE_OPENAI_KEY",
"OPENCLAW_OAUTH_DIR",
"OPENCLAW_PINNED_PYTHON",
"OPENCLAW_PINNED_WRITE_PYTHON",
"OPENCLAW_PROFILE",
"OPENCLAW_STATE_DIR",
"OPENAI_API_KEY",

View File

@@ -432,12 +432,12 @@ describe("normalizeExecApprovals strips invalid security/ask enum values (#59006
},
} as unknown as ExecApprovalsFile;
const resolved = resolveExecApprovalsFromFile({ file });
// Invalid "none" in defaults is stripped, so fallback to DEFAULT_SECURITY ("deny")
expect(resolved.defaults.security).toBe("deny");
// Invalid "none" in defaults is stripped, so fallback to DEFAULT_SECURITY ("allowlist")
expect(resolved.defaults.security).toBe("allowlist");
// Invalid "never" in defaults is stripped, so fallback to DEFAULT_ASK ("on-miss")
expect(resolved.defaults.ask).toBe("on-miss");
// Wildcard agent "none" is stripped, so agent inherits resolved defaults
expect(resolved.agent.security).toBe("deny");
expect(resolved.agent.security).toBe("allowlist");
// Wildcard agent ask="off" is valid and preserved
expect(resolved.agent.ask).toBe("off");
});

View File

@@ -12,8 +12,8 @@ import {
type ExecSecurity,
} from "./exec-approvals.js";
const DEFAULT_REQUESTED_SECURITY: ExecSecurity = "allowlist";
const DEFAULT_REQUESTED_ASK: ExecAsk = "on-miss";
const DEFAULT_REQUESTED_SECURITY: ExecSecurity = "full";
const DEFAULT_REQUESTED_ASK: ExecAsk = "off";
const DEFAULT_HOST_PATH = "~/.openclaw/exec-approvals.json";
const REQUESTED_DEFAULT_LABEL = {
security: DEFAULT_REQUESTED_SECURITY,

View File

@@ -283,9 +283,9 @@ describe("exec approvals store helpers", () => {
});
it("builds approval socket payloads and accepts decision responses only", async () => {
requestJsonlSocketMock.mockImplementationOnce(async ({ payload, accept, timeoutMs }) => {
requestJsonlSocketMock.mockImplementationOnce(async ({ requestLine, accept, timeoutMs }) => {
expect(timeoutMs).toBe(15_000);
const parsed = JSON.parse(payload) as {
const parsed = JSON.parse(requestLine) as {
type: string;
token: string;
id: string;

View File

@@ -168,7 +168,7 @@ export type ExecApprovalsResolved = {
// Keep CLI + gateway defaults in sync.
export const DEFAULT_EXEC_APPROVAL_TIMEOUT_MS = 1_800_000;
const DEFAULT_SECURITY: ExecSecurity = "deny";
const DEFAULT_SECURITY: ExecSecurity = "allowlist";
const DEFAULT_ASK: ExecAsk = "on-miss";
export const DEFAULT_EXEC_APPROVAL_ASK_FALLBACK: ExecSecurity = "deny";
const DEFAULT_AUTO_ALLOW_SKILLS = false;
@@ -304,9 +304,7 @@ function sanitizeExecApprovalPolicy(
const askFallback = toStringOrUndefined(policy?.askFallback)?.trim();
return {
security:
security === "deny" || security === "allowlist" || security === "full"
? security
: undefined,
security === "deny" || security === "allowlist" || security === "full" ? security : undefined,
ask: ask === "off" || ask === "on-miss" || ask === "always" ? ask : undefined,
askFallback:
askFallback === "deny" || askFallback === "allowlist" || askFallback === "full"
@@ -900,7 +898,7 @@ export async function requestExecApprovalViaSocket(params: {
return await requestJsonlSocket({
socketPath,
payload,
requestLine: payload,
timeoutMs,
accept: (value) => {
const msg = value as { type?: string; decision?: ExecApprovalDecision };

View File

@@ -48,7 +48,7 @@ describe("requestExecHostViaSocket", () => {
const call = requestJsonlSocketMock.mock.calls[0]?.[0] as
| {
socketPath: string;
payload: string;
requestLine: string;
timeoutMs: number;
accept: (msg: unknown) => unknown;
}
@@ -59,7 +59,7 @@ describe("requestExecHostViaSocket", () => {
expect(call.socketPath).toBe("/tmp/socket");
expect(call.timeoutMs).toBe(20_000);
const payload = JSON.parse(call.payload) as {
const payload = JSON.parse(call.requestLine) as {
type: string;
id: string;
nonce: string;

View File

@@ -61,7 +61,7 @@ export async function requestExecHostViaSocket(params: {
return await requestJsonlSocket({
socketPath,
payload,
requestLine: payload,
timeoutMs,
accept: (value) => {
const msg = value as { type?: string; ok?: boolean; payload?: unknown; error?: unknown };

View File

@@ -40,7 +40,7 @@ describe.runIf(process.platform !== "win32")("requestJsonlSocket", () => {
await expect(
requestJsonlSocket({
socketPath,
payload: '{"hello":"world"}',
requestLine: '{"hello":"world"}',
timeoutMs: 500,
accept: (msg) => {
const value = msg as { type?: string; value?: number };
@@ -54,6 +54,44 @@ describe.runIf(process.platform !== "win32")("requestJsonlSocket", () => {
});
});
it("half-closes the write side after sending the request line", async () => {
await withTempDir({ prefix: "openclaw-jsonl-socket-" }, async (dir) => {
const socketPath = path.join(dir, "socket.sock");
let receivedBuffer: string | null = null;
const server = net.createServer((socket) => {
let buffer = "";
socket.on("data", (chunk) => {
buffer += chunk.toString("utf8");
});
socket.on("end", () => {
receivedBuffer = buffer;
socket.end('{"type":"done","value":7}\n');
});
});
const listening = await listenOnSocket(server, socketPath);
if (!listening) {
return;
}
try {
await expect(
requestJsonlSocket({
socketPath,
requestLine: '{"hello":"world"}',
timeoutMs: 500,
accept: (msg) => {
const value = msg as { type?: string; value?: number };
return value.type === "done" ? (value.value ?? null) : undefined;
},
}),
).resolves.toBe(7);
expect(receivedBuffer).toBe('{"hello":"world"}\n');
} finally {
server.close();
}
});
});
it("returns null on timeout and on socket errors", async () => {
await withTempDir({ prefix: "openclaw-jsonl-socket-" }, async (dir) => {
const socketPath = path.join(dir, "socket.sock");
@@ -69,7 +107,7 @@ describe.runIf(process.platform !== "win32")("requestJsonlSocket", () => {
await expect(
requestJsonlSocket({
socketPath,
payload: "{}",
requestLine: "{}",
timeoutMs: 50,
accept: () => undefined,
}),
@@ -81,7 +119,7 @@ describe.runIf(process.platform !== "win32")("requestJsonlSocket", () => {
await expect(
requestJsonlSocket({
socketPath,
payload: "{}",
requestLine: "{}",
timeoutMs: 50,
accept: () => undefined,
}),

View File

@@ -1,13 +1,16 @@
import net from "node:net";
import { clearTimeout as clearNodeTimeout, setTimeout as setNodeTimeout } from "node:timers";
/**
* Sends one JSONL request line, half-closes the write side, and waits for an accepted response line.
*/
export async function requestJsonlSocket<T>(params: {
socketPath: string;
payload: string;
requestLine: string;
timeoutMs: number;
accept: (msg: unknown) => T | null | undefined;
}): Promise<T | null> {
const { socketPath, payload, timeoutMs, accept } = params;
const { socketPath, requestLine, timeoutMs, accept } = params;
return await new Promise((resolve) => {
const client = new net.Socket();
let settled = false;
@@ -30,7 +33,7 @@ export async function requestJsonlSocket<T>(params: {
client.on("error", () => finish(null));
client.connect(socketPath, () => {
client.write(`${payload}\n`);
client.end(`${requestLine}\n`);
});
client.on("data", (data) => {
buffer += data.toString("utf8");

View File

@@ -159,6 +159,52 @@ describe("fetchWithSsrFGuard hardening", () => {
expect(fetchImpl).not.toHaveBeenCalled();
});
it("blocks explicit proxies that resolve to private hosts by default", async () => {
const lookupFn = vi.fn(async (hostname: string) => [
{
address: hostname === "proxy.internal" ? "127.0.0.1" : "93.184.216.34",
family: 4,
},
]) as unknown as LookupFn;
const fetchImpl = vi.fn();
await expect(
fetchWithSsrFGuard({
url: "https://public.example/resource",
fetchImpl,
lookupFn,
dispatcherPolicy: {
mode: "explicit-proxy",
proxyUrl: "http://proxy.internal:7890",
},
}),
).rejects.toThrow(/private|internal|blocked/i);
expect(fetchImpl).not.toHaveBeenCalled();
});
it("allows explicit private proxies only when the SSRF policy allows private network access", async () => {
const lookupFn = vi.fn(async (hostname: string) => [
{
address: hostname === "proxy.internal" ? "127.0.0.1" : "93.184.216.34",
family: 4,
},
]) as unknown as LookupFn;
const fetchImpl = vi.fn(async () => okResponse());
const result = await fetchWithSsrFGuard({
url: "https://public.example/resource",
fetchImpl,
lookupFn,
policy: { allowPrivateNetwork: true },
dispatcherPolicy: {
mode: "explicit-proxy",
proxyUrl: "http://proxy.internal:7890",
},
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
await result.release();
});
it("blocks redirect chains that hop to private hosts", async () => {
const lookupFn = createPublicLookup();
const fetchImpl = await expectRedirectFailure({

View File

@@ -93,6 +93,29 @@ function assertExplicitProxySupportsPinnedDns(
}
}
async function assertExplicitProxyAllowed(
dispatcherPolicy: PinnedDispatcherPolicy | undefined,
lookupFn: LookupFn | undefined,
policy: SsrFPolicy | undefined,
): Promise<void> {
if (!dispatcherPolicy || dispatcherPolicy.mode !== "explicit-proxy") {
return;
}
let parsedProxyUrl: URL;
try {
parsedProxyUrl = new URL(dispatcherPolicy.proxyUrl);
} catch {
throw new Error("Invalid explicit proxy URL");
}
if (!["http:", "https:"].includes(parsedProxyUrl.protocol)) {
throw new Error("Explicit proxy URL must use http or https");
}
await resolvePinnedHostnameWithPolicy(parsedProxyUrl.hostname, {
lookupFn,
policy,
});
}
function isRedirectStatus(status: number): boolean {
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
}
@@ -158,6 +181,7 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
let dispatcher: Dispatcher | null = null;
try {
assertExplicitProxySupportsPinnedDns(parsedUrl, params.dispatcherPolicy, params.pinDns);
await assertExplicitProxyAllowed(params.dispatcherPolicy, params.lookupFn, params.policy);
const pinned = await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
lookupFn: params.lookupFn,
policy: params.policy,

View File

@@ -22,18 +22,20 @@ export async function transcribeOpenAiCompatibleAudio(
params: OpenAiCompatibleAudioParams,
): Promise<AudioTranscriptionResult> {
const fetchFn = params.fetchFn ?? fetch;
const { baseUrl, allowPrivateNetwork, headers } = resolveProviderHttpRequestConfig({
baseUrl: params.baseUrl,
defaultBaseUrl: params.defaultBaseUrl,
headers: params.headers,
defaultHeaders: {
authorization: `Bearer ${params.apiKey}`,
},
provider: params.provider,
api: "openai-audio-transcriptions",
capability: "audio",
transport: "media-understanding",
});
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: params.baseUrl,
defaultBaseUrl: params.defaultBaseUrl,
headers: params.headers,
request: params.request,
defaultHeaders: {
authorization: `Bearer ${params.apiKey}`,
},
provider: params.provider,
api: "openai-audio-transcriptions",
capability: "audio",
transport: "media-understanding",
});
const url = `${baseUrl}/audio/transcriptions`;
const model = resolveModel(params.model, params.defaultModel);
@@ -59,6 +61,7 @@ export async function transcribeOpenAiCompatibleAudio(
timeoutMs: params.timeoutMs,
fetchFn,
allowPrivateNetwork,
dispatcherPolicy,
});
try {

View File

@@ -65,6 +65,40 @@ describe("resolveProviderHttpRequestConfig", () => {
expect(resolved.headers.get("x-goog-api-key")).toBe("test-key");
});
it("surfaces dispatcher policy for explicit proxy and mTLS transport overrides", () => {
const resolved = resolveProviderHttpRequestConfig({
baseUrl: "https://api.deepgram.com/v1",
defaultBaseUrl: "https://api.deepgram.com/v1",
defaultHeaders: {
authorization: "Token test-key",
},
request: {
proxy: {
mode: "explicit-proxy",
url: "http://proxy.internal:8443",
tls: {
ca: "proxy-ca",
},
},
tls: {
cert: "client-cert",
key: "client-key",
},
},
provider: "deepgram",
capability: "audio",
transport: "media-understanding",
});
expect(resolved.dispatcherPolicy).toEqual({
mode: "explicit-proxy",
proxyUrl: "http://proxy.internal:8443",
proxyTls: {
ca: "proxy-ca",
},
});
});
it("fails fast when no base URL can be resolved", () => {
expect(() =>
resolveProviderHttpRequestConfig({

View File

@@ -3,13 +3,15 @@ import type {
ProviderRequestTransport,
} from "../agents/provider-attribution.js";
import {
buildProviderRequestDispatcherPolicy,
normalizeBaseUrl,
resolveProviderRequestPolicyConfig,
type ProviderRequestTransportOverrides,
type ResolvedProviderRequestConfig,
} from "../agents/provider-request-config.js";
import type { GuardedFetchResult } from "../infra/net/fetch-guard.js";
import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
import type { LookupFn, SsrFPolicy } from "../infra/net/ssrf.js";
import type { LookupFn, PinnedDispatcherPolicy, SsrFPolicy } from "../infra/net/ssrf.js";
export { fetchWithTimeout } from "../utils/fetch-timeout.js";
export { normalizeBaseUrl } from "../agents/provider-request-config.js";
@@ -21,6 +23,7 @@ export function resolveProviderHttpRequestConfig(params: {
allowPrivateNetwork?: boolean;
headers?: HeadersInit;
defaultHeaders?: Record<string, string>;
request?: ProviderRequestTransportOverrides;
provider?: string;
api?: string;
capability?: ProviderRequestCapability;
@@ -29,6 +32,7 @@ export function resolveProviderHttpRequestConfig(params: {
baseUrl: string;
allowPrivateNetwork: boolean;
headers: Headers;
dispatcherPolicy?: PinnedDispatcherPolicy;
requestConfig: ResolvedProviderRequestConfig;
} {
const requestConfig = resolveProviderRequestPolicyConfig({
@@ -44,6 +48,7 @@ export function resolveProviderHttpRequestConfig(params: {
precedence: "caller-wins",
allowPrivateNetwork: params.allowPrivateNetwork,
api: params.api,
request: params.request,
});
const headers = new Headers(requestConfig.headers);
if (!requestConfig.baseUrl) {
@@ -54,6 +59,7 @@ export function resolveProviderHttpRequestConfig(params: {
baseUrl: requestConfig.baseUrl,
allowPrivateNetwork: requestConfig.allowPrivateNetwork,
headers,
dispatcherPolicy: buildProviderRequestDispatcherPolicy(requestConfig),
requestConfig,
};
}
@@ -67,6 +73,7 @@ export async function fetchWithTimeoutGuarded(
ssrfPolicy?: SsrFPolicy;
lookupFn?: LookupFn;
pinDns?: boolean;
dispatcherPolicy?: PinnedDispatcherPolicy;
},
): Promise<GuardedFetchResult> {
return await fetchWithSsrFGuard({
@@ -77,6 +84,7 @@ export async function fetchWithTimeoutGuarded(
policy: options?.ssrfPolicy,
lookupFn: options?.lookupFn,
pinDns: options?.pinDns,
dispatcherPolicy: options?.dispatcherPolicy,
});
}
@@ -87,6 +95,7 @@ export async function postTranscriptionRequest(params: {
timeoutMs: number;
fetchFn: typeof fetch;
allowPrivateNetwork?: boolean;
dispatcherPolicy?: PinnedDispatcherPolicy;
}) {
return fetchWithTimeoutGuarded(
params.url,
@@ -97,7 +106,12 @@ export async function postTranscriptionRequest(params: {
},
params.timeoutMs,
params.fetchFn,
params.allowPrivateNetwork ? { ssrfPolicy: { allowPrivateNetwork: true } } : undefined,
params.allowPrivateNetwork || params.dispatcherPolicy
? {
...(params.allowPrivateNetwork ? { ssrfPolicy: { allowPrivateNetwork: true } } : {}),
...(params.dispatcherPolicy ? { dispatcherPolicy: params.dispatcherPolicy } : {}),
}
: undefined,
);
}
@@ -108,6 +122,7 @@ export async function postJsonRequest(params: {
timeoutMs: number;
fetchFn: typeof fetch;
allowPrivateNetwork?: boolean;
dispatcherPolicy?: PinnedDispatcherPolicy;
}) {
return fetchWithTimeoutGuarded(
params.url,
@@ -118,7 +133,12 @@ export async function postJsonRequest(params: {
},
params.timeoutMs,
params.fetchFn,
params.allowPrivateNetwork ? { ssrfPolicy: { allowPrivateNetwork: true } } : undefined,
params.allowPrivateNetwork || params.dispatcherPolicy
? {
...(params.allowPrivateNetwork ? { ssrfPolicy: { allowPrivateNetwork: true } } : {}),
...(params.dispatcherPolicy ? { dispatcherPolicy: params.dispatcherPolicy } : {}),
}
: undefined,
);
}

View File

@@ -1,3 +1,5 @@
import type { ProviderRequestTransportOverrides } from "../agents/provider-request-config.js";
export type MediaUnderstandingKind =
| "audio.transcription"
| "video.description"
@@ -55,6 +57,7 @@ export type AudioTranscriptionRequest = {
apiKey: string;
baseUrl?: string;
headers?: Record<string, string>;
request?: ProviderRequestTransportOverrides;
model?: string;
language?: string;
prompt?: string;
@@ -75,6 +78,7 @@ export type VideoDescriptionRequest = {
apiKey: string;
baseUrl?: string;
headers?: Record<string, string>;
request?: ProviderRequestTransportOverrides;
model?: string;
prompt?: string;
timeoutMs: number;

View File

@@ -5,8 +5,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js";
import {
canLoadActivatedBundledPluginPublicSurface,
listImportedBundledPluginFacadeIds,
loadActivatedBundledPluginPublicSurfaceModuleSync,
loadBundledPluginPublicSurfaceModuleSync,
resetFacadeRuntimeStateForTest,
tryLoadActivatedBundledPluginPublicSurfaceModuleSync,
} from "./facade-runtime.js";
@@ -70,6 +72,7 @@ function createCircularPluginDir(prefix: string): string {
afterEach(() => {
vi.restoreAllMocks();
clearRuntimeConfigSnapshot();
resetFacadeRuntimeStateForTest();
if (originalBundledPluginsDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
} else {
@@ -114,6 +117,7 @@ describe("plugin-sdk facade runtime", () => {
});
expect(first).toBe(second);
expect(first.marker).toBe("identity-check");
expect(listImportedBundledPluginFacadeIds()).toEqual(["demo"]);
});
it("breaks circular facade re-entry during module evaluation", () => {
@@ -139,6 +143,8 @@ describe("plugin-sdk facade runtime", () => {
}),
).toThrow("plugin load failure");
expect(listImportedBundledPluginFacadeIds()).toEqual(["bad"]);
// A second call must also throw (not return a stale empty sentinel).
expect(() =>
loadBundledPluginPublicSurfaceModuleSync<{ marker: string }>({

View File

@@ -37,6 +37,7 @@ const ALWAYS_ALLOWED_RUNTIME_DIR_NAMES = new Set([
const EMPTY_FACADE_BOUNDARY_CONFIG: OpenClawConfig = {};
const jitiLoaders = new Map<string, ReturnType<typeof createJiti>>();
const loadedFacadeModules = new Map<string, unknown>();
const loadedFacadePluginIds = new Set<string>();
let cachedBoundaryRawConfig: OpenClawConfig | undefined;
let cachedBoundaryResolvedConfig:
| {
@@ -134,7 +135,8 @@ function getJiti(modulePath: string) {
function readFacadeBoundaryConfigSafely(): OpenClawConfig {
try {
return loadConfig();
const config = loadConfig();
return config && typeof config === "object" ? config : EMPTY_FACADE_BOUNDARY_CONFIG;
} catch {
return EMPTY_FACADE_BOUNDARY_CONFIG;
}
@@ -154,8 +156,8 @@ function getFacadeBoundaryResolvedConfig() {
const resolved = {
rawConfig,
config,
normalizedPluginsConfig: normalizePluginsConfig(config.plugins),
sourceNormalizedPluginsConfig: normalizePluginsConfig(rawConfig.plugins),
normalizedPluginsConfig: normalizePluginsConfig(config?.plugins),
sourceNormalizedPluginsConfig: normalizePluginsConfig(rawConfig?.plugins),
autoEnabledReasons: autoEnabled.autoEnabledReasons,
};
cachedBoundaryRawConfig = rawConfig;
@@ -175,6 +177,10 @@ function resolveBundledPluginManifestRecordByDirName(dirName: string): PluginMan
);
}
function resolveTrackedFacadePluginId(dirName: string): string {
return resolveBundledPluginManifestRecordByDirName(dirName)?.id ?? dirName;
}
function resolveBundledPluginPublicSurfaceAccess(params: {
dirName: string;
artifactBasename: string;
@@ -332,6 +338,9 @@ export function loadBundledPluginPublicSurfaceModuleSync<T extends object>(param
let loaded: T;
try {
// Track the owning plugin once module evaluation begins. Facade top-level
// code may have already executed even if the module later throws.
loadedFacadePluginIds.add(resolveTrackedFacadePluginId(params.dirName));
loaded = getJiti(location.modulePath)(location.modulePath) as T;
Object.assign(sentinel, loaded);
} catch (err) {
@@ -373,3 +382,15 @@ export function tryLoadActivatedBundledPluginPublicSurfaceModuleSync<T extends o
}
return loadBundledPluginPublicSurfaceModuleSync<T>(params);
}
export function listImportedBundledPluginFacadeIds(): string[] {
return [...loadedFacadePluginIds].toSorted((left, right) => left.localeCompare(right));
}
export function resetFacadeRuntimeStateForTest(): void {
loadedFacadeModules.clear();
loadedFacadePluginIds.clear();
jitiLoaders.clear();
cachedBoundaryRawConfig = undefined;
cachedBoundaryResolvedConfig = undefined;
}

View File

@@ -23,6 +23,12 @@ export type {
ProviderRequestPolicyResolution,
ProviderRequestTransport,
} from "../agents/provider-attribution.js";
export type {
ProviderRequestAuthOverride,
ProviderRequestProxyOverride,
ProviderRequestTlsOverride,
ProviderRequestTransportOverrides,
} from "../agents/provider-request-config.js";
export {
resolveProviderEndpoint,
resolveProviderRequestCapabilities,

View File

@@ -292,6 +292,120 @@ describe("bundle manifest parsing", () => {
});
});
it.each([
{
name: "accepts JSON5 Codex bundle manifests",
bundleFormat: "codex" as const,
manifestRelativePath: CODEX_BUNDLE_MANIFEST_RELATIVE_PATH,
json5Manifest: `{
// Bundle name can include comments and trailing commas.
name: "Codex JSON5 Bundle",
skills: "skills",
hooks: "hooks",
}`,
dirs: ["skills", "hooks"],
expected: {
id: "codex-json5-bundle",
name: "Codex JSON5 Bundle",
bundleFormat: "codex",
skills: ["skills"],
hooks: ["hooks"],
},
},
{
name: "accepts JSON5 Claude bundle manifests",
bundleFormat: "claude" as const,
manifestRelativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
json5Manifest: `{
name: "Claude JSON5 Bundle",
commands: "commands-pack",
hooks: "hooks-pack",
outputStyles: "styles",
}`,
dirs: [".claude-plugin", "commands-pack", "hooks-pack", "styles"],
expected: {
id: "claude-json5-bundle",
name: "Claude JSON5 Bundle",
bundleFormat: "claude",
skills: ["commands-pack", "styles"],
hooks: ["hooks-pack"],
},
},
{
name: "accepts JSON5 Cursor bundle manifests",
bundleFormat: "cursor" as const,
manifestRelativePath: CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH,
json5Manifest: `{
name: "Cursor JSON5 Bundle",
commands: ".cursor/commands",
mcpServers: "./.mcp.json",
}`,
dirs: [".cursor-plugin", "skills", ".cursor/commands"],
textFiles: {
".mcp.json": "{ servers: {}, }",
},
expected: {
id: "cursor-json5-bundle",
name: "Cursor JSON5 Bundle",
bundleFormat: "cursor",
skills: ["skills", ".cursor/commands"],
hooks: [],
},
},
] as const)(
"$name",
({ bundleFormat, manifestRelativePath, json5Manifest, dirs, textFiles, expected }) => {
const rootDir = makeTempDir();
setupBundleFixture({
rootDir,
dirs: [path.dirname(manifestRelativePath), ...dirs],
textFiles: {
[manifestRelativePath]: json5Manifest,
...textFiles,
},
});
expectBundleManifest({
rootDir,
bundleFormat,
expected,
});
},
);
it.each([
{
name: "rejects JSON5 Codex bundle manifests that parse to non-objects",
bundleFormat: "codex" as const,
manifestRelativePath: CODEX_BUNDLE_MANIFEST_RELATIVE_PATH,
},
{
name: "rejects JSON5 Claude bundle manifests that parse to non-objects",
bundleFormat: "claude" as const,
manifestRelativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
},
{
name: "rejects JSON5 Cursor bundle manifests that parse to non-objects",
bundleFormat: "cursor" as const,
manifestRelativePath: CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH,
},
] as const)("$name", ({ bundleFormat, manifestRelativePath }) => {
const rootDir = makeTempDir();
setupBundleFixture({
rootDir,
dirs: [path.dirname(manifestRelativePath)],
textFiles: {
[manifestRelativePath]: "'still not an object'",
},
});
const result = loadBundleManifest({ rootDir, bundleFormat });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("plugin manifest must be an object");
}
});
it.each([
{
name: "resolves Claude bundle hooks from default and declared paths",

View File

@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import JSON5 from "json5";
import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { isRecord } from "../utils.js";
import { DEFAULT_PLUGIN_ENTRY_CANDIDATES, PLUGIN_MANIFEST_FILENAME } from "./manifest.js";
@@ -117,7 +118,7 @@ function loadBundleManifestFile(params: {
});
}
try {
const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown;
const raw = JSON5.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown;
if (!isRecord(raw)) {
return { ok: false, error: "plugin manifest must be an object", manifestPath };
}

View File

@@ -35,6 +35,7 @@ import { createEmptyPluginRegistry } from "./registry.js";
import {
getActivePluginRegistry,
getActivePluginRegistryKey,
listImportedRuntimePluginIds,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} from "./runtime.js";
@@ -1250,6 +1251,145 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip
expect(fs.existsSync(skippedMarker)).toBe(false);
},
},
{
label: "can build a manifest-only snapshot without importing plugin modules",
run: () => {
useNoBundledPlugins();
const importedMarker = path.join(makeTempDir(), "manifest-only-imported.txt");
const plugin = writePlugin({
id: "manifest-only-plugin",
filename: "manifest-only-plugin.cjs",
body: `require("node:fs").writeFileSync(${JSON.stringify(importedMarker)}, "loaded", "utf-8");
module.exports = { id: "manifest-only-plugin", register() { throw new Error("manifest-only snapshot should not register"); } };`,
});
const registry = loadOpenClawPlugins({
cache: false,
activate: false,
loadModules: false,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["manifest-only-plugin"],
entries: {
"manifest-only-plugin": { enabled: true },
},
},
},
});
expect(fs.existsSync(importedMarker)).toBe(false);
expect(registry.plugins).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "manifest-only-plugin",
status: "loaded",
}),
]),
);
},
},
{
label: "marks a selected memory slot as matched during manifest-only snapshots",
run: () => {
useNoBundledPlugins();
const memoryPlugin = writePlugin({
id: "memory-demo",
filename: "memory-demo.cjs",
body: `module.exports = {
id: "memory-demo",
kind: "memory",
register() {},
};`,
});
fs.writeFileSync(
path.join(memoryPlugin.dir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "memory-demo",
kind: "memory",
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
const registry = loadOpenClawPlugins({
cache: false,
activate: false,
loadModules: false,
config: {
plugins: {
load: { paths: [memoryPlugin.file] },
allow: ["memory-demo"],
slots: { memory: "memory-demo" },
entries: {
"memory-demo": { enabled: true },
},
},
},
});
expect(
registry.diagnostics.some(
(entry) =>
entry.message === "memory slot plugin not found or not marked as memory: memory-demo",
),
).toBe(false);
expect(registry.plugins).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "memory-demo",
memorySlotSelected: true,
}),
]),
);
},
},
{
label: "tracks plugins as imported when module evaluation throws after top-level execution",
run: () => {
useNoBundledPlugins();
const importMarker = "__openclaw_loader_import_throw_marker";
Reflect.deleteProperty(globalThis, importMarker);
const plugin = writePlugin({
id: "throws-after-import",
filename: "throws-after-import.cjs",
body: `globalThis.${importMarker} = (globalThis.${importMarker} ?? 0) + 1;
throw new Error("boom after import");
module.exports = { id: "throws-after-import", register() {} };`,
});
const registry = loadOpenClawPlugins({
cache: false,
activate: false,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["throws-after-import"],
},
},
});
try {
expect(registry.plugins).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "throws-after-import",
status: "error",
}),
]),
);
expect(listImportedRuntimePluginIds()).toContain("throws-after-import");
expect(Number(Reflect.get(globalThis, importMarker) ?? 0)).toBeGreaterThan(0);
} finally {
Reflect.deleteProperty(globalThis, importMarker);
}
},
},
{
label: "keeps scoped plugin loads in a separate cache entry",
run: () => {

View File

@@ -44,6 +44,7 @@ import { resolvePluginCacheInputs } from "./roots.js";
import {
getActivePluginRegistry,
getActivePluginRegistryKey,
recordImportedPluginId,
setActivePluginRegistry,
} from "./runtime.js";
import type { CreatePluginRuntimeOptions } from "./runtime/index.js";
@@ -96,6 +97,7 @@ export type PluginLoadOptions = {
*/
preferSetupRuntimeForChannelPlugins?: boolean;
activate?: boolean;
loadModules?: boolean;
throwOnLoadError?: boolean;
};
@@ -241,6 +243,7 @@ function buildCacheKey(params: {
onlyPluginIds?: string[];
includeSetupOnlyChannelPlugins?: boolean;
preferSetupRuntimeForChannelPlugins?: boolean;
loadModules?: boolean;
runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable";
pluginSdkResolution?: PluginSdkResolutionPreference;
coreGatewayMethodNames?: string[];
@@ -270,13 +273,14 @@ function buildCacheKey(params: {
const setupOnlyKey = params.includeSetupOnlyChannelPlugins === true ? "setup-only" : "runtime";
const startupChannelMode =
params.preferSetupRuntimeForChannelPlugins === true ? "prefer-setup" : "full";
const moduleLoadMode = params.loadModules === false ? "manifest-only" : "load-modules";
const gatewayMethodsKey = JSON.stringify(params.coreGatewayMethodNames ?? []);
return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({
...params.plugins,
installs,
loadPaths,
activationMetadataKey: params.activationMetadataKey ?? "",
})}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}::${params.runtimeSubagentMode ?? "default"}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`;
})}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}::${moduleLoadMode}::${params.runtimeSubagentMode ?? "default"}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`;
}
function normalizeScopedPluginIds(ids?: string[]): string[] | undefined {
@@ -360,7 +364,8 @@ function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean {
options.pluginSdkResolution !== undefined ||
options.coreGatewayHandlers !== undefined ||
options.includeSetupOnlyChannelPlugins === true ||
options.preferSetupRuntimeForChannelPlugins === true,
options.preferSetupRuntimeForChannelPlugins === true ||
options.loadModules === false,
);
}
@@ -387,6 +392,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
onlyPluginIds,
includeSetupOnlyChannelPlugins,
preferSetupRuntimeForChannelPlugins,
loadModules: options.loadModules,
runtimeSubagentMode: resolveRuntimeSubagentMode(options.runtimeOptions),
pluginSdkResolution: options.pluginSdkResolution,
coreGatewayMethodNames,
@@ -402,6 +408,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
includeSetupOnlyChannelPlugins,
preferSetupRuntimeForChannelPlugins,
shouldActivate: options.activate !== false,
shouldLoadModules: options.loadModules !== false,
runtimeSubagentMode: resolveRuntimeSubagentMode(options.runtimeOptions),
cacheKey,
};
@@ -924,6 +931,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
includeSetupOnlyChannelPlugins,
preferSetupRuntimeForChannelPlugins,
shouldActivate,
shouldLoadModules,
cacheKey,
runtimeSubagentMode,
} = resolvePluginLoadCacheContext(options);
@@ -1306,6 +1314,49 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
continue;
}
if (!shouldLoadModules && registrationMode === "full") {
const memoryDecision = resolveMemorySlotDecision({
id: record.id,
kind: record.kind,
slot: memorySlot,
selectedId: selectedMemoryPluginId,
});
if (!memoryDecision.enabled) {
record.enabled = false;
record.status = "disabled";
record.error = memoryDecision.reason;
markPluginActivationDisabled(record, memoryDecision.reason);
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
continue;
}
if (memoryDecision.selected && hasKind(record.kind, "memory")) {
selectedMemoryPluginId = record.id;
memorySlotMatched = true;
record.memorySlotSelected = true;
}
}
const validatedConfig = validatePluginConfig({
schema: manifestRecord.configSchema,
cacheKey: manifestRecord.schemaCacheKey,
value: entry?.config,
});
if (!validatedConfig.ok) {
logger.error(`[plugins] ${record.id} invalid config: ${validatedConfig.errors?.join(", ")}`);
pushPluginLoadError(`invalid config: ${validatedConfig.errors?.join(", ")}`);
continue;
}
if (!shouldLoadModules) {
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
continue;
}
const pluginRoot = safeRealpathOrResolve(candidate.rootDir);
const loadSource =
(registrationMode === "setup-only" || registrationMode === "setup-runtime") &&
@@ -1328,6 +1379,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
let mod: OpenClawPluginModule | null = null;
try {
// Track the plugin as imported once module evaluation begins. Top-level
// code may have already executed even if evaluation later throws.
recordImportedPluginId(record.id);
mod = getJiti(safeSource)(safeSource) as OpenClawPluginModule;
} catch (err) {
recordPluginError({
@@ -1423,18 +1477,6 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
}
}
const validatedConfig = validatePluginConfig({
schema: manifestRecord.configSchema,
cacheKey: manifestRecord.schemaCacheKey,
value: entry?.config,
});
if (!validatedConfig.ok) {
logger.error(`[plugins] ${record.id} invalid config: ${validatedConfig.errors?.join(", ")}`);
pushPluginLoadError(`invalid config: ${validatedConfig.errors?.join(", ")}`);
continue;
}
if (validateOnly) {
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);

View File

@@ -0,0 +1,103 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { loadPluginManifest } from "./manifest.js";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
const tempDirs: string[] = [];
function makeTempDir() {
return makeTrackedTempDir("openclaw-manifest-json5", tempDirs);
}
afterEach(() => {
cleanupTrackedTempDirs(tempDirs);
});
describe("loadPluginManifest JSON5 tolerance", () => {
it("parses a standard JSON manifest without issues", () => {
const dir = makeTempDir();
const manifest = {
id: "demo",
configSchema: { type: "object" },
};
fs.writeFileSync(
path.join(dir, "openclaw.plugin.json"),
JSON.stringify(manifest, null, 2),
"utf-8",
);
const result = loadPluginManifest(dir, false);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.manifest.id).toBe("demo");
}
});
it("parses a manifest with trailing commas", () => {
const dir = makeTempDir();
const json5Content = `{
"id": "hindsight",
"configSchema": {
"type": "object",
"properties": {
"apiKey": { "type": "string" },
},
},
}`;
fs.writeFileSync(path.join(dir, "openclaw.plugin.json"), json5Content, "utf-8");
const result = loadPluginManifest(dir, false);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.manifest.id).toBe("hindsight");
}
});
it("parses a manifest with single-line comments", () => {
const dir = makeTempDir();
const json5Content = `{
// Plugin identifier
"id": "commented-plugin",
"configSchema": { "type": "object" }
}`;
fs.writeFileSync(path.join(dir, "openclaw.plugin.json"), json5Content, "utf-8");
const result = loadPluginManifest(dir, false);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.manifest.id).toBe("commented-plugin");
}
});
it("parses a manifest with unquoted property names", () => {
const dir = makeTempDir();
const json5Content = `{
id: "unquoted-keys",
configSchema: { type: "object" }
}`;
fs.writeFileSync(path.join(dir, "openclaw.plugin.json"), json5Content, "utf-8");
const result = loadPluginManifest(dir, false);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.manifest.id).toBe("unquoted-keys");
}
});
it("still rejects completely invalid syntax", () => {
const dir = makeTempDir();
fs.writeFileSync(path.join(dir, "openclaw.plugin.json"), "not json at all {{{}}", "utf-8");
const result = loadPluginManifest(dir, false);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("failed to parse plugin manifest");
}
});
it("rejects JSON5 values that parse but are not objects", () => {
const dir = makeTempDir();
fs.writeFileSync(path.join(dir, "openclaw.plugin.json"), "'just a string'", "utf-8");
const result = loadPluginManifest(dir, false);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("plugin manifest must be an object");
}
});
});

View File

@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import JSON5 from "json5";
import { MANIFEST_KEY } from "../compat/legacy-names.js";
import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { isRecord } from "../utils.js";
@@ -273,7 +274,7 @@ export function loadPluginManifest(
}
let raw: unknown;
try {
raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown;
raw = JSON5.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown;
} catch (err) {
return {
ok: false,

View File

@@ -200,7 +200,7 @@ describe("resolvePluginProviders", () => {
applyPluginAutoEnableMock.mockReset();
applyPluginAutoEnableMock.mockImplementation(
(params): PluginAutoEnableResult => ({
config: params.config,
config: params.config ?? ({} as OpenClawConfig),
changes: [],
autoEnabledReasons: {},
}),

View File

@@ -200,6 +200,7 @@ export type PluginRecord = {
enabled: boolean;
explicitlyEnabled?: boolean;
activated?: boolean;
imported?: boolean;
activationSource?: PluginActivationSource;
activationReason?: string;
status: "loaded" | "disabled" | "error";

View File

@@ -5,7 +5,9 @@ import {
getActivePluginHttpRouteRegistryVersion,
getActivePluginRegistryVersion,
getActivePluginRegistry,
listImportedRuntimePluginIds,
pinActivePluginHttpRouteRegistry,
recordImportedPluginId,
releasePinnedPluginHttpRouteRegistry,
resetPluginRuntimeStateForTest,
resolveActivePluginHttpRouteRegistry,
@@ -180,6 +182,72 @@ describe("setActivePluginRegistry", () => {
setActivePluginRegistry(registry);
expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(1);
});
it("does not treat bundle-only loaded entries as imported runtime plugins", () => {
const registry = createEmptyPluginRegistry();
registry.plugins.push({
id: "bundle-only",
name: "Bundle Only",
source: "/tmp/bundle",
origin: "bundled",
enabled: true,
status: "loaded",
format: "bundle",
toolNames: [],
hookNames: [],
channelIds: [],
cliBackendIds: [],
providerIds: [],
speechProviderIds: [],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
webFetchProviderIds: [],
webSearchProviderIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: true,
});
registry.plugins.push({
id: "runtime-plugin",
name: "Runtime Plugin",
source: "/tmp/runtime",
origin: "workspace",
enabled: true,
status: "loaded",
format: "openclaw",
toolNames: [],
hookNames: [],
channelIds: [],
cliBackendIds: [],
providerIds: [],
speechProviderIds: [],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
webFetchProviderIds: [],
webSearchProviderIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: true,
});
setActivePluginRegistry(registry);
expect(listImportedRuntimePluginIds()).toEqual(["runtime-plugin"]);
});
it("includes plugin ids imported before registration failed", () => {
recordImportedPluginId("broken-plugin");
expect(listImportedRuntimePluginIds()).toEqual(["broken-plugin"]);
});
});
describe("setActivePluginRegistry", () => {

View File

@@ -16,6 +16,7 @@ type RegistryState = {
channel: RegistrySurfaceState;
key: string | null;
runtimeSubagentMode: "default" | "explicit" | "gateway-bindable";
importedPluginIds: Set<string>;
};
const state: RegistryState = (() => {
@@ -38,11 +39,16 @@ const state: RegistryState = (() => {
},
key: null,
runtimeSubagentMode: "default",
importedPluginIds: new Set<string>(),
};
}
return globalState[REGISTRY_STATE];
})();
export function recordImportedPluginId(pluginId: string): void {
state.importedPluginIds.add(pluginId);
}
function installSurfaceRegistry(
surface: RegistrySurfaceState,
registry: PluginRegistry | null,
@@ -190,6 +196,39 @@ export function getActivePluginRegistryVersion(): number {
return state.activeVersion;
}
function collectLoadedPluginIds(
registry: PluginRegistry | null | undefined,
ids: Set<string>,
): void {
if (!registry) {
return;
}
for (const plugin of registry.plugins) {
if (plugin.status === "loaded" && plugin.format !== "bundle") {
ids.add(plugin.id);
}
}
}
/**
* Returns plugin ids that were imported by plugin runtime or registry loading in
* the current process.
*
* This is a process-level view, not a fresh import trace: cached registry reuse
* still counts because the plugin code was loaded earlier in this process.
* Explicit loader import tracking covers plugins that were imported but later
* ended in an error state during registration.
* Bundle-format plugins are excluded because they can be "loaded" from metadata
* without importing any JS entrypoint.
*/
export function listImportedRuntimePluginIds(): string[] {
const imported = new Set(state.importedPluginIds);
collectLoadedPluginIds(state.activeRegistry, imported);
collectLoadedPluginIds(state.channel.registry, imported);
collectLoadedPluginIds(state.httpRoute.registry, imported);
return [...imported].toSorted((left, right) => left.localeCompare(right));
}
export function resetPluginRuntimeStateForTest(): void {
state.activeRegistry = null;
state.activeVersion += 1;
@@ -197,4 +236,5 @@ export function resetPluginRuntimeStateForTest(): void {
installSurfaceRegistry(state.channel, null, false);
state.key = null;
state.runtimeSubagentMode = "default";
state.importedPluginIds.clear();
}

View File

@@ -15,7 +15,10 @@ const applyPluginAutoEnableMock = vi.fn();
const resolveBundledProviderCompatPluginIdsMock = vi.fn();
const withBundledPluginAllowlistCompatMock = vi.fn();
const withBundledPluginEnablementCompatMock = vi.fn();
let buildPluginStatusReport: typeof import("./status.js").buildPluginStatusReport;
const listImportedBundledPluginFacadeIdsMock = vi.fn();
const listImportedRuntimePluginIdsMock = vi.fn();
let buildPluginSnapshotReport: typeof import("./status.js").buildPluginSnapshotReport;
let buildPluginDiagnosticsReport: typeof import("./status.js").buildPluginDiagnosticsReport;
let buildPluginInspectReport: typeof import("./status.js").buildPluginInspectReport;
let buildAllPluginInspectReports: typeof import("./status.js").buildAllPluginInspectReports;
let buildPluginCompatibilityNotices: typeof import("./status.js").buildPluginCompatibilityNotices;
@@ -47,6 +50,15 @@ vi.mock("./bundled-compat.js", () => ({
withBundledPluginEnablementCompatMock(...args),
}));
vi.mock("../plugin-sdk/facade-runtime.js", () => ({
listImportedBundledPluginFacadeIds: (...args: unknown[]) =>
listImportedBundledPluginFacadeIdsMock(...args),
}));
vi.mock("./runtime.js", () => ({
listImportedRuntimePluginIds: (...args: unknown[]) => listImportedRuntimePluginIdsMock(...args),
}));
vi.mock("../agents/agent-scope.js", () => ({
resolveAgentWorkspaceDir: () => undefined,
resolveDefaultAgentId: () => "default",
@@ -92,6 +104,7 @@ function expectPluginLoaderCall(params: {
autoEnabledReasons?: Record<string, string[]>;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
loadModules?: boolean;
}) {
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
@@ -104,6 +117,7 @@ function expectPluginLoaderCall(params: {
: {}),
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
...(params.env ? { env: params.env } : {}),
...(params.loadModules !== undefined ? { loadModules: params.loadModules } : {}),
}),
);
}
@@ -227,14 +241,15 @@ function expectBundleInspectState(
expect(inspect.shape).toBe(params.shape);
}
describe("buildPluginStatusReport", () => {
describe("plugin status reports", () => {
beforeAll(async () => {
({
buildAllPluginInspectReports,
buildPluginCompatibilityNotices,
buildPluginDiagnosticsReport,
buildPluginCompatibilityWarnings,
buildPluginInspectReport,
buildPluginStatusReport,
buildPluginSnapshotReport,
formatPluginCompatibilityNotice,
summarizePluginCompatibility,
} = await import("./status.js"));
@@ -247,6 +262,8 @@ describe("buildPluginStatusReport", () => {
resolveBundledProviderCompatPluginIdsMock.mockReset();
withBundledPluginAllowlistCompatMock.mockReset();
withBundledPluginEnablementCompatMock.mockReset();
listImportedBundledPluginFacadeIdsMock.mockReset();
listImportedRuntimePluginIdsMock.mockReset();
loadConfigMock.mockReturnValue({});
applyPluginAutoEnableMock.mockImplementation((params: { config: unknown }) => ({
config: params.config,
@@ -260,13 +277,15 @@ describe("buildPluginStatusReport", () => {
withBundledPluginEnablementCompatMock.mockImplementation(
(params: { config: unknown }) => params.config,
);
listImportedBundledPluginFacadeIdsMock.mockReturnValue([]);
listImportedRuntimePluginIdsMock.mockReturnValue([]);
setPluginLoadResult({ plugins: [] });
});
it("forwards an explicit env to plugin loading", () => {
const env = { HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv;
buildPluginStatusReport({
buildPluginSnapshotReport({
config: {},
workspaceDir: "/workspace",
env,
@@ -276,9 +295,22 @@ describe("buildPluginStatusReport", () => {
config: {},
workspaceDir: "/workspace",
env,
loadModules: false,
});
});
it("uses a non-activating snapshot load for snapshot reports", () => {
buildPluginSnapshotReport({ config: {}, workspaceDir: "/workspace" });
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
activate: false,
cache: false,
loadModules: false,
}),
);
});
it("loads plugin status from the auto-enabled config snapshot", () => {
const { rawConfig, autoEnabledConfig } = createAutoEnabledStatusConfig(
{
@@ -294,7 +326,7 @@ describe("buildPluginStatusReport", () => {
},
});
buildPluginStatusReport({ config: rawConfig });
buildPluginSnapshotReport({ config: rawConfig });
expectAutoEnabledStatusLoad({
rawConfig,
@@ -303,6 +335,7 @@ describe("buildPluginStatusReport", () => {
demo: ["demo configured"],
},
});
expectPluginLoaderCall({ loadModules: false });
});
it("uses the auto-enabled config snapshot for inspect policy summaries", () => {
@@ -345,6 +378,7 @@ describe("buildPluginStatusReport", () => {
allowedModels: ["openai/gpt-5.4"],
hasAllowedModelsConfig: true,
});
expectPluginLoaderCall({ loadModules: true });
});
it("preserves raw config activation context when compatibility notices build their own report", () => {
@@ -386,6 +420,7 @@ describe("buildPluginStatusReport", () => {
demo: ["demo configured"],
},
});
expectPluginLoaderCall({ loadModules: true });
});
it("applies the full bundled provider compat chain before loading plugins", () => {
@@ -395,7 +430,7 @@ describe("buildPluginStatusReport", () => {
withBundledPluginAllowlistCompatMock.mockReturnValue(compatConfig);
withBundledPluginEnablementCompatMock.mockReturnValue(enabledConfig);
buildPluginStatusReport({ config });
buildPluginSnapshotReport({ config });
expectBundledCompatChainApplied({
config,
@@ -417,7 +452,7 @@ describe("buildPluginStatusReport", () => {
}),
);
const report = buildPluginStatusReport({
const report = buildPluginDiagnosticsReport({
config: {},
env: {
OPENCLAW_VERSION: "2026.3.23-1",
@@ -427,6 +462,63 @@ describe("buildPluginStatusReport", () => {
expect(report.plugins[0]?.version).toBe("2026.3.23");
});
it("marks plugins as imported when runtime or facade state has loaded them", () => {
setPluginLoadResult({
plugins: [
createPluginRecord({ id: "runtime-loaded" }),
createPluginRecord({ id: "facade-loaded" }),
createPluginRecord({ id: "bundle-loaded", format: "bundle" }),
createPluginRecord({ id: "cold-plugin" }),
],
});
listImportedRuntimePluginIdsMock.mockReturnValue(["runtime-loaded", "bundle-loaded"]);
listImportedBundledPluginFacadeIdsMock.mockReturnValue(["facade-loaded"]);
const report = buildPluginSnapshotReport({ config: {} });
expect(report.plugins).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "runtime-loaded", imported: true }),
expect.objectContaining({ id: "facade-loaded", imported: true }),
expect.objectContaining({ id: "bundle-loaded", imported: false }),
expect.objectContaining({ id: "cold-plugin", imported: false }),
]),
);
});
it("marks snapshot-loaded plugin modules as imported during full report loads", () => {
setPluginLoadResult({
plugins: [
createPluginRecord({ id: "runtime-loaded" }),
createPluginRecord({ id: "bundle-loaded", format: "bundle" }),
],
});
const report = buildPluginDiagnosticsReport({ config: {} });
expect(report.plugins).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "runtime-loaded", imported: true }),
expect.objectContaining({ id: "bundle-loaded", imported: false }),
]),
);
});
it("marks errored plugin modules as imported when full diagnostics already evaluated them", () => {
setPluginLoadResult({
plugins: [createPluginRecord({ id: "broken-plugin", status: "error" })],
});
listImportedRuntimePluginIdsMock.mockReturnValue(["broken-plugin"]);
const report = buildPluginDiagnosticsReport({ config: {} });
expect(report.plugins).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "broken-plugin", status: "error", imported: true }),
]),
);
});
it("builds an inspect report with capability shape and policy", () => {
loadConfigMock.mockReturnValue({
plugins: {

View File

@@ -4,6 +4,7 @@ import { loadConfig } from "../config/config.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { normalizeOpenClawVersionBase } from "../config/version.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { listImportedBundledPluginFacadeIds } from "../plugin-sdk/facade-runtime.js";
import { resolveCompatibilityHostVersion } from "../version.js";
import { inspectBundleLspRuntimeSupport } from "./bundle-lsp.js";
import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js";
@@ -16,6 +17,7 @@ import { loadOpenClawPlugins } from "./loader.js";
import { createPluginLoaderLogger } from "./logger.js";
import { resolveBundledProviderCompatPluginIds } from "./providers.js";
import type { PluginRegistry } from "./registry.js";
import { listImportedRuntimePluginIds } from "./runtime.js";
import type { PluginDiagnostic, PluginHookName } from "./types.js";
export type PluginStatusReport = PluginRegistry & {
@@ -147,12 +149,17 @@ function resolveReportedPluginVersion(
);
}
export function buildPluginStatusReport(params?: {
type PluginReportParams = {
config?: ReturnType<typeof loadConfig>;
workspaceDir?: string;
/** Use an explicit env when plugin roots should resolve independently from process.env. */
env?: NodeJS.ProcessEnv;
}): PluginStatusReport {
};
function buildPluginReport(
params: PluginReportParams | undefined,
loadModules: boolean,
): PluginStatusReport {
const rawConfig = params?.config ?? loadConfig();
const autoEnabled = resolveStatusConfig(rawConfig, params?.env);
const config = autoEnabled.config;
@@ -188,18 +195,39 @@ export function buildPluginStatusReport(params?: {
workspaceDir,
env: params?.env,
logger: createPluginLoaderLogger(log),
activate: false,
cache: false,
loadModules,
});
const importedPluginIds = new Set([
...(loadModules
? registry.plugins
.filter((plugin) => plugin.status === "loaded" && plugin.format !== "bundle")
.map((plugin) => plugin.id)
: []),
...listImportedRuntimePluginIds(),
...listImportedBundledPluginFacadeIds(),
]);
return {
workspaceDir,
...registry,
plugins: registry.plugins.map((plugin) => ({
...plugin,
imported: plugin.format !== "bundle" && importedPluginIds.has(plugin.id),
version: resolveReportedPluginVersion(plugin, params?.env),
})),
};
}
export function buildPluginSnapshotReport(params?: PluginReportParams): PluginStatusReport {
return buildPluginReport(params, false);
}
export function buildPluginDiagnosticsReport(params?: PluginReportParams): PluginStatusReport {
return buildPluginReport(params, true);
}
function buildCapabilityEntries(plugin: PluginRegistry["plugins"][number]) {
return [
{ kind: "cli-backend" as const, ids: plugin.cliBackendIds ?? [] },
@@ -255,7 +283,7 @@ export function buildPluginInspectReport(params: {
const config = resolvedConfig.config;
const report =
params.report ??
buildPluginStatusReport({
buildPluginDiagnosticsReport({
config: rawConfig,
workspaceDir: params.workspaceDir,
env: params.env,
@@ -388,7 +416,7 @@ export function buildAllPluginInspectReports(params?: {
const rawConfig = params?.config ?? loadConfig();
const report =
params?.report ??
buildPluginStatusReport({
buildPluginDiagnosticsReport({
config: rawConfig,
workspaceDir: params?.workspaceDir,
env: params?.env,

View File

@@ -12,6 +12,7 @@ import type {
} from "../agents/auth-profiles/types.js";
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
import type { ProviderCapabilities } from "../agents/provider-capabilities.js";
import type { ProviderRequestTransportOverrides } from "../agents/provider-request-config.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
import type { ThinkLevel } from "../auto-reply/thinking.js";
import type { ReplyPayload } from "../auto-reply/types.js";
@@ -445,6 +446,7 @@ export type ProviderPrepareRuntimeAuthContext = {
export type ProviderPreparedRuntimeAuth = {
apiKey: string;
baseUrl?: string;
request?: ProviderRequestTransportOverrides;
expiresAt?: number;
};

View File

@@ -306,9 +306,9 @@ describe("resolvePluginWebSearchProviders", () => {
applyPluginAutoEnableSpy = vi
.spyOn(pluginAutoEnableModule, "applyPluginAutoEnable")
.mockImplementation(
(params: { config: unknown }) =>
(params) =>
({
config: params.config,
config: params.config ?? {},
changes: [],
autoEnabledReasons: {},
}) as ReturnType<PluginAutoEnableModule["applyPluginAutoEnable"]>,

View File

@@ -10,6 +10,7 @@ import {
import { isSecretsApplyPlan } from "./plan.js";
import { isValidExecSecretRefId } from "./ref-contract.js";
import { materializePathTokens, parsePathPattern } from "./target-registry-pattern.js";
import { canonicalizeSecretTargetCoverageId } from "./target-registry-test-helpers.js";
import { listSecretTargetRegistryEntries } from "./target-registry.js";
describe("exec SecretRef id parity", () => {
@@ -63,52 +64,56 @@ describe("exec SecretRef id parity", () => {
}
function classifyTargetClass(id: string): string {
if (id.startsWith("auth-profiles.")) {
const canonicalId = canonicalizeSecretTargetCoverageId(id);
if (canonicalId.startsWith("auth-profiles.")) {
return "auth-profiles";
}
if (id.startsWith("agents.")) {
if (canonicalId.startsWith("agents.")) {
return "agents";
}
if (id.startsWith("channels.")) {
if (canonicalId.startsWith("channels.")) {
return "channels";
}
if (id.startsWith("cron.")) {
if (canonicalId.startsWith("cron.")) {
return "cron";
}
if (id.startsWith("gateway.auth.")) {
if (canonicalId.startsWith("gateway.auth.")) {
return "gateway.auth";
}
if (id.startsWith("gateway.remote.")) {
if (canonicalId.startsWith("gateway.remote.")) {
return "gateway.remote";
}
if (id.startsWith("messages.")) {
if (canonicalId.startsWith("messages.")) {
return "messages";
}
if (id.startsWith("models.providers.") && id.includes(".headers.")) {
if (canonicalId.startsWith("models.providers.") && canonicalId.includes(".headers.")) {
return "models.headers";
}
if (id.startsWith("models.providers.")) {
if (canonicalId.startsWith("models.providers.")) {
return "models.apiKey";
}
if (id.startsWith("skills.entries.")) {
if (canonicalId.startsWith("skills.entries.")) {
return "skills";
}
if (id.startsWith("talk.")) {
if (canonicalId.startsWith("talk.")) {
return "talk";
}
if (id.startsWith("tools.web.fetch.")) {
if (canonicalId.startsWith("tools.web.fetch.")) {
return "tools.web.fetch";
}
if (id.startsWith("plugins.entries.") && id.includes(".config.webFetch.apiKey")) {
if (
canonicalId.startsWith("plugins.entries.") &&
canonicalId.includes(".config.webFetch.apiKey")
) {
return "tools.web.fetch";
}
if (id.startsWith("plugins.entries.") && id.includes(".config.webSearch.apiKey")) {
if (
canonicalId.startsWith("plugins.entries.") &&
canonicalId.includes(".config.webSearch.apiKey")
) {
return "tools.web.search";
}
if (id.startsWith("plugins.entries.") && id.includes(".config.webFetch.apiKey")) {
return "tools.web.fetch";
}
if (id.startsWith("tools.web.search.")) {
if (canonicalId.startsWith("tools.web.search.")) {
return "tools.web.search";
}
return "unclassified";

View File

@@ -3,6 +3,7 @@ import type { AuthProfileStore } from "../agents/auth-profiles.js";
import type { OpenClawConfig } from "../config/config.js";
import type { PluginWebSearchProviderEntry } from "../plugins/types.js";
import { getPath, setPathCreateStrict } from "./path-utils.js";
import { canonicalizeSecretTargetCoverageId } from "./target-registry-test-helpers.js";
import { listSecretTargetRegistryEntries } from "./target-registry.js";
type SecretRegistryEntry = ReturnType<typeof listSecretTargetRegistryEntries>[number];
@@ -112,6 +113,10 @@ function resolveCoverageEnvId(entry: SecretRegistryEntry, fallbackEnvId: string)
: fallbackEnvId;
}
function resolveCoverageResolvedPath(entry: SecretRegistryEntry): string {
return canonicalizeSecretTargetCoverageId(entry.id);
}
function buildConfigForOpenClawTarget(entry: SecretRegistryEntry, envId: string): OpenClawConfig {
const config = {} as OpenClawConfig;
const resolvedEnvId = resolveCoverageEnvId(entry, envId);
@@ -262,7 +267,10 @@ describe("secrets runtime target coverage", () => {
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
});
const resolved = getPath(snapshot.config, toConcretePathSegments(entry.pathPattern));
const resolved = getPath(
snapshot.config,
toConcretePathSegments(resolveCoverageResolvedPath(entry)),
);
if (entry.expectedResolvedValue === "string") {
expect(resolved).toBe(expectedValue);
} else {

View File

@@ -0,0 +1,3 @@
export function canonicalizeSecretTargetCoverageId(id: string): string {
return id === "tools.web.x_search.apiKey" ? "plugins.entries.xai.config.webSearch.apiKey" : id;
}

View File

@@ -68,6 +68,7 @@ describe("web fetch runtime", () => {
});
beforeEach(() => {
vi.unstubAllEnvs();
resolveBundledPluginWebFetchProvidersMock.mockReset();
resolveRuntimeWebFetchProvidersMock.mockReset();
resolveBundledPluginWebFetchProvidersMock.mockReturnValue([]);
@@ -78,7 +79,7 @@ describe("web fetch runtime", () => {
clearSecretsRuntimeSnapshot();
});
it("does not auto-detect providers from env SecretRefs without runtime metadata", () => {
it("does not auto-detect providers from plugin-owned env SecretRefs without runtime metadata", () => {
const provider = createProvider({
pluginId: "firecrawl",
id: "firecrawl",
@@ -112,6 +113,8 @@ describe("web fetch runtime", () => {
},
};
vi.stubEnv("FIRECRAWL_API_KEY", "");
expect(resolveWebFetchDefinition({ config })).toBeNull();
});