mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
23 Commits
codex/para
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d93091dad7 | ||
|
|
316d10637b | ||
|
|
65c1716ad4 | ||
|
|
ef86edacf7 | ||
|
|
b40ef364b7 | ||
|
|
4f692190b4 | ||
|
|
e0d20966ae | ||
|
|
0e3cc12900 | ||
|
|
c4fb15e492 | ||
|
|
ecf72319ed | ||
|
|
bb3f17fc02 | ||
|
|
b0f94a227b | ||
|
|
4269f40811 | ||
|
|
c678ae7e7a | ||
|
|
0500b410c5 | ||
|
|
def5b954a8 | ||
|
|
1ecd92af89 | ||
|
|
a1f95e5278 | ||
|
|
41b81ca7f8 | ||
|
|
59eccef768 | ||
|
|
e45b29b247 | ||
|
|
fcf708665c | ||
|
|
290e5bf219 |
24
CHANGELOG.md
24
CHANGELOG.md
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) },
|
||||
|
||||
7
apps/android/app/src/main/res/values/assistant.xml
Normal file
7
apps/android/app/src/main/res/values/assistant.xml
Normal 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>
|
||||
17
apps/android/app/src/main/res/xml/shortcuts.xml
Normal file
17
apps/android/app/src/main/res/xml/shortcuts.xml
Normal 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>
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 don’t want remote execution, set security to **deny** and remove node pairing for that Mac.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
106
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
208
src/agents/pi-embedded-runner/run/auth-controller.test.ts
Normal file
208
src/agents/pi-embedded-runner/run/auth-controller.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -436,7 +436,8 @@ describe("exec approvals CLI", () => {
|
||||
effective: "always",
|
||||
}),
|
||||
askFallback: expect.objectContaining({
|
||||
source: "OpenClaw default (deny)",
|
||||
effective: "full",
|
||||
source: "OpenClaw default (full)",
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
|
||||
83
src/cli/plugins-cli.list.test.ts
Normal file
83
src/cli/plugins-cli.list.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
|
||||
@@ -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: [] };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] } });
|
||||
|
||||
|
||||
@@ -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[]>();
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }>({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
103
src/plugins/manifest.json5-tolerance.test.ts
Normal file
103
src/plugins/manifest.json5-tolerance.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -200,7 +200,7 @@ describe("resolvePluginProviders", () => {
|
||||
applyPluginAutoEnableMock.mockReset();
|
||||
applyPluginAutoEnableMock.mockImplementation(
|
||||
(params): PluginAutoEnableResult => ({
|
||||
config: params.config,
|
||||
config: params.config ?? ({} as OpenClawConfig),
|
||||
changes: [],
|
||||
autoEnabledReasons: {},
|
||||
}),
|
||||
|
||||
@@ -200,6 +200,7 @@ export type PluginRecord = {
|
||||
enabled: boolean;
|
||||
explicitlyEnabled?: boolean;
|
||||
activated?: boolean;
|
||||
imported?: boolean;
|
||||
activationSource?: PluginActivationSource;
|
||||
activationReason?: string;
|
||||
status: "loaded" | "disabled" | "error";
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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"]>,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
3
src/secrets/target-registry-test-helpers.ts
Normal file
3
src/secrets/target-registry-test-helpers.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user